changes
This commit is contained in:
39
api/package-lock.json
generated
39
api/package-lock.json
generated
@@ -604,9 +604,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -621,9 +618,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -638,9 +632,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -655,9 +646,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -672,9 +660,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -689,9 +674,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -706,9 +688,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -723,9 +702,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -740,9 +716,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -757,9 +730,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -774,9 +744,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -791,9 +758,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -808,9 +772,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ const settingsSchema = z.object({
|
|||||||
const userUpdateSchema = z.object({
|
const userUpdateSchema = z.object({
|
||||||
role: z.enum(['ADMIN', 'USER']).optional(),
|
role: z.enum(['ADMIN', 'USER']).optional(),
|
||||||
isActive: z.boolean().optional(),
|
isActive: z.boolean().optional(),
|
||||||
defaultCurrency: z.string().min(3).max(8).optional()
|
defaultCurrency: z.string().min(3).max(8).optional(),
|
||||||
|
integrationsEnabled: z.boolean().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
@@ -172,6 +173,7 @@ export const updateUser = async (req: AuthenticatedRequest, res: Response) => {
|
|||||||
if (parsed.data.role) item.role = parsed.data.role;
|
if (parsed.data.role) item.role = parsed.data.role;
|
||||||
if (typeof parsed.data.isActive === 'boolean') item.isActive = parsed.data.isActive;
|
if (typeof parsed.data.isActive === 'boolean') item.isActive = parsed.data.isActive;
|
||||||
if (parsed.data.defaultCurrency) item.defaultCurrency = parsed.data.defaultCurrency;
|
if (parsed.data.defaultCurrency) item.defaultCurrency = parsed.data.defaultCurrency;
|
||||||
|
if (typeof parsed.data.integrationsEnabled === 'boolean') item.integrationsEnabled = parsed.data.integrationsEnabled;
|
||||||
|
|
||||||
await userRepo().save(item);
|
await userRepo().save(item);
|
||||||
return res.json({ item: sanitizeUser(item) });
|
return res.json({ item: sanitizeUser(item) });
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ const serializeBudget = (item: Budget, spent: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getMonthRange = (month: string) => ({ startDate: `${month}-01`, endDate: `${month}-31` });
|
const getMonthRange = (month: string) => ({ startDate: `${month}-01`, endDate: `${month}-31` });
|
||||||
|
const normalizeNullableText = (value: unknown) => {
|
||||||
|
if (value === null || value === undefined) return undefined;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
};
|
||||||
|
|
||||||
export const listBudgets = async (req: AuthenticatedRequest, res: Response) => {
|
export const listBudgets = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
await processDueRecurringExpenses(req.user!.id);
|
await processDueRecurringExpenses(req.user!.id);
|
||||||
@@ -96,7 +101,7 @@ export const listBudgets = async (req: AuthenticatedRequest, res: Response) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createBudget = async (req: AuthenticatedRequest, res: Response) => {
|
export const createBudget = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const parsed = budgetSchema.safeParse(req.body);
|
const parsed = budgetSchema.safeParse({ ...req.body, name: normalizeNullableText(req.body?.name), categoryId: normalizeNullableText(req.body?.categoryId) });
|
||||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid budget payload', issues: parsed.error.issues });
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid budget payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
const category = parsed.data.categoryId
|
const category = parsed.data.categoryId
|
||||||
@@ -125,7 +130,7 @@ export const createBudget = async (req: AuthenticatedRequest, res: Response) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const updateBudget = async (req: AuthenticatedRequest, res: Response) => {
|
export const updateBudget = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const parsed = budgetSchema.safeParse(req.body);
|
const parsed = budgetSchema.safeParse({ ...req.body, name: normalizeNullableText(req.body?.name), categoryId: normalizeNullableText(req.body?.categoryId) });
|
||||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid budget payload', issues: parsed.error.issues });
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid budget payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
const item = await budgetRepo().findOne({ where: { id: String(req.params.id), user: { id: req.user!.id } }, relations: { category: { user: true }, user: true } });
|
const item = await budgetRepo().findOne({ where: { id: String(req.params.id), user: { id: req.user!.id } }, relations: { category: { user: true }, user: true } });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
import { In } from 'typeorm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { AppDataSource } from '../config/data-source.js';
|
import { AppDataSource } from '../config/data-source.js';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
@@ -16,6 +17,10 @@ const paymentMethodSchema = z.enum(['CARD', 'CASH', 'TRANSFER', 'BLIK', 'OTHER']
|
|||||||
const proofTypeSchema = z.enum(['RECEIPT', 'INVOICE', 'NOTE', 'BANK_STATEMENT', 'OTHER']);
|
const proofTypeSchema = z.enum(['RECEIPT', 'INVOICE', 'NOTE', 'BANK_STATEMENT', 'OTHER']);
|
||||||
const statusSchema = z.enum(['DRAFT', 'PENDING', 'APPROVED', 'REJECTED']);
|
const statusSchema = z.enum(['DRAFT', 'PENDING', 'APPROVED', 'REJECTED']);
|
||||||
const duplicateReviewSchema = z.object({ action: z.enum(['CONFIRM', 'DISMISS', 'REOPEN']) });
|
const duplicateReviewSchema = z.object({ action: z.enum(['CONFIRM', 'DISMISS', 'REOPEN']) });
|
||||||
|
const expenseStatusUpdateSchema = z.object({ status: statusSchema });
|
||||||
|
const bulkIdsSchema = z.array(z.string().uuid()).min(1).max(200);
|
||||||
|
const bulkExpenseStatusUpdateSchema = z.object({ ids: bulkIdsSchema, status: statusSchema });
|
||||||
|
const bulkExpenseDeleteSchema = z.object({ ids: bulkIdsSchema });
|
||||||
|
|
||||||
const createExpenseSchema = z.object({
|
const createExpenseSchema = z.object({
|
||||||
title: z.string().min(2).max(140),
|
title: z.string().min(2).max(140),
|
||||||
@@ -45,7 +50,11 @@ const updateExpenseSchema = z.object({
|
|||||||
currency: z.string().min(3).max(8).default('PLN'),
|
currency: z.string().min(3).max(8).default('PLN'),
|
||||||
status: statusSchema.default('PENDING'),
|
status: statusSchema.default('PENDING'),
|
||||||
tags: z.array(z.string().min(1).max(40)).default([]),
|
tags: z.array(z.string().min(1).max(40)).default([]),
|
||||||
customFields: z.record(z.string(), z.string()).default({})
|
customFields: z.record(z.string(), z.string()).default({}),
|
||||||
|
proofType: proofTypeSchema.optional(),
|
||||||
|
proofLabel: z.string().max(150).nullable().optional(),
|
||||||
|
proofNote: z.string().max(1000).nullable().optional(),
|
||||||
|
removeProofIds: z.array(z.string().uuid()).default([])
|
||||||
});
|
});
|
||||||
|
|
||||||
const addProofSchema = z.object({
|
const addProofSchema = z.object({
|
||||||
@@ -75,6 +84,53 @@ const removeUploadedFiles = (files: Express.Multer.File[]) => {
|
|||||||
files.forEach((file) => removeUploadedFile(file.filename));
|
files.forEach((file) => removeUploadedFile(file.filename));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const slugifyForFilename = (value: string) =>
|
||||||
|
value
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 80) || 'expense';
|
||||||
|
|
||||||
|
const deriveFileExtension = (file: Express.Multer.File) => {
|
||||||
|
const fromOriginal = path.extname(file.originalname || '').trim();
|
||||||
|
if (fromOriginal) return fromOriginal.toLowerCase();
|
||||||
|
const fromStored = path.extname(file.filename || '').trim();
|
||||||
|
if (fromStored) return fromStored.toLowerCase();
|
||||||
|
if ((file.mimetype || '').toLowerCase().includes('pdf')) return '.pdf';
|
||||||
|
if ((file.mimetype || '').toLowerCase().includes('png')) return '.png';
|
||||||
|
if ((file.mimetype || '').toLowerCase().includes('jpeg') || (file.mimetype || '').toLowerCase().includes('jpg')) return '.jpg';
|
||||||
|
if ((file.mimetype || '').toLowerCase().includes('webp')) return '.webp';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const renameUploadedFilesForExpense = (files: Express.Multer.File[], expenseTitle: string, proofType?: string | null) => {
|
||||||
|
const base = [slugifyForFilename(expenseTitle), proofType?.toLowerCase() || null].filter(Boolean).join('-');
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
const extension = deriveFileExtension(file);
|
||||||
|
const nextFilename = `${base || 'expense'}-${Date.now()}-${index + 1}${extension}`.slice(0, 220);
|
||||||
|
const fromPath = path.resolve(env.UPLOAD_DIR, file.filename);
|
||||||
|
const toPath = path.resolve(env.UPLOAD_DIR, nextFilename);
|
||||||
|
if (fromPath !== toPath && fs.existsSync(fromPath)) fs.renameSync(fromPath, toPath);
|
||||||
|
file.filename = nextFilename;
|
||||||
|
file.originalname = nextFilename;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeIdList = (value: unknown) => {
|
||||||
|
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (!value.trim()) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (Array.isArray(parsed)) return parsed.map((item) => String(item).trim()).filter(Boolean);
|
||||||
|
} catch {}
|
||||||
|
return value.split(',').map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeTagList = (value: unknown) => {
|
const normalizeTagList = (value: unknown) => {
|
||||||
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
|
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
@@ -116,7 +172,8 @@ const normalizeCustomFields = (value: unknown) => {
|
|||||||
const enrichPayload = (body: Record<string, unknown>) => ({
|
const enrichPayload = (body: Record<string, unknown>) => ({
|
||||||
...body,
|
...body,
|
||||||
tags: normalizeTagList(body.tags),
|
tags: normalizeTagList(body.tags),
|
||||||
customFields: normalizeCustomFields(body.customFields)
|
customFields: normalizeCustomFields(body.customFields),
|
||||||
|
removeProofIds: normalizeIdList(body.removeProofIds)
|
||||||
});
|
});
|
||||||
|
|
||||||
const serializeExpense = (expense: Expense) => ({
|
const serializeExpense = (expense: Expense) => ({
|
||||||
@@ -142,7 +199,7 @@ const serializeExpense = (expense: Expense) => ({
|
|||||||
isSystem: expense.category.isSystem,
|
isSystem: expense.category.isSystem,
|
||||||
ownerId: expense.category.user?.id ?? null
|
ownerId: expense.category.user?.id ?? null
|
||||||
},
|
},
|
||||||
proofs: expense.proofs?.map(serializeProof) ?? [],
|
proofs: expense.proofs?.map((proof) => serializeProof(proof, expense.id)) ?? [],
|
||||||
createdAt: expense.createdAt,
|
createdAt: expense.createdAt,
|
||||||
updatedAt: expense.updatedAt
|
updatedAt: expense.updatedAt
|
||||||
});
|
});
|
||||||
@@ -191,6 +248,9 @@ const hydrateExpense = (id: string) =>
|
|||||||
|
|
||||||
const parseFilterArray = (value: string | undefined) => value?.split(',').map((item) => item.trim()).filter(Boolean) ?? [];
|
const parseFilterArray = (value: string | undefined) => value?.split(',').map((item) => item.trim()).filter(Boolean) ?? [];
|
||||||
|
|
||||||
|
const sortBySchema = z.enum(['expenseDate', 'title', 'amount', 'status', 'category', 'merchant', 'createdAt', 'updatedAt']).catch('expenseDate');
|
||||||
|
const sortDirSchema = z.enum(['asc', 'desc']).catch('desc');
|
||||||
|
|
||||||
const initialStatuses: ExpenseStatus[] = ['DRAFT', 'PENDING', 'APPROVED'];
|
const initialStatuses: ExpenseStatus[] = ['DRAFT', 'PENDING', 'APPROVED'];
|
||||||
const transitionMap: Record<ExpenseStatus, ExpenseStatus[]> = {
|
const transitionMap: Record<ExpenseStatus, ExpenseStatus[]> = {
|
||||||
DRAFT: ['DRAFT', 'PENDING', 'REJECTED'],
|
DRAFT: ['DRAFT', 'PENDING', 'REJECTED'],
|
||||||
@@ -200,9 +260,25 @@ const transitionMap: Record<ExpenseStatus, ExpenseStatus[]> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const validateInitialStatus = (nextStatus: ExpenseStatus) => initialStatuses.includes(nextStatus);
|
const validateInitialStatus = (nextStatus: ExpenseStatus) => initialStatuses.includes(nextStatus);
|
||||||
|
const parsePositiveInt = (value: unknown, fallback: number, max = 100) => {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) return fallback;
|
||||||
|
return Math.min(Math.max(Math.trunc(parsed), 1), max);
|
||||||
|
};
|
||||||
const validateStatusTransition = (currentStatus: ExpenseStatus, nextStatus: ExpenseStatus) => (transitionMap[currentStatus] ?? []).includes(nextStatus);
|
const validateStatusTransition = (currentStatus: ExpenseStatus, nextStatus: ExpenseStatus) => (transitionMap[currentStatus] ?? []).includes(nextStatus);
|
||||||
const approvalNeedsProof = (nextStatus: ExpenseStatus) => nextStatus === 'APPROVED';
|
const approvalNeedsProof = (nextStatus: ExpenseStatus) => nextStatus === 'APPROVED';
|
||||||
|
|
||||||
|
const loadOwnedExpenses = async (ids: string[], user: AuthenticatedRequest['user']) => {
|
||||||
|
const where = user?.role === 'ADMIN'
|
||||||
|
? { id: In(ids) }
|
||||||
|
: { id: In(ids), user: { id: user!.id } };
|
||||||
|
|
||||||
|
return expenseRepo().find({
|
||||||
|
where,
|
||||||
|
relations: { user: true, category: { user: true }, proofs: true }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const applyDuplicateState = (expense: Expense, duplicates: Expense[]) => {
|
const applyDuplicateState = (expense: Expense, duplicates: Expense[]) => {
|
||||||
if (!duplicates.length) {
|
if (!duplicates.length) {
|
||||||
expense.possibleDuplicate = false;
|
expense.possibleDuplicate = false;
|
||||||
@@ -225,9 +301,13 @@ export const listExpenses = async (req: AuthenticatedRequest, res: Response) =>
|
|||||||
const endDate = typeof req.query.endDate === 'string' ? req.query.endDate : undefined;
|
const endDate = typeof req.query.endDate === 'string' ? req.query.endDate : undefined;
|
||||||
const categoryId = typeof req.query.categoryId === 'string' ? req.query.categoryId : undefined;
|
const categoryId = typeof req.query.categoryId === 'string' ? req.query.categoryId : undefined;
|
||||||
const search = typeof req.query.search === 'string' ? req.query.search.toLowerCase().trim() : undefined;
|
const search = typeof req.query.search === 'string' ? req.query.search.toLowerCase().trim() : undefined;
|
||||||
const status = typeof req.query.status === 'string' ? req.query.status.toUpperCase() : undefined;
|
const status = typeof req.query.status === 'string' ? req.query.status.toUpperCase().trim() : undefined;
|
||||||
const tags = parseFilterArray(typeof req.query.tags === 'string' ? req.query.tags : undefined).map((item) => item.toLowerCase());
|
const tags = parseFilterArray(typeof req.query.tags === 'string' ? req.query.tags : undefined).map((item) => item.toLowerCase());
|
||||||
const duplicatesOnly = String(req.query.duplicatesOnly ?? '') === 'true';
|
const duplicatesOnly = String(req.query.duplicatesOnly ?? '') === 'true';
|
||||||
|
const pageSize = parsePositiveInt(req.query.pageSize, 20, 100);
|
||||||
|
const requestedPage = parsePositiveInt(req.query.page, 1, 100000);
|
||||||
|
const sortBy = sortBySchema.parse(req.query.sortBy);
|
||||||
|
const sortDir = sortDirSchema.parse(req.query.sortDir);
|
||||||
|
|
||||||
const items = await expenseRepo().find({
|
const items = await expenseRepo().find({
|
||||||
where: { user: { id: req.user!.id } },
|
where: { user: { id: req.user!.id } },
|
||||||
@@ -255,7 +335,71 @@ export const listExpenses = async (req: AuthenticatedRequest, res: Response) =>
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({ items: filtered.map(serializeExpense) });
|
const sorted = [...filtered].sort((left, right) => {
|
||||||
|
const direction = sortDir === 'asc' ? 1 : -1;
|
||||||
|
const leftValue = (() => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'title': return left.title ?? '';
|
||||||
|
case 'amount': return left.amount ?? 0;
|
||||||
|
case 'status': return left.status ?? '';
|
||||||
|
case 'category': return left.category?.name ?? '';
|
||||||
|
case 'merchant': return left.merchant ?? '';
|
||||||
|
case 'createdAt': return left.createdAt?.toISOString?.() ?? String(left.createdAt ?? '');
|
||||||
|
case 'updatedAt': return left.updatedAt?.toISOString?.() ?? String(left.updatedAt ?? '');
|
||||||
|
case 'expenseDate':
|
||||||
|
default: return left.expenseDate ?? '';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const rightValue = (() => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'title': return right.title ?? '';
|
||||||
|
case 'amount': return right.amount ?? 0;
|
||||||
|
case 'status': return right.status ?? '';
|
||||||
|
case 'category': return right.category?.name ?? '';
|
||||||
|
case 'merchant': return right.merchant ?? '';
|
||||||
|
case 'createdAt': return right.createdAt?.toISOString?.() ?? String(right.createdAt ?? '');
|
||||||
|
case 'updatedAt': return right.updatedAt?.toISOString?.() ?? String(right.updatedAt ?? '');
|
||||||
|
case 'expenseDate':
|
||||||
|
default: return right.expenseDate ?? '';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (typeof leftValue === 'number' && typeof rightValue === 'number') return (leftValue - rightValue) * direction;
|
||||||
|
return String(leftValue).localeCompare(String(rightValue), 'pl', { sensitivity: 'base' }) * direction;
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = sorted.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const page = Math.min(requestedPage, totalPages);
|
||||||
|
const startIndex = (page - 1) * pageSize;
|
||||||
|
const paged = sorted.slice(startIndex, startIndex + pageSize);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
items: paged.map(serializeExpense),
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
hasPrev: page > 1,
|
||||||
|
hasNext: page < totalPages
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const id = String(req.params.id || '').trim();
|
||||||
|
const where = req.user?.role === 'ADMIN'
|
||||||
|
? { id }
|
||||||
|
: { id, user: { id: req.user!.id } };
|
||||||
|
|
||||||
|
const item = await expenseRepo().findOne({
|
||||||
|
where,
|
||||||
|
relations: { category: { user: true }, proofs: true, user: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!item) return res.status(404).json({ message: 'Expense not found' });
|
||||||
|
return res.json({ item: serializeExpense(item) });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const listDuplicates = async (req: AuthenticatedRequest, res: Response) => {
|
export const listDuplicates = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
@@ -323,6 +467,8 @@ export const createExpense = async (req: AuthenticatedRequest, res: Response) =>
|
|||||||
customFields: parsed.data.customFields
|
customFields: parsed.data.customFields
|
||||||
});
|
});
|
||||||
|
|
||||||
|
renameUploadedFilesForExpense(uploadedFiles, parsed.data.title, parsed.data.proofType);
|
||||||
|
|
||||||
const proofs: Proof[] = uploadedFiles.map((file, index) =>
|
const proofs: Proof[] = uploadedFiles.map((file, index) =>
|
||||||
proofRepo().create({
|
proofRepo().create({
|
||||||
type: parsed.data.proofType ?? 'OTHER',
|
type: parsed.data.proofType ?? 'OTHER',
|
||||||
@@ -377,8 +523,10 @@ export const createExpense = async (req: AuthenticatedRequest, res: Response) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const updateExpense = async (req: AuthenticatedRequest, res: Response) => {
|
export const updateExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const uploadedFiles = getUploadedFiles(req);
|
||||||
const parsed = updateExpenseSchema.safeParse(enrichPayload(req.body as Record<string, unknown>));
|
const parsed = updateExpenseSchema.safeParse(enrichPayload(req.body as Record<string, unknown>));
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
removeUploadedFiles(uploadedFiles);
|
||||||
return res.status(400).json({ message: 'Invalid expense payload', issues: parsed.error.issues });
|
return res.status(400).json({ message: 'Invalid expense payload', issues: parsed.error.issues });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,24 +534,37 @@ export const updateExpense = async (req: AuthenticatedRequest, res: Response) =>
|
|||||||
where: { id: String(req.params.id) },
|
where: { id: String(req.params.id) },
|
||||||
relations: { user: true, category: { user: true }, proofs: true }
|
relations: { user: true, category: { user: true }, proofs: true }
|
||||||
});
|
});
|
||||||
if (!item) return res.status(404).json({ message: 'Expense not found' });
|
if (!item) {
|
||||||
|
removeUploadedFiles(uploadedFiles);
|
||||||
|
return res.status(404).json({ message: 'Expense not found' });
|
||||||
|
}
|
||||||
if (req.user?.role !== 'ADMIN' && item.user.id !== req.user?.id) {
|
if (req.user?.role !== 'ADMIN' && item.user.id !== req.user?.id) {
|
||||||
|
removeUploadedFiles(uploadedFiles);
|
||||||
return res.status(403).json({ message: 'You cannot edit this expense' });
|
return res.status(403).json({ message: 'You cannot edit this expense' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validateStatusTransition(item.status, parsed.data.status)) {
|
if (!validateStatusTransition(item.status, parsed.data.status)) {
|
||||||
|
removeUploadedFiles(uploadedFiles);
|
||||||
return res.status(400).json({ message: `Status transition from ${item.status} to ${parsed.data.status} is not allowed.` });
|
return res.status(400).json({ message: `Status transition from ${item.status} to ${parsed.data.status} is not allowed.` });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (approvalNeedsProof(parsed.data.status) && item.proofs.length === 0) {
|
|
||||||
return res.status(400).json({ message: 'Add at least one attachment before approving an expense.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const category = await categoryRepo().findOne({
|
const category = await categoryRepo().findOne({
|
||||||
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
||||||
relations: { user: true }
|
relations: { user: true }
|
||||||
});
|
});
|
||||||
if (!category) return res.status(404).json({ message: 'Category not found' });
|
if (!category) {
|
||||||
|
removeUploadedFiles(uploadedFiles);
|
||||||
|
return res.status(404).json({ message: 'Category not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const proofIdsToRemove = new Set(parsed.data.removeProofIds);
|
||||||
|
const remainingProofs = item.proofs.filter((proof) => !proofIdsToRemove.has(proof.id));
|
||||||
|
if (approvalNeedsProof(parsed.data.status) && remainingProofs.length + uploadedFiles.length === 0) {
|
||||||
|
removeUploadedFiles(uploadedFiles);
|
||||||
|
return res.status(400).json({ message: 'Add at least one attachment before approving an expense.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
renameUploadedFilesForExpense(uploadedFiles, parsed.data.title, parsed.data.proofType);
|
||||||
|
|
||||||
const duplicates = await findDuplicateMatches({
|
const duplicates = await findDuplicateMatches({
|
||||||
userId: req.user!.id,
|
userId: req.user!.id,
|
||||||
@@ -428,11 +589,122 @@ export const updateExpense = async (req: AuthenticatedRequest, res: Response) =>
|
|||||||
item.category = category;
|
item.category = category;
|
||||||
applyDuplicateState(item, duplicates);
|
applyDuplicateState(item, duplicates);
|
||||||
|
|
||||||
|
if (proofIdsToRemove.size) {
|
||||||
|
const proofsToDelete = item.proofs.filter((proof) => proofIdsToRemove.has(proof.id));
|
||||||
|
proofsToDelete.forEach((proof) => removeUploadedFile(proof.storedName ?? undefined));
|
||||||
|
if (proofsToDelete.length) await proofRepo().remove(proofsToDelete);
|
||||||
|
item.proofs = remainingProofs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdProofs = uploadedFiles.map((file, index) =>
|
||||||
|
proofRepo().create({
|
||||||
|
type: parsed.data.proofType ?? 'OTHER',
|
||||||
|
label: uploadedFiles.length === 1 ? (parsed.data.proofLabel ?? file.originalname ?? 'Attachment') : file.originalname,
|
||||||
|
note: uploadedFiles.length === 1 && index === 0 ? (parsed.data.proofNote ?? null) : null,
|
||||||
|
originalName: file.originalname ?? null,
|
||||||
|
storedName: file.filename ?? null,
|
||||||
|
mimeType: file.mimetype ?? null,
|
||||||
|
fileSize: file.size ?? null,
|
||||||
|
expense: item
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!createdProofs.length && !item.proofs.length && (parsed.data.proofLabel || parsed.data.proofNote || parsed.data.proofType)) {
|
||||||
|
createdProofs.push(
|
||||||
|
proofRepo().create({
|
||||||
|
type: parsed.data.proofType ?? 'OTHER',
|
||||||
|
label: parsed.data.proofLabel ?? 'Attachment',
|
||||||
|
note: parsed.data.proofNote ?? null,
|
||||||
|
originalName: null,
|
||||||
|
storedName: null,
|
||||||
|
mimeType: null,
|
||||||
|
fileSize: null,
|
||||||
|
expense: item
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await expenseRepo().save(item);
|
await expenseRepo().save(item);
|
||||||
|
if (createdProofs.length) await proofRepo().save(createdProofs);
|
||||||
|
|
||||||
const refreshed = await hydrateExpense(item.id);
|
const refreshed = await hydrateExpense(item.id);
|
||||||
return res.json({ item: serializeExpense(refreshed), warnings: buildWarnings(duplicates, parsed.data.amount, parsed.data.expenseDate) });
|
return res.json({ item: serializeExpense(refreshed), warnings: buildWarnings(duplicates, parsed.data.amount, parsed.data.expenseDate) });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const bulkUpdateExpenseStatus = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = bulkExpenseStatusUpdateSchema.safeParse({
|
||||||
|
ids: Array.isArray(req.body?.ids) ? req.body.ids : [],
|
||||||
|
status: typeof req.body?.status === 'string' ? req.body.status.trim().toUpperCase() : req.body?.status
|
||||||
|
});
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid bulk expense status payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
|
const items = await loadOwnedExpenses(parsed.data.ids, req.user);
|
||||||
|
if (items.length !== parsed.data.ids.length) return res.status(404).json({ message: 'One or more expenses were not found' });
|
||||||
|
|
||||||
|
const invalidTransition = items.find((item) => !validateStatusTransition(item.status, parsed.data.status));
|
||||||
|
if (invalidTransition) {
|
||||||
|
return res.status(400).json({ message: `Status transition from ${invalidTransition.status} to ${parsed.data.status} is not allowed for ${invalidTransition.title}.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingProof = items.find((item) => approvalNeedsProof(parsed.data.status) && !item.proofs.length);
|
||||||
|
if (missingProof) {
|
||||||
|
return res.status(400).json({ message: `Add at least one attachment before approving ${missingProof.title}.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
item.status = parsed.data.status;
|
||||||
|
});
|
||||||
|
await expenseRepo().save(items);
|
||||||
|
|
||||||
|
const refreshed = await expenseRepo().find({
|
||||||
|
where: { id: In(items.map((item) => item.id)) },
|
||||||
|
relations: { category: { user: true }, proofs: true, user: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ items: refreshed.map(serializeExpense), updated: refreshed.length });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bulkDeleteExpenses = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = bulkExpenseDeleteSchema.safeParse({ ids: Array.isArray(req.body?.ids) ? req.body.ids : [] });
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid bulk expense delete payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
|
const items = await loadOwnedExpenses(parsed.data.ids, req.user);
|
||||||
|
if (items.length !== parsed.data.ids.length) return res.status(404).json({ message: 'One or more expenses were not found' });
|
||||||
|
|
||||||
|
items.forEach((item) => item.proofs.forEach((proof) => removeUploadedFile(proof.storedName ?? undefined)));
|
||||||
|
await expenseRepo().remove(items);
|
||||||
|
|
||||||
|
return res.json({ deleted: items.length });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateExpenseStatus = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = expenseStatusUpdateSchema.safeParse({ status: typeof req.body?.status === 'string' ? req.body.status.trim().toUpperCase() : req.body?.status });
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid expense status payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
|
const item = await expenseRepo().findOne({
|
||||||
|
where: { id: String(req.params.id) },
|
||||||
|
relations: { user: true, category: { user: true }, proofs: true }
|
||||||
|
});
|
||||||
|
if (!item) return res.status(404).json({ message: 'Expense not found' });
|
||||||
|
if (req.user?.role !== 'ADMIN' && item.user.id !== req.user?.id) {
|
||||||
|
return res.status(403).json({ message: 'You cannot edit this expense' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateStatusTransition(item.status, parsed.data.status)) {
|
||||||
|
return res.status(400).json({ message: `Status transition from ${item.status} to ${parsed.data.status} is not allowed.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approvalNeedsProof(parsed.data.status) && !item.proofs.length) {
|
||||||
|
return res.status(400).json({ message: 'Add at least one attachment before approving an expense.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
item.status = parsed.data.status;
|
||||||
|
await expenseRepo().save(item);
|
||||||
|
|
||||||
|
const refreshed = await hydrateExpense(item.id);
|
||||||
|
return res.json({ item: serializeExpense(refreshed) });
|
||||||
|
};
|
||||||
|
|
||||||
export const reviewDuplicate = async (req: AuthenticatedRequest, res: Response) => {
|
export const reviewDuplicate = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const parsed = duplicateReviewSchema.safeParse(req.body ?? {});
|
const parsed = duplicateReviewSchema.safeParse(req.body ?? {});
|
||||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid duplicate review payload', issues: parsed.error.issues });
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid duplicate review payload', issues: parsed.error.issues });
|
||||||
@@ -494,6 +766,47 @@ export const deleteExpense = async (req: AuthenticatedRequest, res: Response) =>
|
|||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const deleteProof = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const expense = await expenseRepo().findOne({
|
||||||
|
where: { id: String(req.params.id) },
|
||||||
|
relations: { user: true, proofs: true, category: { user: true } }
|
||||||
|
});
|
||||||
|
if (!expense) return res.status(404).json({ message: 'Expense not found' });
|
||||||
|
if (req.user?.role !== 'ADMIN' && expense.user.id !== req.user?.id) {
|
||||||
|
return res.status(403).json({ message: 'You cannot delete proof from this expense' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const proof = expense.proofs.find((item) => item.id === String(req.params.proofId));
|
||||||
|
if (!proof) return res.status(404).json({ message: 'Proof not found' });
|
||||||
|
|
||||||
|
removeUploadedFile(proof.storedName ?? undefined);
|
||||||
|
await proofRepo().remove(proof);
|
||||||
|
|
||||||
|
const refreshed = await hydrateExpense(expense.id);
|
||||||
|
return res.json({ item: serializeExpense(refreshed) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProofFile = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const expense = await expenseRepo().findOne({
|
||||||
|
where: { id: String(req.params.id) },
|
||||||
|
relations: { user: true, proofs: true }
|
||||||
|
});
|
||||||
|
if (!expense) return res.status(404).json({ message: 'Expense not found' });
|
||||||
|
if (req.user?.role !== 'ADMIN' && expense.user.id !== req.user?.id) {
|
||||||
|
return res.status(403).json({ message: 'You cannot access files for this expense' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const proof = expense.proofs.find((item) => item.id === String(req.params.proofId));
|
||||||
|
if (!proof || !proof.storedName) return res.status(404).json({ message: 'Proof file not found' });
|
||||||
|
|
||||||
|
const filePath = path.resolve(env.UPLOAD_DIR, proof.storedName);
|
||||||
|
if (!fs.existsSync(filePath)) return res.status(404).json({ message: 'Proof file not found' });
|
||||||
|
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${proof.originalName || proof.storedName}"`);
|
||||||
|
if (proof.mimeType) res.type(proof.mimeType);
|
||||||
|
return res.sendFile(filePath);
|
||||||
|
};
|
||||||
|
|
||||||
export const addProof = async (req: AuthenticatedRequest, res: Response) => {
|
export const addProof = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const uploadedFiles = getUploadedFiles(req);
|
const uploadedFiles = getUploadedFiles(req);
|
||||||
const parsed = addProofSchema.safeParse(req.body ?? {});
|
const parsed = addProofSchema.safeParse(req.body ?? {});
|
||||||
@@ -515,6 +828,8 @@ export const addProof = async (req: AuthenticatedRequest, res: Response) => {
|
|||||||
return res.status(403).json({ message: 'You cannot add proof to this expense' });
|
return res.status(403).json({ message: 'You cannot add proof to this expense' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renameUploadedFilesForExpense(uploadedFiles, expense.title, parsed.data.type);
|
||||||
|
|
||||||
const createdProofs = uploadedFiles.length
|
const createdProofs = uploadedFiles.length
|
||||||
? uploadedFiles.map((file) =>
|
? uploadedFiles.map((file) =>
|
||||||
proofRepo().create({
|
proofRepo().create({
|
||||||
@@ -543,5 +858,5 @@ export const addProof = async (req: AuthenticatedRequest, res: Response) => {
|
|||||||
|
|
||||||
await proofRepo().save(createdProofs);
|
await proofRepo().save(createdProofs);
|
||||||
const refreshed = await hydrateExpense(expense.id);
|
const refreshed = await hydrateExpense(expense.id);
|
||||||
return res.status(201).json({ proofs: createdProofs.map(serializeProof), expense: serializeExpense(refreshed) });
|
return res.status(201).json({ proofs: createdProofs.map((proof) => serializeProof(proof, expense.id)), expense: serializeExpense(refreshed) });
|
||||||
};
|
};
|
||||||
@@ -54,6 +54,13 @@ const importItemSchema = z.object({
|
|||||||
tags: z.array(z.string().min(1).max(40)).default([])
|
tags: z.array(z.string().min(1).max(40)).default([])
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const importPeriodSchema = z.object({
|
||||||
|
period: z.string().regex(/^\d{4}-\d{2}$/),
|
||||||
|
categoryId: z.string().uuid(),
|
||||||
|
status: importStatusSchema,
|
||||||
|
merchant: z.string().max(120).nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
const userRepo = () => AppDataSource.getRepository(User);
|
const userRepo = () => AppDataSource.getRepository(User);
|
||||||
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
||||||
const categoryRepo = () => AppDataSource.getRepository(Category);
|
const categoryRepo = () => AppDataSource.getRepository(Category);
|
||||||
@@ -72,6 +79,13 @@ const getSettings = async (userId: string) => {
|
|||||||
return user ?? null;
|
return user ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensureIntegrationsAllowed = (user: User) => {
|
||||||
|
if (user.role === 'ADMIN' || user.integrationsEnabled) return;
|
||||||
|
const error = new Error('Integrations are disabled for this user.') as Error & { status?: number };
|
||||||
|
error.status = 403;
|
||||||
|
throw error;
|
||||||
|
};
|
||||||
|
|
||||||
const sanitizeIntegration = (value: User['shoppingListIntegration']) => ({
|
const sanitizeIntegration = (value: User['shoppingListIntegration']) => ({
|
||||||
enabled: Boolean(value?.enabled),
|
enabled: Boolean(value?.enabled),
|
||||||
baseUrl: value?.baseUrl ?? '',
|
baseUrl: value?.baseUrl ?? '',
|
||||||
@@ -93,6 +107,7 @@ const buildHeaders = (config: ShoppingListConfig) => {
|
|||||||
const requireConfig = async (userId: string) => {
|
const requireConfig = async (userId: string) => {
|
||||||
const user = await getSettings(userId);
|
const user = await getSettings(userId);
|
||||||
if (!user) throw new Error('User not found');
|
if (!user) throw new Error('User not found');
|
||||||
|
ensureIntegrationsAllowed(user);
|
||||||
const config = user.shoppingListIntegration;
|
const config = user.shoppingListIntegration;
|
||||||
if (!config?.enabled || !config.baseUrl || !config.apiToken) throw new Error('Shopping list integration is not configured for this user');
|
if (!config?.enabled || !config.baseUrl || !config.apiToken) throw new Error('Shopping list integration is not configured for this user');
|
||||||
return { user, config };
|
return { user, config };
|
||||||
@@ -179,6 +194,16 @@ const deriveListDate = (items: Record<string, unknown>[], listCreatedAt?: string
|
|||||||
return itemDates[itemDates.length - 1] ?? readDate(listCreatedAt) ?? new Date().toISOString().slice(0, 10);
|
return itemDates[itemDates.length - 1] ?? readDate(listCreatedAt) ?? new Date().toISOString().slice(0, 10);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const monthRange = (period: string) => {
|
||||||
|
const [yearText, monthText] = period.split('-');
|
||||||
|
const year = Number(yearText);
|
||||||
|
const month = Number(monthText);
|
||||||
|
const start = `${period}-01`;
|
||||||
|
const lastDay = new Date(year, month, 0).getDate();
|
||||||
|
const end = `${period}-${String(lastDay).padStart(2, '0')}`;
|
||||||
|
return { start, end };
|
||||||
|
};
|
||||||
|
|
||||||
const resolveCategory = async (userId: string, categoryId: string) =>
|
const resolveCategory = async (userId: string, categoryId: string) =>
|
||||||
categoryRepo().findOne({
|
categoryRepo().findOne({
|
||||||
where: [{ id: categoryId, isSystem: true }, { id: categoryId, user: { id: userId } }],
|
where: [{ id: categoryId, isSystem: true }, { id: categoryId, user: { id: userId } }],
|
||||||
@@ -312,6 +337,7 @@ const createImportedExpense = async (input: {
|
|||||||
export const getShoppingListSettings = async (req: AuthenticatedRequest, res: Response) => {
|
export const getShoppingListSettings = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const user = await getSettings(req.user!.id);
|
const user = await getSettings(req.user!.id);
|
||||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||||
|
try { ensureIntegrationsAllowed(user); } catch (error) { return res.status((error as { status?: number }).status ?? 403).json({ message: (error as Error).message }); }
|
||||||
return res.json({ item: sanitizeIntegration(user.shoppingListIntegration) });
|
return res.json({ item: sanitizeIntegration(user.shoppingListIntegration) });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -321,6 +347,7 @@ export const updateShoppingListSettings = async (req: AuthenticatedRequest, res:
|
|||||||
|
|
||||||
const user = await getSettings(req.user!.id);
|
const user = await getSettings(req.user!.id);
|
||||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||||
|
try { ensureIntegrationsAllowed(user); } catch (error) { return res.status((error as { status?: number }).status ?? 403).json({ message: (error as Error).message }); }
|
||||||
|
|
||||||
const current = user.shoppingListIntegration ?? {};
|
const current = user.shoppingListIntegration ?? {};
|
||||||
user.shoppingListIntegration = {
|
user.shoppingListIntegration = {
|
||||||
@@ -433,7 +460,7 @@ export const importShoppingListAsExpense = async (req: AuthenticatedRequest, res
|
|||||||
const title = trimToNull(parsed.data.title) ?? `Shopping list: ${trimToNull(parsed.data.listTitle) ?? listId}`;
|
const title = trimToNull(parsed.data.title) ?? `Shopping list: ${trimToNull(parsed.data.listTitle) ?? listId}`;
|
||||||
const description = trimToNull(parsed.data.description) ?? `Imported aggregate from shopping list API (${items.length} item${items.length > 1 ? 's' : ''}).`;
|
const description = trimToNull(parsed.data.description) ?? `Imported aggregate from shopping list API (${items.length} item${items.length > 1 ? 's' : ''}).`;
|
||||||
const merchant = trimToNull(parsed.data.merchant) ?? trimToNull(parsed.data.listTitle) ?? 'Shopping list API';
|
const merchant = trimToNull(parsed.data.merchant) ?? trimToNull(parsed.data.listTitle) ?? 'Shopping list API';
|
||||||
const tags = normalizeTags([...parsed.data.tags, 'shopping-list', 'external-import']);
|
const tags = normalizeTags(parsed.data.tags.length ? parsed.data.tags : ['shopping-list']);
|
||||||
|
|
||||||
const result = await createImportedExpense({
|
const result = await createImportedExpense({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -461,6 +488,64 @@ export const importShoppingListAsExpense = async (req: AuthenticatedRequest, res
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const importShoppingPeriodAsExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const parsed = importPeriodSchema.safeParse(req.body ?? {});
|
||||||
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid shopping period import payload', issues: parsed.error.issues });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { user, config } = await requireConfig(req.user!.id);
|
||||||
|
const range = monthRange(parsed.data.period);
|
||||||
|
const existing = await getExistingExpenses(user.id);
|
||||||
|
if (hasExternalImport(existing, 'externalShoppingListPeriod', parsed.data.period)) {
|
||||||
|
return res.status(409).json({ message: 'This shopping month has already been imported as a local expense.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await proxyRequest(config, '/api/expenses/summary', {
|
||||||
|
start_date: range.start,
|
||||||
|
end_date: range.end,
|
||||||
|
owner_id: config.ownerId ?? undefined,
|
||||||
|
list_id: config.defaultListId ?? undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalAmount = readNumber((payload as { total?: unknown }).total, (payload as { amount?: unknown }).amount, (payload as { meta?: { total_amount?: unknown } }).meta?.total_amount);
|
||||||
|
const totalCount = readNumber((payload as { count?: unknown }).count, (payload as { records?: unknown }).records, (payload as { meta?: { total_count?: unknown } }).meta?.total_count);
|
||||||
|
const listGroups = [
|
||||||
|
(payload as { lists?: unknown }).lists,
|
||||||
|
(payload as { totals?: unknown }).totals,
|
||||||
|
(payload as { aggregates?: unknown }).aggregates
|
||||||
|
].find((value) => Array.isArray(value));
|
||||||
|
const listCount = Array.isArray(listGroups) ? listGroups.length : 0;
|
||||||
|
|
||||||
|
if (totalAmount <= 0) {
|
||||||
|
return res.status(400).json({ message: 'The selected shopping month does not contain any importable expenses.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createImportedExpense({
|
||||||
|
userId: user.id,
|
||||||
|
categoryId: parsed.data.categoryId,
|
||||||
|
title: `Zakupy ${parsed.data.period}`,
|
||||||
|
description: `Zbiorczy import zakupów za okres ${parsed.data.period}.`,
|
||||||
|
amount: Number(totalAmount.toFixed(2)),
|
||||||
|
expenseDate: range.end,
|
||||||
|
merchant: trimToNull(parsed.data.merchant) ?? 'Zakupy',
|
||||||
|
status: parsed.data.status,
|
||||||
|
tags: ['shopping-list'],
|
||||||
|
customFields: {
|
||||||
|
externalSource: 'shopping-list-api',
|
||||||
|
externalShoppingListImportType: 'PERIOD',
|
||||||
|
externalShoppingListPeriod: parsed.data.period,
|
||||||
|
externalShoppingListListCount: String(listCount),
|
||||||
|
externalShoppingListItemCount: String(totalCount)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status((error as { status?: number }).status ?? 400).json({ message: (error as Error).message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const importShoppingListItemAsExpense = async (req: AuthenticatedRequest, res: Response) => {
|
export const importShoppingListItemAsExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const parsed = importItemSchema.safeParse(req.body ?? {});
|
const parsed = importItemSchema.safeParse(req.body ?? {});
|
||||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid shopping list item import payload', issues: parsed.error.issues });
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid shopping list item import payload', issues: parsed.error.issues });
|
||||||
@@ -475,7 +560,7 @@ export const importShoppingListItemAsExpense = async (req: AuthenticatedRequest,
|
|||||||
|
|
||||||
const title = parsed.data.title.trim();
|
const title = parsed.data.title.trim();
|
||||||
const merchant = trimToNull(parsed.data.merchant) ?? trimToNull(parsed.data.listTitle) ?? 'Shopping list API';
|
const merchant = trimToNull(parsed.data.merchant) ?? trimToNull(parsed.data.listTitle) ?? 'Shopping list API';
|
||||||
const tags = normalizeTags([...parsed.data.tags, 'shopping-list', 'external-import']);
|
const tags = normalizeTags(parsed.data.tags.length ? parsed.data.tags : ['shopping-list']);
|
||||||
const description = trimToNull(parsed.data.description) ?? `Imported from shopping list API${parsed.data.listTitle ? ` (${parsed.data.listTitle})` : ''}.`;
|
const description = trimToNull(parsed.data.description) ?? `Imported from shopping list API${parsed.data.listTitle ? ` (${parsed.data.listTitle})` : ''}.`;
|
||||||
|
|
||||||
const result = await createImportedExpense({
|
const result = await createImportedExpense({
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ const require = createRequire(import.meta.url);
|
|||||||
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
||||||
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
||||||
|
|
||||||
|
const normalizeOptionalString = (value: unknown) => {
|
||||||
|
if (value === null || value === undefined) return undefined;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeNullableString = (value: unknown) => {
|
||||||
|
if (value === null || value === undefined) return undefined;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
};
|
||||||
|
|
||||||
const defaultPrefs = (email: string) => ({
|
const defaultPrefs = (email: string) => ({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
frequency: 'monthly' as const,
|
frequency: 'monthly' as const,
|
||||||
@@ -196,7 +208,7 @@ export const getPreferences = async (req: AuthenticatedRequest, res: Response) =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const updatePreferences = async (req: AuthenticatedRequest, res: Response) => {
|
export const updatePreferences = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const parsed = preferencesSchema.safeParse(req.body);
|
const parsed = preferencesSchema.safeParse({ ...req.body, sendToEmail: normalizeNullableString(req.body?.sendToEmail) });
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return res.status(400).json({ message: 'Invalid report preferences payload', issues: parsed.error.issues });
|
return res.status(400).json({ message: 'Invalid report preferences payload', issues: parsed.error.issues });
|
||||||
}
|
}
|
||||||
@@ -282,7 +294,7 @@ export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
|
|||||||
|
|
||||||
export const exportReport = async (req: AuthenticatedRequest, res: Response) => {
|
export const exportReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
await processDueRecurringExpenses(req.user!.id);
|
await processDueRecurringExpenses(req.user!.id);
|
||||||
const parsed = exportQuerySchema.safeParse(req.query);
|
const parsed = exportQuerySchema.safeParse({ ...req.query, format: normalizeOptionalString(req.query.format), startDate: normalizeOptionalString(req.query.startDate), endDate: normalizeOptionalString(req.query.endDate), categoryIds: normalizeOptionalString(req.query.categoryIds), status: normalizeOptionalString(req.query.status), tag: normalizeOptionalString(req.query.tag) });
|
||||||
if (!parsed.success) return res.status(400).json({ message: 'Invalid report export filters', issues: parsed.error.issues });
|
if (!parsed.success) return res.status(400).json({ message: 'Invalid report export filters', issues: parsed.error.issues });
|
||||||
|
|
||||||
const categoryIds = parsed.data.categoryIds?.split(',').filter(Boolean) ?? [];
|
const categoryIds = parsed.data.categoryIds?.split(',').filter(Boolean) ?? [];
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import { getCashflowSummary, getStatistics } from '../services/statistics.servic
|
|||||||
import { processDueRecurringExpenses } from '../services/recurring.service.js';
|
import { processDueRecurringExpenses } from '../services/recurring.service.js';
|
||||||
import type { AuthenticatedRequest } from '../types/express.js';
|
import type { AuthenticatedRequest } from '../types/express.js';
|
||||||
|
|
||||||
|
const normalizeOptionalString = (value: unknown) => {
|
||||||
|
if (value === null || value === undefined) return undefined;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const querySchema = z.object({
|
const querySchema = z.object({
|
||||||
startDate: z.string().optional(),
|
startDate: z.string().optional(),
|
||||||
endDate: z.string().optional(),
|
endDate: z.string().optional(),
|
||||||
@@ -15,11 +21,15 @@ const querySchema = z.object({
|
|||||||
|
|
||||||
export const getOverview = async (req: AuthenticatedRequest, res: Response) => {
|
export const getOverview = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
await processDueRecurringExpenses(req.user!.id);
|
await processDueRecurringExpenses(req.user!.id);
|
||||||
const parsed = querySchema.safeParse(req.query);
|
const parsed = querySchema.safeParse({ ...req.query, startDate: normalizeOptionalString(req.query.startDate), endDate: normalizeOptionalString(req.query.endDate), categoryIds: normalizeOptionalString(req.query.categoryIds), bucket: normalizeOptionalString(req.query.bucket), tag: normalizeOptionalString(req.query.tag), status: normalizeOptionalString(req.query.status) });
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return res.status(400).json({ message: 'Invalid statistics filters', issues: parsed.error.issues });
|
return res.status(400).json({ message: 'Invalid statistics filters', issues: parsed.error.issues });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
res.set('Pragma', 'no-cache');
|
||||||
|
res.set('Expires', '0');
|
||||||
|
|
||||||
const categoryIds = parsed.data.categoryIds ? parsed.data.categoryIds.split(',').filter(Boolean) : [];
|
const categoryIds = parsed.data.categoryIds ? parsed.data.categoryIds.split(',').filter(Boolean) : [];
|
||||||
return res.json(
|
return res.json(
|
||||||
await getStatistics(
|
await getStatistics(
|
||||||
@@ -38,5 +48,8 @@ export const getOverview = async (req: AuthenticatedRequest, res: Response) => {
|
|||||||
|
|
||||||
export const getCashflow = async (req: AuthenticatedRequest, res: Response) => {
|
export const getCashflow = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
await processDueRecurringExpenses(req.user!.id);
|
await processDueRecurringExpenses(req.user!.id);
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
res.set('Pragma', 'no-cache');
|
||||||
|
res.set('Expires', '0');
|
||||||
return res.json(await getCashflowSummary(req.user!.id));
|
return res.json(await getCashflowSummary(req.user!.id));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export class User {
|
|||||||
@Column({ type: 'varchar', length: 8, default: 'PLN' })
|
@Column({ type: 'varchar', length: 8, default: 'PLN' })
|
||||||
defaultCurrency!: string;
|
defaultCurrency!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
integrationsEnabled!: boolean;
|
||||||
|
|
||||||
@Column({ type: 'simple-json', nullable: true })
|
@Column({ type: 'simple-json', nullable: true })
|
||||||
reportPreferences!: {
|
reportPreferences!: {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { addProof, createExpense, deleteExpense, listDuplicates, listExpenses, reviewDuplicate, updateExpense } from '../controllers/expense.controller.js';
|
import { addProof, bulkDeleteExpenses, bulkUpdateExpenseStatus, createExpense, deleteExpense, deleteProof, getExpense, getProofFile, listDuplicates, listExpenses, reviewDuplicate, updateExpense, updateExpenseStatus } from '../controllers/expense.controller.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
import { uploadProofFiles } from '../middleware/upload.js';
|
import { uploadProofFiles } from '../middleware/upload.js';
|
||||||
|
|
||||||
@@ -7,8 +7,15 @@ export const expenseRouter = Router();
|
|||||||
expenseRouter.use(requireAuth);
|
expenseRouter.use(requireAuth);
|
||||||
expenseRouter.get('/', listExpenses);
|
expenseRouter.get('/', listExpenses);
|
||||||
expenseRouter.get('/duplicates', listDuplicates);
|
expenseRouter.get('/duplicates', listDuplicates);
|
||||||
|
expenseRouter.patch('/bulk/status', bulkUpdateExpenseStatus);
|
||||||
|
expenseRouter.post('/bulk/delete', bulkDeleteExpenses);
|
||||||
|
expenseRouter.get('/item/:id', getExpense);
|
||||||
|
expenseRouter.get('/:id', getExpense);
|
||||||
expenseRouter.post('/', uploadProofFiles, createExpense);
|
expenseRouter.post('/', uploadProofFiles, createExpense);
|
||||||
expenseRouter.put('/:id', updateExpense);
|
expenseRouter.put('/:id', uploadProofFiles, updateExpense);
|
||||||
|
expenseRouter.patch('/:id/status', updateExpenseStatus);
|
||||||
expenseRouter.post('/:id/duplicate-review', reviewDuplicate);
|
expenseRouter.post('/:id/duplicate-review', reviewDuplicate);
|
||||||
expenseRouter.delete('/:id', deleteExpense);
|
expenseRouter.delete('/:id', deleteExpense);
|
||||||
|
expenseRouter.get('/:id/proofs/:proofId/file', getProofFile);
|
||||||
expenseRouter.post('/:id/proofs', uploadProofFiles, addProof);
|
expenseRouter.post('/:id/proofs', uploadProofFiles, addProof);
|
||||||
|
expenseRouter.delete('/:id/proofs/:proofId', deleteProof);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getShoppingLists,
|
getShoppingLists,
|
||||||
importShoppingListAsExpense,
|
importShoppingListAsExpense,
|
||||||
importShoppingListItemAsExpense,
|
importShoppingListItemAsExpense,
|
||||||
|
importShoppingPeriodAsExpense,
|
||||||
testShoppingListConnection,
|
testShoppingListConnection,
|
||||||
updateShoppingListSettings
|
updateShoppingListSettings
|
||||||
} from '../controllers/integration.controller.js';
|
} from '../controllers/integration.controller.js';
|
||||||
@@ -22,4 +23,5 @@ integrationRouter.get('/shopping-list/latest', getShoppingListLatestExpenses);
|
|||||||
integrationRouter.get('/shopping-list/lists', getShoppingLists);
|
integrationRouter.get('/shopping-list/lists', getShoppingLists);
|
||||||
integrationRouter.get('/shopping-list/lists/:id/expenses', getShoppingListExpenses);
|
integrationRouter.get('/shopping-list/lists/:id/expenses', getShoppingListExpenses);
|
||||||
integrationRouter.post('/shopping-list/import-list', importShoppingListAsExpense);
|
integrationRouter.post('/shopping-list/import-list', importShoppingListAsExpense);
|
||||||
|
integrationRouter.post('/shopping-list/import-period', importShoppingPeriodAsExpense);
|
||||||
integrationRouter.post('/shopping-list/import-item', importShoppingListItemAsExpense);
|
integrationRouter.post('/shopping-list/import-item', importShoppingListItemAsExpense);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const sanitizeUser = (user: User) => ({
|
|||||||
role: user.role,
|
role: user.role,
|
||||||
isActive: user.isActive,
|
isActive: user.isActive,
|
||||||
defaultCurrency: user.defaultCurrency,
|
defaultCurrency: user.defaultCurrency,
|
||||||
|
integrationsEnabled: Boolean(user.integrationsEnabled),
|
||||||
reportPreferences: user.reportPreferences ?? {
|
reportPreferences: user.reportPreferences ?? {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
frequency: 'monthly',
|
frequency: 'monthly',
|
||||||
@@ -47,6 +48,7 @@ export const createUser = async (input: {
|
|||||||
passwordHash: await hashPassword(input.password),
|
passwordHash: await hashPassword(input.password),
|
||||||
role: input.role ?? 'USER',
|
role: input.role ?? 'USER',
|
||||||
defaultCurrency: input.defaultCurrency ?? env.DEFAULT_CURRENCY,
|
defaultCurrency: input.defaultCurrency ?? env.DEFAULT_CURRENCY,
|
||||||
|
integrationsEnabled: false,
|
||||||
reportPreferences: {
|
reportPreferences: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
frequency: 'monthly',
|
frequency: 'monthly',
|
||||||
|
|||||||
@@ -113,7 +113,13 @@ const currentMonthKey = () => {
|
|||||||
return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}`;
|
return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const monthRange = (monthKey: string) => ({ startDate: `${monthKey}-01`, endDate: `${monthKey}-31` });
|
const monthRange = (monthKey: string) => {
|
||||||
|
const [yearText, monthText] = monthKey.split('-');
|
||||||
|
const year = Number(yearText);
|
||||||
|
const month = Number(monthText);
|
||||||
|
const lastDay = new Date(year, month, 0).getDate();
|
||||||
|
return { startDate: `${monthKey}-01`, endDate: `${monthKey}-${String(lastDay).padStart(2, '0')}` };
|
||||||
|
};
|
||||||
|
|
||||||
export const getCashflowSummary = async (userId: string) => {
|
export const getCashflowSummary = async (userId: string) => {
|
||||||
const expenseRepo = AppDataSource.getRepository(Expense);
|
const expenseRepo = AppDataSource.getRepository(Expense);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Proof } from '../entities/Proof.js';
|
import type { Proof } from '../entities/Proof.js';
|
||||||
export const buildProofUrl = (storedName: string | null) => storedName ? `/uploads/${storedName}` : null;
|
export const buildProofUrl = (storedName: string | null) => storedName ? `/uploads/${storedName}` : null;
|
||||||
export const serializeProof = (proof: Proof) => ({
|
export const serializeProof = (proof: Proof, _expenseId?: string) => ({
|
||||||
id: proof.id,
|
id: proof.id,
|
||||||
type: proof.type,
|
type: proof.type,
|
||||||
label: proof.label,
|
label: proof.label,
|
||||||
@@ -9,5 +9,6 @@ export const serializeProof = (proof: Proof) => ({
|
|||||||
mimeType: proof.mimeType,
|
mimeType: proof.mimeType,
|
||||||
fileSize: proof.fileSize,
|
fileSize: proof.fileSize,
|
||||||
fileUrl: buildProofUrl(proof.storedName),
|
fileUrl: buildProofUrl(proof.storedName),
|
||||||
|
previewUrl: buildProofUrl(proof.storedName),
|
||||||
createdAt: proof.createdAt
|
createdAt: proof.createdAt
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ server {
|
|||||||
server_tokens off;
|
server_tokens off;
|
||||||
etag off;
|
etag off;
|
||||||
|
|
||||||
#client_max_body_size ${NGINX_CLIENT_MAX_BODY_SIZE};
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -13,19 +17,25 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api:4000/api/;
|
proxy_pass http://api:4000/api/;
|
||||||
|
add_header Cache-Control "no-store, no-cache" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /uploads/ {
|
location /uploads/ {
|
||||||
alias /srv/uploads/;
|
alias /srv/uploads/;
|
||||||
access_log off;
|
access_log off;
|
||||||
expires 30d;
|
expires 30d;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public, max-age=86400, immutable" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://web:80/;
|
proxy_pass http://web:80/;
|
||||||
|
add_header Cache-Control "no-store, no-cache" always;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
0
start_dev.sh
Executable file → Normal file
0
start_dev.sh
Executable file → Normal file
@@ -3,6 +3,7 @@ server {
|
|||||||
server_name _;
|
server_name _;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
server_tokens off;
|
server_tokens off;
|
||||||
|
etag off;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
90
web/package-lock.json
generated
90
web/package-lock.json
generated
@@ -2452,9 +2452,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2472,9 +2469,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2492,9 +2486,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2512,9 +2503,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2532,9 +2520,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2552,9 +2537,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2572,9 +2554,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3018,9 +2997,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3042,9 +3018,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3066,9 +3039,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3090,9 +3060,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3114,9 +3081,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3138,9 +3102,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3328,9 +3289,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3348,9 +3306,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3368,9 +3323,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3388,9 +3340,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3567,9 +3516,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3584,9 +3530,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3601,9 +3544,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3618,9 +3558,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3635,9 +3572,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3652,9 +3586,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3669,9 +3600,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3686,9 +3614,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3703,9 +3628,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3720,9 +3642,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3737,9 +3656,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3754,9 +3670,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3771,9 +3684,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { adminGuard } from './core/guards/admin.guard';
|
import { adminGuard } from './core/guards/admin.guard';
|
||||||
import { authGuard } from './core/guards/auth.guard';
|
import { authGuard } from './core/guards/auth.guard';
|
||||||
|
import { integrationsGuard } from './core/guards/integrations.guard';
|
||||||
import { AdminComponent } from './features/admin/admin.component';
|
import { AdminComponent } from './features/admin/admin.component';
|
||||||
import { LoginComponent } from './features/auth/login.component';
|
import { LoginComponent } from './features/auth/login.component';
|
||||||
import { BudgetsComponent } from './features/budgets/budgets.component';
|
import { BudgetsComponent } from './features/budgets/budgets.component';
|
||||||
@@ -8,6 +9,8 @@ import { CashflowComponent } from './features/cashflow/cashflow.component';
|
|||||||
import { CategoriesComponent } from './features/categories/categories.component';
|
import { CategoriesComponent } from './features/categories/categories.component';
|
||||||
import { DashboardComponent } from './features/dashboard/dashboard.component';
|
import { DashboardComponent } from './features/dashboard/dashboard.component';
|
||||||
import { ExpensesComponent } from './features/expenses/expenses.component';
|
import { ExpensesComponent } from './features/expenses/expenses.component';
|
||||||
|
import { ExpenseListComponent } from './features/expenses/expense-list.component';
|
||||||
|
import { ExpenseDetailComponent } from './features/expenses/expense-detail.component';
|
||||||
import { IntegrationsComponent } from './features/integrations/integrations.component';
|
import { IntegrationsComponent } from './features/integrations/integrations.component';
|
||||||
import { MerchantsComponent } from './features/merchants/merchants.component';
|
import { MerchantsComponent } from './features/merchants/merchants.component';
|
||||||
import { RecurringComponent } from './features/recurring/recurring.component';
|
import { RecurringComponent } from './features/recurring/recurring.component';
|
||||||
@@ -23,7 +26,15 @@ export const routes: Routes = [
|
|||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
children: [
|
children: [
|
||||||
{ path: '', component: DashboardComponent },
|
{ path: '', component: DashboardComponent },
|
||||||
{ path: 'expenses', component: ExpensesComponent },
|
{
|
||||||
|
path: 'expenses',
|
||||||
|
children: [
|
||||||
|
{ path: '', redirectTo: 'list', pathMatch: 'full' },
|
||||||
|
{ path: 'add', component: ExpensesComponent },
|
||||||
|
{ path: 'list', component: ExpenseListComponent },
|
||||||
|
{ path: ':id', component: ExpenseDetailComponent }
|
||||||
|
]
|
||||||
|
},
|
||||||
{ path: 'stats', component: StatsComponent },
|
{ path: 'stats', component: StatsComponent },
|
||||||
{ path: 'cashflow', component: CashflowComponent },
|
{ path: 'cashflow', component: CashflowComponent },
|
||||||
{ path: 'budgets', component: BudgetsComponent },
|
{ path: 'budgets', component: BudgetsComponent },
|
||||||
@@ -31,7 +42,7 @@ export const routes: Routes = [
|
|||||||
{ path: 'merchants', component: MerchantsComponent },
|
{ path: 'merchants', component: MerchantsComponent },
|
||||||
{ path: 'reports', component: ReportsComponent },
|
{ path: 'reports', component: ReportsComponent },
|
||||||
{ path: 'categories', component: CategoriesComponent },
|
{ path: 'categories', component: CategoriesComponent },
|
||||||
{ path: 'integrations', component: IntegrationsComponent },
|
{ path: 'integrations', component: IntegrationsComponent, canActivate: [integrationsGuard] },
|
||||||
{ path: 'admin', component: AdminComponent, canActivate: [adminGuard] }
|
{ path: 'admin', component: AdminComponent, canActivate: [adminGuard] }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
17
web/src/app/core/guards/integrations.guard.ts
Normal file
17
web/src/app/core/guards/integrations.guard.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
import { ToastService } from '../services/toast.service';
|
||||||
|
import { UiService } from '../services/ui.service';
|
||||||
|
|
||||||
|
export const integrationsGuard: CanActivateFn = () => {
|
||||||
|
const auth = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
const toast = inject(ToastService);
|
||||||
|
const ui = inject(UiService);
|
||||||
|
|
||||||
|
if (auth.currentUser()?.integrationsEnabled) return true;
|
||||||
|
|
||||||
|
toast.warning(ui.t('integrations.disabledForUser'));
|
||||||
|
return router.createUrlTree(['/']);
|
||||||
|
};
|
||||||
@@ -19,7 +19,7 @@ export class AdminService {
|
|||||||
return this.http.get<{ items: User[] }>(`${environment.apiBaseUrl}/admin/users`);
|
return this.http.get<{ items: User[] }>(`${environment.apiBaseUrl}/admin/users`);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateUser(id: string, payload: Partial<User>) {
|
updateUser(id: string, payload: Partial<User> & { integrationsEnabled?: boolean }) {
|
||||||
return this.http.patch<{ item: User }>(`${environment.apiBaseUrl}/admin/users/${id}`, payload);
|
return this.http.patch<{ item: User }>(`${environment.apiBaseUrl}/admin/users/${id}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import type { DuplicateGroup, Expense, Proof } from '../../shared/models';
|
import type { DuplicateGroup, Expense, ExpenseListResponse, Proof } from '../../shared/models';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ExpensesService {
|
export class ExpensesService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
list(filters: { startDate?: string; endDate?: string; categoryId?: string; search?: string; status?: string; tags?: string; duplicatesOnly?: boolean } = {}) {
|
list(filters: { startDate?: string; endDate?: string; categoryId?: string; search?: string; status?: string; tags?: string; duplicatesOnly?: boolean; page?: number; pageSize?: number; sortBy?: string; sortDir?: 'asc' | 'desc' } = {}) {
|
||||||
let params = new HttpParams();
|
let params = new HttpParams();
|
||||||
Object.entries(filters).forEach(([key, value]) => {
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== null && value !== '') params = params.set(key, String(value));
|
if (value !== undefined && value !== null && value !== '') params = params.set(key, String(value));
|
||||||
});
|
});
|
||||||
return this.http.get<{ items: Expense[] }>(`${environment.apiBaseUrl}/expenses`, { params });
|
return this.http.get<ExpenseListResponse>(`${environment.apiBaseUrl}/expenses`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
getById(id: string) {
|
||||||
|
return this.http.get<{ item: Expense }>(`${environment.apiBaseUrl}/expenses/item/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
duplicates() {
|
duplicates() {
|
||||||
@@ -23,8 +28,20 @@ export class ExpensesService {
|
|||||||
return this.http.post<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/expenses`, formData);
|
return this.http.post<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/expenses`, formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id: string, payload: Partial<Expense> & { categoryId: string }) {
|
update(id: string, formData: FormData) {
|
||||||
return this.http.put<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/expenses/${id}`, payload);
|
return this.http.put<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/expenses/${id}`, formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(id: string, status: Expense['status']) {
|
||||||
|
return this.http.patch<{ item: Expense }>(`${environment.apiBaseUrl}/expenses/${id}/status`, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkUpdateStatus(ids: string[], status: Expense['status']) {
|
||||||
|
return this.http.patch<{ items: Expense[]; updated: number }>(`${environment.apiBaseUrl}/expenses/bulk/status`, { ids, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkDelete(ids: string[]) {
|
||||||
|
return this.http.post<{ deleted: number }>(`${environment.apiBaseUrl}/expenses/bulk/delete`, { ids });
|
||||||
}
|
}
|
||||||
|
|
||||||
reviewDuplicate(id: string, action: 'CONFIRM' | 'DISMISS' | 'REOPEN') {
|
reviewDuplicate(id: string, action: 'CONFIRM' | 'DISMISS' | 'REOPEN') {
|
||||||
@@ -38,4 +55,8 @@ export class ExpensesService {
|
|||||||
addProof(id: string, formData: FormData) {
|
addProof(id: string, formData: FormData) {
|
||||||
return this.http.post<{ proofs: Proof[]; expense: Expense }>(`${environment.apiBaseUrl}/expenses/${id}/proofs`, formData);
|
return this.http.post<{ proofs: Proof[]; expense: Expense }>(`${environment.apiBaseUrl}/expenses/${id}/proofs`, formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteProof(expenseId: string, proofId: string) {
|
||||||
|
return this.http.delete<{ item: Expense }>(`${environment.apiBaseUrl}/expenses/${expenseId}/proofs/${proofId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import type { Expense, ShoppingListExpenseItem, ShoppingListIntegrationSettings, ShoppingListRef, ShoppingListSummary } from '../../shared/models';
|
import type { Expense, ShoppingListExpenseItem, ShoppingListIntegrationSettings, ShoppingListPeriodImportResponse, ShoppingListRef, ShoppingListSummary } from '../../shared/models';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ShoppingListIntegrationService {
|
export class ShoppingListIntegrationService {
|
||||||
@@ -48,6 +48,15 @@ export class ShoppingListIntegrationService {
|
|||||||
return this.http.get<{ items?: ShoppingListExpenseItem[]; data?: ShoppingListExpenseItem[] }>(`${environment.apiBaseUrl}/integrations/shopping-list/lists/${id}/expenses`, { params });
|
return this.http.get<{ items?: ShoppingListExpenseItem[]; data?: ShoppingListExpenseItem[] }>(`${environment.apiBaseUrl}/integrations/shopping-list/lists/${id}/expenses`, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
importPeriod(payload: {
|
||||||
|
period: string;
|
||||||
|
categoryId: string;
|
||||||
|
status: 'DRAFT' | 'PENDING';
|
||||||
|
merchant?: string | null;
|
||||||
|
}) {
|
||||||
|
return this.http.post<ShoppingListPeriodImportResponse>(`${environment.apiBaseUrl}/integrations/shopping-list/import-period`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
importList(payload: {
|
importList(payload: {
|
||||||
listId: string | number;
|
listId: string | number;
|
||||||
listTitle?: string | null;
|
listTitle?: string | null;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export class StatsService {
|
|||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
overview(filters: { startDate?: string; endDate?: string; categoryIds?: string; bucket?: 'month' | 'quarter' | 'year'; tag?: string; status?: string }) {
|
overview(filters: { startDate?: string; endDate?: string; categoryIds?: string; bucket?: 'month' | 'quarter' | 'year'; tag?: string; status?: string }) {
|
||||||
let params = new HttpParams();
|
let params = new HttpParams().set('_ts', String(Date.now()));
|
||||||
Object.entries(filters).forEach(([key, value]) => {
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
if (value) params = params.set(key, value);
|
if (value) params = params.set(key, value);
|
||||||
});
|
});
|
||||||
@@ -16,6 +16,7 @@ export class StatsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cashflow() {
|
cashflow() {
|
||||||
return this.http.get<CashflowResponse>(`${environment.apiBaseUrl}/statistics/cashflow`);
|
const params = new HttpParams().set('_ts', String(Date.now()));
|
||||||
|
return this.http.get<CashflowResponse>(`${environment.apiBaseUrl}/statistics/cashflow`, { params });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'action.cancel': 'Anuluj',
|
'action.cancel': 'Anuluj',
|
||||||
'action.reset': 'Reset',
|
'action.reset': 'Reset',
|
||||||
'action.show': 'Pokaż',
|
'action.show': 'Pokaż',
|
||||||
|
'action.view': 'Szczegóły',
|
||||||
|
'action.backToList': 'Wróć do listy',
|
||||||
|
'action.clearSelection': 'Wyczyść zaznaczenie',
|
||||||
'action.filter': 'Filtruj',
|
'action.filter': 'Filtruj',
|
||||||
'action.refreshPreview': 'Odśwież podgląd',
|
'action.refreshPreview': 'Odśwież podgląd',
|
||||||
'action.sendNow': 'Wyślij teraz',
|
'action.sendNow': 'Wyślij teraz',
|
||||||
@@ -45,6 +48,8 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'action.setUser': 'Ustaw USER',
|
'action.setUser': 'Ustaw USER',
|
||||||
'action.setAdmin': 'Ustaw ADMIN',
|
'action.setAdmin': 'Ustaw ADMIN',
|
||||||
'action.import': 'Importuj',
|
'action.import': 'Importuj',
|
||||||
|
'action.enableIntegrations': 'Włącz integracje',
|
||||||
|
'action.disableIntegrations': 'Wyłącz integracje',
|
||||||
|
|
||||||
'theme.label': 'Motyw',
|
'theme.label': 'Motyw',
|
||||||
'theme.dark': 'Ciemny',
|
'theme.dark': 'Ciemny',
|
||||||
@@ -94,9 +99,12 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'stats.noCategoryChart': 'Brak danych do wykresu kategorii.',
|
'stats.noCategoryChart': 'Brak danych do wykresu kategorii.',
|
||||||
'stats.noTrendChart': 'Brak danych do wykresu trendu.',
|
'stats.noTrendChart': 'Brak danych do wykresu trendu.',
|
||||||
'stats.expensesLabel': 'Wydatki',
|
'stats.expensesLabel': 'Wydatki',
|
||||||
|
'table.createdAt': 'Utworzono',
|
||||||
|
'table.updatedAt': 'Zmieniono',
|
||||||
|
|
||||||
'expenses.title': 'Wydatki',
|
'expenses.title': 'Wydatki',
|
||||||
'expenses.subtitle': 'Dodawaj wydatki, zapisuj potwierdzenia i wybieraj kontrahentów z listy.',
|
'expenses.subtitle': 'Dodawaj wydatki, zapisuj potwierdzenia i wybieraj kontrahentów z listy.',
|
||||||
|
'expenses.listSubtitle': 'Przeglądaj, filtruj i sortuj zapisane wydatki.',
|
||||||
'expenses.new': 'Nowy wydatek',
|
'expenses.new': 'Nowy wydatek',
|
||||||
'expenses.edit': 'Edytuj wydatek',
|
'expenses.edit': 'Edytuj wydatek',
|
||||||
'expenses.requiredHint': 'Uzupełnij wymagane pola oznaczone *.',
|
'expenses.requiredHint': 'Uzupełnij wymagane pola oznaczone *.',
|
||||||
@@ -125,18 +133,33 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'expenses.filters': 'Filtry i ostatnie wydatki',
|
'expenses.filters': 'Filtry i ostatnie wydatki',
|
||||||
'expenses.noMerchant': 'Brak kontrahenta',
|
'expenses.noMerchant': 'Brak kontrahenta',
|
||||||
'expenses.noItems': 'Brak wydatków do wyświetlenia.',
|
'expenses.noItems': 'Brak wydatków do wyświetlenia.',
|
||||||
|
'expenses.listTitle': 'Lista wydatków',
|
||||||
|
'expenses.detailTitle': 'Szczegóły wydatku',
|
||||||
|
'expenses.meta': 'Metadane',
|
||||||
|
'expenses.noProofs': 'Brak załączników.',
|
||||||
|
'expenses.selectedCount': 'Zaznaczone',
|
||||||
|
'expenses.bulkUpdated': 'Zbiorcza zmiana statusu została zapisana.',
|
||||||
|
'expenses.bulkDeleted': 'Wybrane wydatki zostały usunięte.',
|
||||||
|
'expenses.bulkActionError': 'Nie udało się wykonać operacji zbiorczej.',
|
||||||
|
'expenses.bulkDeleteConfirm': 'Usunąć zaznaczone wydatki?',
|
||||||
|
'expenses.totalItems': 'Łącznie',
|
||||||
|
'expenses.perPage': 'na stronę',
|
||||||
'expenses.proof': 'Potwierdzenie',
|
'expenses.proof': 'Potwierdzenie',
|
||||||
'expenses.saving': 'Zapisywanie...',
|
'expenses.saving': 'Zapisywanie...',
|
||||||
'expenses.added': 'Wydatek został dodany.',
|
'expenses.added': 'Wydatek został dodany.',
|
||||||
'expenses.saved': 'Wydatek został zapisany.',
|
'expenses.saved': 'Wydatek został zapisany.',
|
||||||
'expenses.deleted': 'Wydatek został usunięty.',
|
'expenses.deleted': 'Wydatek został usunięty.',
|
||||||
|
'expenses.statusUpdated': 'Status wydatku został zmieniony.',
|
||||||
|
'expenses.statusUpdateError': 'Nie udało się zmienić statusu wydatku.',
|
||||||
'expenses.addError': 'Nie udało się dodać wydatku.',
|
'expenses.addError': 'Nie udało się dodać wydatku.',
|
||||||
'expenses.saveError': 'Nie udało się zapisać wydatku.',
|
'expenses.saveError': 'Nie udało się zapisać wydatku.',
|
||||||
|
'expenses.loadError': 'Nie udało się pobrać wydatku.',
|
||||||
'expenses.deleteError': 'Nie udało się usunąć wydatku.',
|
'expenses.deleteError': 'Nie udało się usunąć wydatku.',
|
||||||
'expenses.validation.title': 'Podaj tytuł wydatku.',
|
'expenses.validation.title': 'Podaj tytuł wydatku.',
|
||||||
'expenses.validation.amount': 'Podaj poprawną kwotę większą od 0.',
|
'expenses.validation.amount': 'Podaj poprawną kwotę większą od 0.',
|
||||||
'expenses.validation.date': 'Wybierz datę wydatku.',
|
'expenses.validation.date': 'Wybierz datę wydatku.',
|
||||||
'expenses.validation.category': 'Wybierz kategorię.',
|
'expenses.validation.category': 'Wybierz kategorię.',
|
||||||
|
'expenses.existingProofs': 'Obecne załączniki',
|
||||||
|
|
||||||
'proof.receipt': 'Paragon',
|
'proof.receipt': 'Paragon',
|
||||||
'proof.invoice': 'Faktura',
|
'proof.invoice': 'Faktura',
|
||||||
@@ -227,6 +250,8 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'admin.roleError': 'Nie udało się zmienić roli.',
|
'admin.roleError': 'Nie udało się zmienić roli.',
|
||||||
'admin.statusUpdated': 'Status konta został zaktualizowany.',
|
'admin.statusUpdated': 'Status konta został zaktualizowany.',
|
||||||
'admin.statusError': 'Nie udało się zmienić statusu.',
|
'admin.statusError': 'Nie udało się zmienić statusu.',
|
||||||
|
'admin.integrationsAccess': 'Integracje',
|
||||||
|
'admin.integrationsUpdated': 'Dostęp do integracji został zaktualizowany.',
|
||||||
|
|
||||||
|
|
||||||
'nav.cashflow': 'Cashflow',
|
'nav.cashflow': 'Cashflow',
|
||||||
@@ -310,6 +335,7 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'cashflow.statusSummary': 'Statusy wydatków',
|
'cashflow.statusSummary': 'Statusy wydatków',
|
||||||
'cashflow.upcomingRecurring': 'Nadchodzące cykliczne',
|
'cashflow.upcomingRecurring': 'Nadchodzące cykliczne',
|
||||||
'common.none': 'Brak',
|
'common.none': 'Brak',
|
||||||
|
'common.loading': 'Ładowanie...',
|
||||||
'common.select': 'Wybierz',
|
'common.select': 'Wybierz',
|
||||||
'common.noData': 'Brak danych.',
|
'common.noData': 'Brak danych.',
|
||||||
'common.noExpenses': 'Brak wydatków.',
|
'common.noExpenses': 'Brak wydatków.',
|
||||||
@@ -343,6 +369,7 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'integrations.selfHostedHint': 'Tutaj ustawiasz URL i token do osobnej, samodzielnie hostowanej aplikacji list zakupowych.',
|
'integrations.selfHostedHint': 'Tutaj ustawiasz URL i token do osobnej, samodzielnie hostowanej aplikacji list zakupowych.',
|
||||||
'integrations.importExplainTitle': 'Jak działa import',
|
'integrations.importExplainTitle': 'Jak działa import',
|
||||||
'integrations.importExplainBody': 'Import z list zakupowych zapisuje dane jako zwykły lokalny wydatek w tej aplikacji. Możesz zaimportować całą listę jako 1 wydatek albo pojedyncze pozycje osobno.',
|
'integrations.importExplainBody': 'Import z list zakupowych zapisuje dane jako zwykły lokalny wydatek w tej aplikacji. Możesz zaimportować całą listę jako 1 wydatek albo pojedyncze pozycje osobno.',
|
||||||
|
'integrations.importExplainBodySimple': 'Masz dwa proste tryby: import całego miesiąca jako jeden wydatek albo import wybranej listy jako jeden wydatek.',
|
||||||
|
|
||||||
'footer.apiOnline': 'API online',
|
'footer.apiOnline': 'API online',
|
||||||
'footer.apiOffline': 'API offline',
|
'footer.apiOffline': 'API offline',
|
||||||
@@ -380,10 +407,17 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'integrations.summary': 'Podsumowanie zewnętrzne',
|
'integrations.summary': 'Podsumowanie zewnętrzne',
|
||||||
'integrations.latest': 'Wydatki z wybranego okresu',
|
'integrations.latest': 'Wydatki z wybranego okresu',
|
||||||
'integrations.lists': 'Listy zakupowe z okresu',
|
'integrations.lists': 'Listy zakupowe z okresu',
|
||||||
|
'integrations.externalLists': 'Liczba list',
|
||||||
|
'integrations.summaryLists': 'List',
|
||||||
|
'integrations.summarySpend': 'Kwota',
|
||||||
'integrations.listExpenses': 'Pozycje wybranej listy',
|
'integrations.listExpenses': 'Pozycje wybranej listy',
|
||||||
'integrations.importTitle': 'Import do lokalnych wydatków',
|
'integrations.importTitle': 'Import do lokalnych wydatków',
|
||||||
|
'integrations.importMonthTitle': 'Import zbiorczy miesiąca',
|
||||||
|
'integrations.importMonthHint': 'Doda jeden wydatek zakupowy dla wybranego miesiąca.',
|
||||||
|
'integrations.importListTitle': 'Import wybranej listy',
|
||||||
'integrations.importSelectedList': 'Importuj wybraną listę jako 1 wydatek',
|
'integrations.importSelectedList': 'Importuj wybraną listę jako 1 wydatek',
|
||||||
'integrations.selectListHint': 'Wybierz listę po lewej, aby podejrzeć pozycje i zaimportować całą listę lub pojedyncze wydatki.',
|
'integrations.selectListHint': 'Wybierz listę po lewej, aby podejrzeć pozycje i zaimportować całą listę lub pojedyncze wydatki.',
|
||||||
|
'integrations.selectListHintSimple': 'Wybierz listę po lewej stronie.',
|
||||||
'integrations.selectedListSummary': 'Pozycje / suma',
|
'integrations.selectedListSummary': 'Pozycje / suma',
|
||||||
'integrations.tags': 'Tagi importu',
|
'integrations.tags': 'Tagi importu',
|
||||||
'integrations.tagsHint': 'Oddzielaj tagi przecinkami.',
|
'integrations.tagsHint': 'Oddzielaj tagi przecinkami.',
|
||||||
@@ -396,6 +430,9 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'integrations.testError': 'Nie udało się połączyć z zewnętrznym API.',
|
'integrations.testError': 'Nie udało się połączyć z zewnętrznym API.',
|
||||||
'integrations.loadError': 'Nie udało się pobrać danych integracji.',
|
'integrations.loadError': 'Nie udało się pobrać danych integracji.',
|
||||||
'integrations.importListSuccess': 'Lista zakupowa została zaimportowana jako lokalny wydatek.',
|
'integrations.importListSuccess': 'Lista zakupowa została zaimportowana jako lokalny wydatek.',
|
||||||
|
'integrations.importPeriod': 'Importuj miesiąc',
|
||||||
|
'integrations.importPeriodSuccess': 'Miesiąc zakupów został zaimportowany.',
|
||||||
|
'integrations.disabledForUser': 'Integracje są wyłączone dla tego użytkownika.',
|
||||||
'integrations.importItemSuccess': 'Pozycja z listy zakupowej została zaimportowana.',
|
'integrations.importItemSuccess': 'Pozycja z listy zakupowej została zaimportowana.',
|
||||||
'integrations.importError': 'Nie udało się zaimportować danych z list zakupowych.',
|
'integrations.importError': 'Nie udało się zaimportować danych z list zakupowych.',
|
||||||
|
|
||||||
@@ -441,6 +478,9 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'action.cancel': 'Cancel',
|
'action.cancel': 'Cancel',
|
||||||
'action.reset': 'Reset',
|
'action.reset': 'Reset',
|
||||||
'action.show': 'Show',
|
'action.show': 'Show',
|
||||||
|
'action.view': 'Details',
|
||||||
|
'action.backToList': 'Back to list',
|
||||||
|
'action.clearSelection': 'Clear selection',
|
||||||
'action.filter': 'Filter',
|
'action.filter': 'Filter',
|
||||||
'action.refreshPreview': 'Refresh preview',
|
'action.refreshPreview': 'Refresh preview',
|
||||||
'action.sendNow': 'Send now',
|
'action.sendNow': 'Send now',
|
||||||
@@ -455,6 +495,8 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'action.setUser': 'Set USER',
|
'action.setUser': 'Set USER',
|
||||||
'action.setAdmin': 'Set ADMIN',
|
'action.setAdmin': 'Set ADMIN',
|
||||||
'action.import': 'Import',
|
'action.import': 'Import',
|
||||||
|
'action.enableIntegrations': 'Enable integrations',
|
||||||
|
'action.disableIntegrations': 'Disable integrations',
|
||||||
|
|
||||||
'theme.label': 'Theme',
|
'theme.label': 'Theme',
|
||||||
'theme.dark': 'Dark',
|
'theme.dark': 'Dark',
|
||||||
@@ -504,8 +546,11 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'stats.noCategoryChart': 'No category chart data available.',
|
'stats.noCategoryChart': 'No category chart data available.',
|
||||||
'stats.noTrendChart': 'No trend chart data available.',
|
'stats.noTrendChart': 'No trend chart data available.',
|
||||||
'stats.expensesLabel': 'Expenses',
|
'stats.expensesLabel': 'Expenses',
|
||||||
|
'table.createdAt': 'Created',
|
||||||
|
'table.updatedAt': 'Updated',
|
||||||
|
|
||||||
'expenses.title': 'Expenses',
|
'expenses.title': 'Expenses',
|
||||||
|
'expenses.listSubtitle': 'Browse, filter, and sort saved expenses.',
|
||||||
'expenses.subtitle': 'Add expenses, store proofs and pick merchants from the list.',
|
'expenses.subtitle': 'Add expenses, store proofs and pick merchants from the list.',
|
||||||
'expenses.new': 'New expense',
|
'expenses.new': 'New expense',
|
||||||
'expenses.edit': 'Edit expense',
|
'expenses.edit': 'Edit expense',
|
||||||
@@ -535,6 +580,17 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'expenses.filters': 'Filters and recent expenses',
|
'expenses.filters': 'Filters and recent expenses',
|
||||||
'expenses.noMerchant': 'No merchant',
|
'expenses.noMerchant': 'No merchant',
|
||||||
'expenses.noItems': 'No expenses to display.',
|
'expenses.noItems': 'No expenses to display.',
|
||||||
|
'expenses.listTitle': 'Expense list',
|
||||||
|
'expenses.detailTitle': 'Expense details',
|
||||||
|
'expenses.meta': 'Metadata',
|
||||||
|
'expenses.noProofs': 'No attachments.',
|
||||||
|
'expenses.selectedCount': 'Selected',
|
||||||
|
'expenses.bulkUpdated': 'Bulk status update saved.',
|
||||||
|
'expenses.bulkDeleted': 'Selected expenses were deleted.',
|
||||||
|
'expenses.bulkActionError': 'Bulk action failed.',
|
||||||
|
'expenses.bulkDeleteConfirm': 'Delete selected expenses?',
|
||||||
|
'expenses.totalItems': 'Total',
|
||||||
|
'expenses.perPage': 'per page',
|
||||||
'expenses.proof': 'Proof',
|
'expenses.proof': 'Proof',
|
||||||
'expenses.saving': 'Saving...',
|
'expenses.saving': 'Saving...',
|
||||||
'expenses.added': 'Expense added successfully.',
|
'expenses.added': 'Expense added successfully.',
|
||||||
@@ -542,11 +598,13 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'expenses.deleted': 'Expense deleted successfully.',
|
'expenses.deleted': 'Expense deleted successfully.',
|
||||||
'expenses.addError': 'Failed to add the expense.',
|
'expenses.addError': 'Failed to add the expense.',
|
||||||
'expenses.saveError': 'Failed to save the expense.',
|
'expenses.saveError': 'Failed to save the expense.',
|
||||||
|
'expenses.loadError': 'Failed to load the expense.',
|
||||||
'expenses.deleteError': 'Failed to delete the expense.',
|
'expenses.deleteError': 'Failed to delete the expense.',
|
||||||
'expenses.validation.title': 'Enter an expense title.',
|
'expenses.validation.title': 'Enter an expense title.',
|
||||||
'expenses.validation.amount': 'Enter a valid amount greater than 0.',
|
'expenses.validation.amount': 'Enter a valid amount greater than 0.',
|
||||||
'expenses.validation.date': 'Select an expense date.',
|
'expenses.validation.date': 'Select an expense date.',
|
||||||
'expenses.validation.category': 'Select a category.',
|
'expenses.validation.category': 'Select a category.',
|
||||||
|
'expenses.existingProofs': 'Current attachments',
|
||||||
|
|
||||||
'proof.receipt': 'Receipt',
|
'proof.receipt': 'Receipt',
|
||||||
'proof.invoice': 'Invoice',
|
'proof.invoice': 'Invoice',
|
||||||
@@ -637,6 +695,8 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'admin.roleError': 'Failed to change the role.',
|
'admin.roleError': 'Failed to change the role.',
|
||||||
'admin.statusUpdated': 'Account status updated successfully.',
|
'admin.statusUpdated': 'Account status updated successfully.',
|
||||||
'admin.statusError': 'Failed to change the account status.',
|
'admin.statusError': 'Failed to change the account status.',
|
||||||
|
'admin.integrationsAccess': 'Integrations',
|
||||||
|
'admin.integrationsUpdated': 'Integrations access has been updated.',
|
||||||
|
|
||||||
|
|
||||||
'nav.cashflow': 'Cashflow',
|
'nav.cashflow': 'Cashflow',
|
||||||
@@ -720,6 +780,7 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'cashflow.statusSummary': 'Expense statuses',
|
'cashflow.statusSummary': 'Expense statuses',
|
||||||
'cashflow.upcomingRecurring': 'Upcoming recurring',
|
'cashflow.upcomingRecurring': 'Upcoming recurring',
|
||||||
'common.none': 'None',
|
'common.none': 'None',
|
||||||
|
'common.loading': 'Loading...',
|
||||||
'common.select': 'Select',
|
'common.select': 'Select',
|
||||||
'common.noData': 'No data.',
|
'common.noData': 'No data.',
|
||||||
'common.noExpenses': 'No expenses.',
|
'common.noExpenses': 'No expenses.',
|
||||||
@@ -753,6 +814,7 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'integrations.selfHostedHint': 'Set the URL and token for the separate self-hosted shopping list application here.',
|
'integrations.selfHostedHint': 'Set the URL and token for the separate self-hosted shopping list application here.',
|
||||||
'integrations.importExplainTitle': 'How import works',
|
'integrations.importExplainTitle': 'How import works',
|
||||||
'integrations.importExplainBody': 'Importing from shopping lists creates a normal local expense in this app. You can import the whole list as one expense or import single entries separately.',
|
'integrations.importExplainBody': 'Importing from shopping lists creates a normal local expense in this app. You can import the whole list as one expense or import single entries separately.',
|
||||||
|
'integrations.importExplainBodySimple': 'You have two simple modes: import the whole month as one expense or import one selected list as one expense.',
|
||||||
|
|
||||||
'footer.apiOnline': 'API online',
|
'footer.apiOnline': 'API online',
|
||||||
'footer.apiOffline': 'API offline',
|
'footer.apiOffline': 'API offline',
|
||||||
@@ -790,10 +852,17 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'integrations.summary': 'External summary',
|
'integrations.summary': 'External summary',
|
||||||
'integrations.latest': 'Expenses for selected period',
|
'integrations.latest': 'Expenses for selected period',
|
||||||
'integrations.lists': 'Shopping lists for period',
|
'integrations.lists': 'Shopping lists for period',
|
||||||
|
'integrations.externalLists': 'Lists',
|
||||||
|
'integrations.summaryLists': 'Lists',
|
||||||
|
'integrations.summarySpend': 'Amount',
|
||||||
'integrations.listExpenses': 'Entries for selected list',
|
'integrations.listExpenses': 'Entries for selected list',
|
||||||
'integrations.importTitle': 'Import into local expenses',
|
'integrations.importTitle': 'Import into local expenses',
|
||||||
|
'integrations.importMonthTitle': 'Import whole month',
|
||||||
|
'integrations.importMonthHint': 'Adds one shopping expense for the selected month.',
|
||||||
|
'integrations.importListTitle': 'Import selected list',
|
||||||
'integrations.importSelectedList': 'Import selected list as 1 expense',
|
'integrations.importSelectedList': 'Import selected list as 1 expense',
|
||||||
'integrations.selectListHint': 'Select a list on the left to preview entries and import the whole list or individual expenses.',
|
'integrations.selectListHint': 'Select a list on the left to preview entries and import the whole list or individual expenses.',
|
||||||
|
'integrations.selectListHintSimple': 'Select a list on the left.',
|
||||||
'integrations.selectedListSummary': 'Entries / total',
|
'integrations.selectedListSummary': 'Entries / total',
|
||||||
'integrations.tags': 'Import tags',
|
'integrations.tags': 'Import tags',
|
||||||
'integrations.tagsHint': 'Separate tags with commas.',
|
'integrations.tagsHint': 'Separate tags with commas.',
|
||||||
@@ -806,6 +875,9 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'integrations.testError': 'Failed to connect to the external API.',
|
'integrations.testError': 'Failed to connect to the external API.',
|
||||||
'integrations.loadError': 'Failed to load integration data.',
|
'integrations.loadError': 'Failed to load integration data.',
|
||||||
'integrations.importListSuccess': 'The shopping list was imported as a local expense.',
|
'integrations.importListSuccess': 'The shopping list was imported as a local expense.',
|
||||||
|
'integrations.importPeriod': 'Import month',
|
||||||
|
'integrations.importPeriodSuccess': 'Shopping month imported successfully.',
|
||||||
|
'integrations.disabledForUser': 'Integrations are disabled for this user.',
|
||||||
'integrations.importItemSuccess': 'The shopping list entry was imported.',
|
'integrations.importItemSuccess': 'The shopping list entry was imported.',
|
||||||
'integrations.importError': 'Failed to import data from the shopping list API.',
|
'integrations.importError': 'Failed to import data from the shopping list API.',
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
|
|||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-vcenter card-table mb-0">
|
<table class="table table-vcenter card-table mb-0">
|
||||||
<thead><tr><th>{{ ui.t('admin.userLabel') }}</th><th>{{ ui.t('admin.role') }}</th><th>{{ ui.t('admin.status') }}</th><th>{{ ui.t('admin.date') }}</th><th class="w-1"></th></tr></thead>
|
<thead><tr><th>{{ ui.t('admin.userLabel') }}</th><th>{{ ui.t('admin.role') }}</th><th>{{ ui.t('admin.status') }}</th><th>{{ ui.t('admin.integrationsAccess') }}</th><th>{{ ui.t('admin.date') }}</th><th class="w-1"></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (user of users(); track user.id) {
|
@for (user of users(); track user.id) {
|
||||||
<tr>
|
<tr>
|
||||||
@@ -154,20 +154,24 @@ import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
|
|||||||
{{ user.isActive ? ui.t('common.active') : ui.t('common.blocked') }}
|
{{ user.isActive ? ui.t('common.active') : ui.t('common.blocked') }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td><span class="badge" [class.bg-success]="user.integrationsEnabled" [class.bg-secondary]="!user.integrationsEnabled">{{ user.integrationsEnabled ? ui.t('common.active') : ui.t('common.blocked') }}</span></td>
|
||||||
<td>{{ user.createdAt | date:'short' }}</td>
|
<td>{{ user.createdAt | date:'short' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-list flex-nowrap">
|
<div class="btn-list flex-wrap">
|
||||||
<button class="btn btn-outline-warning btn-sm" type="button" (click)="toggleRole(user)">
|
<button class="btn btn-outline-warning btn-sm" type="button" (click)="toggleRole(user)">
|
||||||
{{ user.role === 'ADMIN' ? ui.t('action.setUser') : ui.t('action.setAdmin') }}
|
{{ user.role === 'ADMIN' ? ui.t('action.setUser') : ui.t('action.setAdmin') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm" [class.btn-danger]="user.isActive" [class.btn-success]="!user.isActive" type="button" (click)="toggleActive(user)">
|
<button class="btn btn-sm" [class.btn-danger]="user.isActive" [class.btn-success]="!user.isActive" type="button" (click)="toggleActive(user)">
|
||||||
{{ user.isActive ? ui.t('action.block') : ui.t('action.unblock') }}
|
{{ user.isActive ? ui.t('action.block') : ui.t('action.unblock') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-sm" [class.btn-outline-primary]="!user.integrationsEnabled" [class.btn-primary]="user.integrationsEnabled" type="button" (click)="toggleIntegrations(user)">
|
||||||
|
{{ user.integrationsEnabled ? ui.t('action.disableIntegrations') : ui.t('action.enableIntegrations') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr><td colspan="5" class="text-secondary">{{ ui.t('admin.noUsers') }}</td></tr>
|
<tr><td colspan="6" class="text-secondary">{{ ui.t('admin.noUsers') }}</td></tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -296,4 +300,15 @@ export class AdminComponent implements OnInit {
|
|||||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.statusError'))
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.statusError'))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
toggleIntegrations(user: User) {
|
||||||
|
this.admin.updateUser(user.id, { integrationsEnabled: !user.integrationsEnabled }).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.users.update((items) => items.map((item) => (item.id === user.id ? response.item : item)));
|
||||||
|
this.toast.success(this.ui.t('admin.integrationsUpdated'));
|
||||||
|
},
|
||||||
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.roleError'))
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||||
import { AfterViewChecked, Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
|
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
|
||||||
import { Chart, LineController, LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Legend } from 'chart.js';
|
import { Chart, LineController, LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Legend } from 'chart.js';
|
||||||
import { StatsService } from '../../core/services/stats.service';
|
import { StatsService } from '../../core/services/stats.service';
|
||||||
import { UiService } from '../../core/services/ui.service';
|
import { UiService } from '../../core/services/ui.service';
|
||||||
@@ -17,9 +17,9 @@ Chart.register(LineController, LineElement, PointElement, CategoryScale, LinearS
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row row-cards">
|
<div class="row row-cards">
|
||||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.actual') }}</div><div class="display-6">{{ data()?.actualCurrent || 0 | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.actual') }}</div><div class="display-6">{{ (data()?.actualCurrent || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.budget') }}</div><div class="display-6">{{ data()?.totalBudget || 0 | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.budget') }}</div><div class="display-6">{{ (data()?.totalBudget || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.forecast') }}</div><div class="display-6">{{ data()?.forecastCurrentMonth || 0 | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.forecast') }}</div><div class="display-6">{{ (data()?.forecastCurrentMonth || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.pending') }}</div><div class="display-6">{{ data()?.pendingApproval || 0 }}</div></div></div></div>
|
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.pending') }}</div><div class="display-6">{{ data()?.pendingApproval || 0 }}</div></div></div></div>
|
||||||
|
|
||||||
<div class="col-lg-8 d-flex align-items-stretch">
|
<div class="col-lg-8 d-flex align-items-stretch">
|
||||||
@@ -56,21 +56,29 @@ Chart.register(LineController, LineElement, PointElement, CategoryScale, LinearS
|
|||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
export class CashflowComponent implements OnInit, AfterViewChecked, OnDestroy {
|
export class CashflowComponent implements OnInit, OnDestroy {
|
||||||
readonly ui = inject(UiService);
|
readonly ui = inject(UiService);
|
||||||
private readonly statsService = inject(StatsService);
|
private readonly statsService = inject(StatsService);
|
||||||
readonly data = signal<CashflowResponse | null>(null);
|
readonly data = signal<CashflowResponse | null>(null);
|
||||||
private chart?: Chart;
|
private chart?: Chart;
|
||||||
private chartPending = false;
|
ngOnInit() {
|
||||||
|
this.statsService.cashflow().subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.data.set(response);
|
||||||
|
requestAnimationFrame(() => this.renderChart());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit() { this.statsService.cashflow().subscribe({ next: (response) => { this.data.set(response); this.chartPending = true; } }); }
|
|
||||||
ngAfterViewChecked() { if (this.chartPending) { this.chartPending = false; this.renderChart(); } }
|
|
||||||
ngOnDestroy() { this.chart?.destroy(); }
|
ngOnDestroy() { this.chart?.destroy(); }
|
||||||
|
|
||||||
private renderChart() {
|
private renderChart() {
|
||||||
const canvas = document.getElementById('cashflowTrendChart') as HTMLCanvasElement | null;
|
const canvas = document.getElementById('cashflowTrendChart') as HTMLCanvasElement | null;
|
||||||
const data = this.data();
|
const data = this.data();
|
||||||
if (!canvas || !data?.trend?.length) return;
|
if (!canvas || !data?.trend?.length) {
|
||||||
|
this.chart?.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.chart?.destroy();
|
this.chart?.destroy();
|
||||||
this.chart = new Chart(canvas, {
|
this.chart = new Chart(canvas, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
|
|||||||
@@ -180,6 +180,10 @@ export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|||||||
}[status] || 'text-bg-secondary';
|
}[status] || 'text-bg-secondary';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleChartRender() {
|
||||||
|
requestAnimationFrame(() => this.renderChart());
|
||||||
|
}
|
||||||
|
|
||||||
private renderChart() {
|
private renderChart() {
|
||||||
const canvas = document.getElementById('dashboardCategoryChart') as HTMLCanvasElement | null;
|
const canvas = document.getElementById('dashboardCategoryChart') as HTMLCanvasElement | null;
|
||||||
if (!canvas || !this.stats?.byCategory?.length) {
|
if (!canvas || !this.stats?.byCategory?.length) {
|
||||||
|
|||||||
195
web/src/app/features/expenses/expense-detail.component.ts
Normal file
195
web/src/app/features/expenses/expense-detail.component.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||||
|
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
|
import { Location } from '@angular/common';
|
||||||
|
import { ExpensesService } from '../../core/services/expenses.service';
|
||||||
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import type { Expense, Proof } from '../../shared/models';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-expense-detail',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink, CurrencyPipe, DatePipe],
|
||||||
|
template: `
|
||||||
|
<div class="page-header d-print-none mb-3 ec-page-header">
|
||||||
|
<div class="row align-items-center g-3">
|
||||||
|
<div class="col">
|
||||||
|
<h2 class="page-title mb-1">{{ ui.t('expenses.detailTitle') }}</h2>
|
||||||
|
<div class="text-secondary">{{ expense()?.title || ui.t('expenses.title') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto ms-auto">
|
||||||
|
<div class="btn-list">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" (click)="goBack()">{{ ui.t('action.backToList') }}</button>
|
||||||
|
@if (expense()) {
|
||||||
|
<button class="btn btn-primary" type="button" (click)="editExpense()">{{ ui.t('action.edit') }}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loadError()) {
|
||||||
|
<div class="alert alert-danger">{{ loadError() }}</div>
|
||||||
|
} @else if (loading()) {
|
||||||
|
<div class="card"><div class="card-body text-secondary">{{ ui.t('common.loading') }}</div></div>
|
||||||
|
} @else if (expense(); as item) {
|
||||||
|
<div class="row row-cards">
|
||||||
|
<div class="col-12 col-xl-8">
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="card-header"><h3 class="card-title mb-0">{{ item.title }}</h3></div>
|
||||||
|
<div class="card-body d-grid gap-3">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.date') }}</div><div class="fw-semibold">{{ item.expenseDate | date:'yyyy-MM-dd' }}</div></div>
|
||||||
|
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.amount') }}</div><div class="fw-semibold">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</div></div>
|
||||||
|
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.status') }}</div><div><span class="badge" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span></div></div>
|
||||||
|
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.category') }}</div><div class="fw-semibold">{{ item.category.name }}</div></div>
|
||||||
|
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.payment') }}</div><div>{{ paymentLabel(item.paymentMethod) }}</div></div>
|
||||||
|
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.merchantName') }}</div><div>{{ item.merchant || ui.t('expenses.noMerchant') }}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (item.description) {
|
||||||
|
<div>
|
||||||
|
<div class="text-secondary small mb-1">{{ ui.t('expenses.field.description') }}</div>
|
||||||
|
<div>{{ item.description }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (item.tags.length) {
|
||||||
|
<div>
|
||||||
|
<div class="text-secondary small mb-1">{{ ui.t('expenses.field.tags') }}</div>
|
||||||
|
<div class="d-flex flex-wrap gap-1">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (customFieldEntries(item).length) {
|
||||||
|
<div>
|
||||||
|
<div class="text-secondary small mb-1">{{ ui.t('expenses.field.customFields') }}</div>
|
||||||
|
<div class="row g-2">@for (field of customFieldEntries(item); track field[0]) { <div class="col-sm-6"><div class="border rounded-3 p-2 h-100"><div class="text-secondary small">{{ field[0] }}</div><div class="fw-semibold">{{ field[1] }}</div></div></div> }</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-xl-4">
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="card-header"><h3 class="card-title mb-0">{{ ui.t('expenses.existingProofs') }}</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
@if (item.proofs.length) {
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
@for (proof of item.proofs; track proof.id) {
|
||||||
|
<button class="btn btn-outline-secondary text-start" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="text-secondary">{{ ui.t('expenses.noProofs') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card overflow-hidden mt-3">
|
||||||
|
<div class="card-header"><h3 class="card-title mb-0">{{ ui.t('expenses.meta') }}</h3></div>
|
||||||
|
<div class="card-body d-grid gap-2 small">
|
||||||
|
<div><span class="text-secondary">ID:</span> {{ item.id }}</div>
|
||||||
|
<div><span class="text-secondary">{{ ui.t('table.createdAt') || 'Utworzono' }}:</span> {{ item.createdAt | date:'yyyy-MM-dd HH:mm' }}</div>
|
||||||
|
<div><span class="text-secondary">{{ ui.t('table.updatedAt') || 'Zmieniono' }}:</span> {{ item.updatedAt | date:'yyyy-MM-dd HH:mm' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (proofPreview()) {
|
||||||
|
<div class="modal modal-blur fade show d-block" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5>
|
||||||
|
<button class="btn-close ec-modal-close" type="button" (click)="closeProofPreview()"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body ec-proof-modal-body">
|
||||||
|
@if (isPdf(proofPreview()!)) {
|
||||||
|
<iframe class="ec-proof-frame" [src]="proofPreviewUrl()"></iframe>
|
||||||
|
} @else {
|
||||||
|
<img class="img-fluid ec-proof-preview" [src]="proofPreview()?.previewUrl || proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show" (click)="closeProofPreview()"></div>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ExpenseDetailComponent implements OnInit {
|
||||||
|
readonly ui = inject(UiService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly location = inject(Location);
|
||||||
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
private readonly expensesService = inject(ExpensesService);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
|
|
||||||
|
readonly expense = signal<Expense | null>(null);
|
||||||
|
readonly loading = signal(true);
|
||||||
|
readonly loadError = signal('');
|
||||||
|
readonly proofPreview = signal<Proof | null>(null);
|
||||||
|
readonly proofPreviewUrl = computed<SafeResourceUrl | null>(() => {
|
||||||
|
const proof = this.proofPreview();
|
||||||
|
if (!proof || !this.isPdf(proof)) return null;
|
||||||
|
const previewUrl = proof.previewUrl || proof.fileUrl;
|
||||||
|
if (!previewUrl) return null;
|
||||||
|
const suffix = previewUrl.includes('#') ? '' : '#toolbar=0&navpanes=0&scrollbar=1&view=FitH';
|
||||||
|
return this.sanitizer.bypassSecurityTrustResourceUrl(`${previewUrl}${suffix}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.route.paramMap.subscribe((params) => {
|
||||||
|
const id = params.get('id');
|
||||||
|
if (!id) return;
|
||||||
|
this.loading.set(true);
|
||||||
|
this.loadError.set('');
|
||||||
|
this.expense.set(null);
|
||||||
|
this.expensesService.getById(id).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.expense.set(response.item);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
const message = error.error?.message ?? this.ui.t('expenses.loadError');
|
||||||
|
this.loadError.set(message);
|
||||||
|
this.loading.set(false);
|
||||||
|
this.toast.error(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
customFieldEntries(item: Expense) { return Object.entries(item.customFields || {}); }
|
||||||
|
openProof(proof: Proof) { this.proofPreview.set(proof); }
|
||||||
|
closeProofPreview() { this.proofPreview.set(null); }
|
||||||
|
isPdf(proof: Proof) { return (proof.mimeType || '').toLowerCase().includes('pdf'); }
|
||||||
|
goBack() { this.location.back(); }
|
||||||
|
editExpense() {
|
||||||
|
const item = this.expense();
|
||||||
|
if (!item) return;
|
||||||
|
this.router.navigate(['/expenses/add'], { queryParams: { edit: item.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentLabel(value: Expense['paymentMethod']) {
|
||||||
|
if (!value) return this.ui.t('expenses.payment.none');
|
||||||
|
return ({
|
||||||
|
CARD: this.ui.t('expenses.payment.card'),
|
||||||
|
CASH: this.ui.t('expenses.payment.cash'),
|
||||||
|
TRANSFER: this.ui.t('expenses.payment.transfer'),
|
||||||
|
BLIK: 'BLIK',
|
||||||
|
OTHER: this.ui.t('expenses.payment.other')
|
||||||
|
} as Record<string, string>)[value] || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusBadgeClass(status: string) {
|
||||||
|
return ({ DRAFT: 'text-bg-secondary', PENDING: 'text-bg-warning', APPROVED: 'text-bg-success', REJECTED: 'text-bg-danger' } as Record<string, string>)[status] || 'text-bg-secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
551
web/src/app/features/expenses/expense-list.component.ts
Normal file
551
web/src/app/features/expenses/expense-list.component.ts
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||||
|
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||||
|
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
|
import { CategoriesService } from '../../core/services/categories.service';
|
||||||
|
import { ExpensesService } from '../../core/services/expenses.service';
|
||||||
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
|
import { UiService } from '../../core/services/ui.service';
|
||||||
|
import type { DuplicateGroup, Expense, PaginationMeta, Proof } from '../../shared/models';
|
||||||
|
|
||||||
|
type SortColumn = 'expenseDate' | 'title' | 'amount' | 'status' | 'category';
|
||||||
|
|
||||||
|
type ListState = {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
categoryId: string;
|
||||||
|
search: string;
|
||||||
|
status: string;
|
||||||
|
tags: string;
|
||||||
|
duplicatesOnly: boolean;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
sortBy: SortColumn;
|
||||||
|
sortDir: 'asc' | 'desc';
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultState: ListState = {
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
categoryId: '',
|
||||||
|
search: '',
|
||||||
|
status: '',
|
||||||
|
tags: '',
|
||||||
|
duplicatesOnly: false,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
sortBy: 'expenseDate',
|
||||||
|
sortDir: 'desc'
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-expense-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule, RouterLink, CurrencyPipe, DatePipe],
|
||||||
|
template: `
|
||||||
|
<div class="page-header d-print-none mb-3 ec-page-header">
|
||||||
|
<div class="row align-items-center g-3">
|
||||||
|
<div class="col">
|
||||||
|
<h2 class="page-title mb-1">{{ ui.t('expenses.title') }}</h2>
|
||||||
|
<div class="text-secondary">{{ ui.t('expenses.listSubtitle') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<nav class="nav nav-pills gap-2">
|
||||||
|
<a class="nav-link" [routerLink]="['/expenses/add']">{{ ui.t('action.addExpense') }}</a>
|
||||||
|
<a class="nav-link active" [routerLink]="['/expenses/list']">{{ ui.t('expenses.listTitle') }}</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (duplicateGroups().length) {
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<div class="fw-semibold mb-2">{{ ui.t('expenses.duplicatesTitle') }}</div>
|
||||||
|
<div class="d-grid gap-1">
|
||||||
|
@for (group of duplicateGroups().slice(0, 3); track group.source.id) {
|
||||||
|
<div>{{ group.source.title }} · {{ group.matches.length }} {{ ui.t('expenses.potentialMatches') }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="card overflow-hidden mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||||
|
<h3 class="card-title mb-0">{{ ui.t('expenses.filters') }}</h3>
|
||||||
|
@if (hasActiveFilters()) {
|
||||||
|
<span class="badge text-bg-primary">{{ ui.t('action.filter') }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form [formGroup]="filterForm" (ngSubmit)="applyFilters()" class="row g-3 align-items-end">
|
||||||
|
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
|
||||||
|
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
|
||||||
|
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.category') }}</label><select class="form-select" formControlName="categoryId"><option value="">{{ ui.t('expenses.allCategories') }}</option>@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }</select></div>
|
||||||
|
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="">{{ ui.t('common.none') }}</option><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
|
||||||
|
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tags" /></div>
|
||||||
|
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.search') }}</label><input class="form-control" formControlName="search" /></div>
|
||||||
|
<div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox" formControlName="duplicatesOnly" /><span class="form-check-label">{{ ui.t('expenses.duplicatesOnly') }}</span></label></div>
|
||||||
|
<div class="col-12 d-flex gap-2 flex-wrap"><button class="btn btn-primary" type="submit">{{ ui.t('action.filter') }}</button><button class="btn btn-outline-secondary" type="button" (click)="resetFilters()">{{ ui.t('action.reset') }}</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title mb-0">{{ ui.t('expenses.listTitle') }}</h3>
|
||||||
|
<div class="small text-secondary">{{ ui.t('expenses.totalItems') }}: {{ pagination().total }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<select class="form-select form-select-sm w-auto" [value]="pagination().pageSize" (change)="changePageSize($any($event.target).value)">
|
||||||
|
@for (size of pageSizeOptions; track size) {
|
||||||
|
<option [value]="size">{{ size }} / {{ ui.t('expenses.perPage') }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedIds().length) {
|
||||||
|
<div class="card-body border-bottom bg-body-tertiary py-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||||
|
<div class="fw-semibold">{{ ui.t('expenses.selectedCount') }}: {{ selectedIds().length }}</div>
|
||||||
|
<div class="btn-list flex-wrap">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="clearSelection()">{{ ui.t('action.clearSelection') }}</button>
|
||||||
|
<button class="btn btn-sm btn-outline-warning" type="button" (click)="bulkUpdateStatus('PENDING')">{{ ui.t('status.pending') }}</button>
|
||||||
|
<button class="btn btn-sm btn-outline-success" type="button" (click)="bulkUpdateStatus('APPROVED')">{{ ui.t('status.approved') }}</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="button" (click)="bulkDelete()">{{ ui.t('action.delete') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-1">
|
||||||
|
<input class="form-check-input" type="checkbox" [checked]="allVisibleSelected()" [indeterminate]="someVisibleSelected()" (change)="toggleAllVisible($any($event.target).checked)" />
|
||||||
|
</th>
|
||||||
|
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('expenseDate')">{{ ui.t('expenses.field.date') }} {{ sortIndicator('expenseDate') }}</button></th>
|
||||||
|
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('title')">{{ ui.t('table.title') }} {{ sortIndicator('title') }}</button></th>
|
||||||
|
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('category')">{{ ui.t('expenses.field.category') }} {{ sortIndicator('category') }}</button></th>
|
||||||
|
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('status')">{{ ui.t('expenses.field.status') }} {{ sortIndicator('status') }}</button></th>
|
||||||
|
<th class="text-end"><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('amount')">{{ ui.t('table.amount') }} {{ sortIndicator('amount') }}</button></th>
|
||||||
|
<th class="text-end">{{ ui.t('table.actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (item of expenses(); track item.id) {
|
||||||
|
<tr>
|
||||||
|
<td><input class="form-check-input" type="checkbox" [checked]="isSelected(item.id)" (change)="toggleSelection(item.id, $any($event.target).checked)" /></td>
|
||||||
|
<td>{{ item.expenseDate | date:'yyyy-MM-dd' }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<a class="link-body-emphasis text-decoration-none" [routerLink]="['/expenses', item.id]">{{ item.title }}</a>
|
||||||
|
@if (item.possibleDuplicate || item.duplicateStatus) {
|
||||||
|
<span class="badge" [ngClass]="duplicateBadgeClass(item)">{{ duplicateLabel(item) }}</span>
|
||||||
|
}
|
||||||
|
@if (item.recurringSourceId) {
|
||||||
|
<span class="badge text-bg-info">{{ ui.t('recurring.badge') }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="text-secondary small">{{ item.merchant || ui.t('expenses.noMerchant') }}</div>
|
||||||
|
@if (item.tags.length) { <div class="mt-1 d-flex flex-wrap gap-1">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div> }
|
||||||
|
@if (customFieldEntries(item).length) { <div class="small text-secondary mt-1">@for (field of customFieldEntries(item); track field[0]) { <span class="me-2">{{ field[0] }}: {{ field[1] }}</span> }</div> }
|
||||||
|
@if (item.proofs.length) { <div class="mt-2 d-flex flex-wrap gap-2">@for (proof of item.proofs; track proof.id) { <button class="btn btn-sm btn-outline-secondary" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button> }</div> }
|
||||||
|
</td>
|
||||||
|
<td>{{ item.category.name }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<span class="badge d-inline-flex align-items-center justify-content-center" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span>
|
||||||
|
<select class="form-select form-select-sm" [value]="item.status" [disabled]="statusSavingId() === item.id" (change)="quickChangeStatus(item, $any($event.target).value)">
|
||||||
|
<option value="DRAFT">{{ ui.t('status.draft') }}</option>
|
||||||
|
<option value="PENDING">{{ ui.t('status.pending') }}</option>
|
||||||
|
<option value="APPROVED">{{ ui.t('status.approved') }}</option>
|
||||||
|
<option value="REJECTED">{{ ui.t('status.rejected') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-list justify-content-end flex-wrap">
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" [routerLink]="['/expenses', item.id]">{{ ui.t('action.view') }}</a>
|
||||||
|
@if (item.possibleDuplicate && item.duplicateStatus !== 'CONFIRMED') {
|
||||||
|
<button class="btn btn-sm btn-outline-success" type="button" (click)="reviewDuplicate(item, 'CONFIRM')">OK</button>
|
||||||
|
}
|
||||||
|
@if (item.possibleDuplicate && item.duplicateStatus !== 'DISMISSED') {
|
||||||
|
<button class="btn btn-sm btn-outline-warning" type="button" (click)="reviewDuplicate(item, 'DISMISS')">X</button>
|
||||||
|
}
|
||||||
|
@if (item.duplicateStatus === 'DISMISSED' || item.duplicateStatus === 'CONFIRMED') {
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="reviewDuplicate(item, 'REOPEN')">↺</button>
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-outline-primary" type="button" (click)="startEdit(item)">{{ ui.t('action.edit') }}</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="button" (click)="removeExpense(item)">{{ ui.t('action.delete') }}</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty { <tr><td colspan="7" class="text-secondary">{{ ui.t('expenses.noItems') }}</td></tr> }
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||||
|
<div class="small text-secondary">{{ pageStart() }}-{{ pageEnd() }} / {{ pagination().total }}</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasPrev" (click)="changePage(1)">«</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasPrev" (click)="changePage(pagination().page - 1)">‹</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" disabled>{{ pagination().page }} / {{ pagination().totalPages }}</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasNext" (click)="changePage(pagination().page + 1)">›</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasNext" (click)="changePage(pagination().totalPages)">»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (proofPreview()) {
|
||||||
|
<div class="modal modal-blur fade show d-block" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5>
|
||||||
|
<button class="btn-close ec-modal-close" type="button" (click)="closeProofPreview()"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body ec-proof-modal-body">
|
||||||
|
@if (isPdf(proofPreview()!)) {
|
||||||
|
<iframe class="ec-proof-frame" [src]="proofPreviewUrl()"></iframe>
|
||||||
|
} @else {
|
||||||
|
<img class="img-fluid ec-proof-preview" [src]="proofPreview()?.previewUrl || proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show" (click)="closeProofPreview()"></div>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ExpenseListComponent implements OnInit {
|
||||||
|
readonly ui = inject(UiService);
|
||||||
|
private readonly fb = inject(FormBuilder);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
private readonly categoriesService = inject(CategoriesService);
|
||||||
|
private readonly expensesService = inject(ExpensesService);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
|
|
||||||
|
readonly categories = this.categoriesService.items;
|
||||||
|
readonly expenses = signal<Expense[]>([]);
|
||||||
|
readonly duplicateGroups = signal<DuplicateGroup[]>([]);
|
||||||
|
readonly proofPreview = signal<Proof | null>(null);
|
||||||
|
readonly proofPreviewUrl = computed<SafeResourceUrl | null>(() => {
|
||||||
|
const proof = this.proofPreview();
|
||||||
|
if (!proof || !this.isPdf(proof)) return null;
|
||||||
|
const previewUrl = proof.previewUrl || proof.fileUrl;
|
||||||
|
if (!previewUrl) return null;
|
||||||
|
const suffix = previewUrl.includes('#') ? '' : '#toolbar=0&navpanes=0&scrollbar=1&view=FitH';
|
||||||
|
return this.sanitizer.bypassSecurityTrustResourceUrl(`${previewUrl}${suffix}`);
|
||||||
|
});
|
||||||
|
readonly statusSavingId = signal<string | null>(null);
|
||||||
|
readonly pagination = signal<PaginationMeta>({ page: 1, pageSize: 20, total: 0, totalPages: 1, hasPrev: false, hasNext: false });
|
||||||
|
readonly pageSizeOptions = [10, 20, 50];
|
||||||
|
readonly sortBy = signal<SortColumn>('expenseDate');
|
||||||
|
readonly sortDir = signal<'asc' | 'desc'>('desc');
|
||||||
|
readonly selectedIds = signal<string[]>([]);
|
||||||
|
readonly visibleIds = computed(() => this.expenses().map((item) => item.id));
|
||||||
|
readonly allVisibleSelected = computed(() => this.visibleIds().length > 0 && this.visibleIds().every((id) => this.selectedIds().includes(id)));
|
||||||
|
readonly someVisibleSelected = computed(() => !this.allVisibleSelected() && this.visibleIds().some((id) => this.selectedIds().includes(id)));
|
||||||
|
|
||||||
|
readonly filterForm = this.fb.nonNullable.group({ startDate: [''], endDate: [''], categoryId: [''], search: [''], status: [''], tags: [''], duplicatesOnly: [false] });
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.categoriesService.ensureLoaded(true);
|
||||||
|
this.route.queryParamMap.subscribe((params) => {
|
||||||
|
const state: ListState = {
|
||||||
|
startDate: params.get('startDate') ?? defaultState.startDate,
|
||||||
|
endDate: params.get('endDate') ?? defaultState.endDate,
|
||||||
|
categoryId: params.get('categoryId') ?? defaultState.categoryId,
|
||||||
|
search: params.get('search') ?? defaultState.search,
|
||||||
|
status: params.get('status') ?? defaultState.status,
|
||||||
|
tags: params.get('tags') ?? defaultState.tags,
|
||||||
|
duplicatesOnly: ['1', 'true'].includes((params.get('duplicatesOnly') ?? '').toLowerCase()),
|
||||||
|
page: this.parsePositiveInt(params.get('page'), defaultState.page),
|
||||||
|
pageSize: this.parsePositiveInt(params.get('pageSize'), defaultState.pageSize),
|
||||||
|
sortBy: this.parseSortColumn(params.get('sortBy')),
|
||||||
|
sortDir: params.get('sortDir') === 'asc' ? 'asc' : 'desc'
|
||||||
|
};
|
||||||
|
this.filterForm.patchValue({
|
||||||
|
startDate: state.startDate,
|
||||||
|
endDate: state.endDate,
|
||||||
|
categoryId: state.categoryId,
|
||||||
|
search: state.search,
|
||||||
|
status: state.status,
|
||||||
|
tags: state.tags,
|
||||||
|
duplicatesOnly: state.duplicatesOnly
|
||||||
|
}, { emitEvent: false });
|
||||||
|
this.sortBy.set(state.sortBy);
|
||||||
|
this.sortDir.set(state.sortDir);
|
||||||
|
this.pagination.update((current) => ({ ...current, page: state.page, pageSize: state.pageSize }));
|
||||||
|
this.loadExpenses(state);
|
||||||
|
this.loadDuplicates();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
customFieldEntries(item: Expense) { return Object.entries(item.customFields || {}); }
|
||||||
|
hasActiveFilters() {
|
||||||
|
const raw = this.filterForm.getRawValue();
|
||||||
|
return Boolean(raw.startDate || raw.endDate || raw.categoryId || raw.search || raw.status || raw.tags || raw.duplicatesOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadExpenses(state: ListState) {
|
||||||
|
this.expensesService.list({
|
||||||
|
startDate: state.startDate || undefined,
|
||||||
|
endDate: state.endDate || undefined,
|
||||||
|
categoryId: state.categoryId || undefined,
|
||||||
|
search: state.search || undefined,
|
||||||
|
status: state.status || undefined,
|
||||||
|
tags: state.tags || undefined,
|
||||||
|
duplicatesOnly: state.duplicatesOnly || undefined,
|
||||||
|
page: state.page,
|
||||||
|
pageSize: state.pageSize,
|
||||||
|
sortBy: state.sortBy,
|
||||||
|
sortDir: state.sortDir
|
||||||
|
}).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.expenses.set(response.items);
|
||||||
|
this.pagination.set(response.pagination ?? { page: state.page, pageSize: state.pageSize, total: response.items.length, totalPages: 1, hasPrev: false, hasNext: false });
|
||||||
|
this.selectedIds.update((ids) => ids.filter((id) => response.items.some((item) => item.id === id)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadDuplicates() {
|
||||||
|
this.expensesService.duplicates().subscribe({ next: (response) => this.duplicateGroups.set(response.items) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildQueryParams(overrides: Partial<ListState> = {}) {
|
||||||
|
const raw = this.filterForm.getRawValue();
|
||||||
|
const state: ListState = {
|
||||||
|
startDate: raw.startDate,
|
||||||
|
endDate: raw.endDate,
|
||||||
|
categoryId: raw.categoryId,
|
||||||
|
search: raw.search,
|
||||||
|
status: raw.status,
|
||||||
|
tags: raw.tags,
|
||||||
|
duplicatesOnly: raw.duplicatesOnly,
|
||||||
|
page: this.pagination().page,
|
||||||
|
pageSize: this.pagination().pageSize,
|
||||||
|
sortBy: this.sortBy(),
|
||||||
|
sortDir: this.sortDir(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: state.startDate || null,
|
||||||
|
endDate: state.endDate || null,
|
||||||
|
categoryId: state.categoryId || null,
|
||||||
|
search: state.search || null,
|
||||||
|
status: state.status || null,
|
||||||
|
tags: state.tags || null,
|
||||||
|
duplicatesOnly: state.duplicatesOnly ? '1' : null,
|
||||||
|
page: state.page !== defaultState.page ? state.page : null,
|
||||||
|
pageSize: state.pageSize !== defaultState.pageSize ? state.pageSize : null,
|
||||||
|
sortBy: state.sortBy !== defaultState.sortBy ? state.sortBy : null,
|
||||||
|
sortDir: state.sortDir !== defaultState.sortDir ? state.sortDir : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateUrl(overrides: Partial<ListState> = {}) {
|
||||||
|
this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: this.buildQueryParams(overrides),
|
||||||
|
replaceUrl: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilters() {
|
||||||
|
this.clearSelection();
|
||||||
|
this.updateUrl({ page: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFilters() {
|
||||||
|
this.filterForm.reset({ startDate: '', endDate: '', categoryId: '', search: '', status: '', tags: '', duplicatesOnly: false });
|
||||||
|
this.clearSelection();
|
||||||
|
this.updateUrl({ ...defaultState });
|
||||||
|
}
|
||||||
|
|
||||||
|
setSort(column: SortColumn) {
|
||||||
|
if (this.sortBy() === column) this.sortDir.set(this.sortDir() === 'asc' ? 'desc' : 'asc');
|
||||||
|
else {
|
||||||
|
this.sortBy.set(column);
|
||||||
|
this.sortDir.set(column === 'amount' || column === 'title' || column === 'category' ? 'asc' : 'desc');
|
||||||
|
}
|
||||||
|
this.clearSelection();
|
||||||
|
this.updateUrl({ page: 1, sortBy: this.sortBy(), sortDir: this.sortDir() });
|
||||||
|
}
|
||||||
|
|
||||||
|
sortIndicator(column: SortColumn) {
|
||||||
|
if (this.sortBy() !== column) return '';
|
||||||
|
return this.sortDir() === 'asc' ? '↑' : '↓';
|
||||||
|
}
|
||||||
|
|
||||||
|
changePage(page: number) {
|
||||||
|
if (page < 1 || page > this.pagination().totalPages) return;
|
||||||
|
this.updateUrl({ page });
|
||||||
|
}
|
||||||
|
|
||||||
|
changePageSize(value: string | number) {
|
||||||
|
const pageSize = Number(value);
|
||||||
|
if (!Number.isFinite(pageSize) || pageSize <= 0) return;
|
||||||
|
this.clearSelection();
|
||||||
|
this.updateUrl({ page: 1, pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
pageStart() {
|
||||||
|
if (!this.pagination().total) return 0;
|
||||||
|
return (this.pagination().page - 1) * this.pagination().pageSize + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pageEnd() {
|
||||||
|
if (!this.pagination().total) return 0;
|
||||||
|
return Math.min(this.pagination().page * this.pagination().pageSize, this.pagination().total);
|
||||||
|
}
|
||||||
|
|
||||||
|
isSelected(id: string) { return this.selectedIds().includes(id); }
|
||||||
|
|
||||||
|
toggleSelection(id: string, checked: boolean) {
|
||||||
|
this.selectedIds.update((ids) => checked ? Array.from(new Set([...ids, id])) : ids.filter((item) => item !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAllVisible(checked: boolean) {
|
||||||
|
const visibleIds = this.visibleIds();
|
||||||
|
this.selectedIds.update((ids) => {
|
||||||
|
if (checked) return Array.from(new Set([...ids, ...visibleIds]));
|
||||||
|
return ids.filter((id) => !visibleIds.includes(id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelection() { this.selectedIds.set([]); }
|
||||||
|
|
||||||
|
startEdit(item: Expense) {
|
||||||
|
this.router.navigate(['/expenses/add'], { queryParams: { edit: item.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
removeExpense(item: Expense) {
|
||||||
|
this.expensesService.delete(item.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toast.success(this.ui.t('expenses.deleted'));
|
||||||
|
this.clearSelection();
|
||||||
|
this.loadExpensesFromCurrentRoute();
|
||||||
|
this.loadDuplicates();
|
||||||
|
},
|
||||||
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('expenses.deleteError'))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
quickChangeStatus(item: Expense, nextStatus: string) {
|
||||||
|
if (!nextStatus || nextStatus === item.status) return;
|
||||||
|
this.statusSavingId.set(item.id);
|
||||||
|
this.expensesService.updateStatus(item.id, nextStatus as Expense['status']).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toast.success(this.ui.t('expenses.statusUpdated'));
|
||||||
|
this.statusSavingId.set(null);
|
||||||
|
this.loadExpensesFromCurrentRoute();
|
||||||
|
this.loadDuplicates();
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.statusSavingId.set(null);
|
||||||
|
this.toast.error(error.error?.message ?? this.ui.t('expenses.statusUpdateError'));
|
||||||
|
this.loadExpensesFromCurrentRoute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkUpdateStatus(status: Expense['status']) {
|
||||||
|
if (!this.selectedIds().length) return;
|
||||||
|
this.expensesService.bulkUpdateStatus(this.selectedIds(), status).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toast.success(this.ui.t('expenses.bulkUpdated'));
|
||||||
|
this.clearSelection();
|
||||||
|
this.loadExpensesFromCurrentRoute();
|
||||||
|
this.loadDuplicates();
|
||||||
|
},
|
||||||
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('expenses.bulkActionError'))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkDelete() {
|
||||||
|
if (!this.selectedIds().length) return;
|
||||||
|
if (!globalThis.confirm(this.ui.t('expenses.bulkDeleteConfirm'))) return;
|
||||||
|
this.expensesService.bulkDelete(this.selectedIds()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toast.success(this.ui.t('expenses.bulkDeleted'));
|
||||||
|
this.clearSelection();
|
||||||
|
this.loadExpensesFromCurrentRoute();
|
||||||
|
this.loadDuplicates();
|
||||||
|
},
|
||||||
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('expenses.bulkActionError'))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reviewDuplicate(item: Expense, action: 'CONFIRM' | 'DISMISS' | 'REOPEN') {
|
||||||
|
this.expensesService.reviewDuplicate(item.id, action).subscribe({
|
||||||
|
next: () => {
|
||||||
|
if (action === 'CONFIRM') this.toast.success(this.ui.t('expenses.duplicateConfirmed'));
|
||||||
|
if (action === 'DISMISS') this.toast.success(this.ui.t('expenses.duplicateDismissed'));
|
||||||
|
if (action === 'REOPEN') this.toast.success(this.ui.t('expenses.duplicateReopened'));
|
||||||
|
this.loadExpensesFromCurrentRoute();
|
||||||
|
this.loadDuplicates();
|
||||||
|
},
|
||||||
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('toast.error'))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openProof(proof: Proof) { this.proofPreview.set(proof); }
|
||||||
|
closeProofPreview() { this.proofPreview.set(null); }
|
||||||
|
isPdf(proof: Proof) { return (proof.mimeType || '').toLowerCase().includes('pdf'); }
|
||||||
|
|
||||||
|
statusBadgeClass(status: string) {
|
||||||
|
return ({ DRAFT: 'text-bg-secondary', PENDING: 'text-bg-warning', APPROVED: 'text-bg-success', REJECTED: 'text-bg-danger' } as Record<string, string>)[status] || 'text-bg-secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicateBadgeClass(item: Expense) {
|
||||||
|
const state = item.duplicateStatus ?? (item.possibleDuplicate ? 'OPEN' : null);
|
||||||
|
return ({ OPEN: 'text-bg-warning', CONFIRMED: 'text-bg-danger', DISMISSED: 'text-bg-secondary' } as Record<string, string>)[state || 'OPEN'] || 'text-bg-warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicateLabel(item: Expense) {
|
||||||
|
const state = item.duplicateStatus ?? (item.possibleDuplicate ? 'OPEN' : null);
|
||||||
|
if (state === 'CONFIRMED') return this.ui.t('expenses.duplicateStatus.confirmed');
|
||||||
|
if (state === 'DISMISSED') return this.ui.t('expenses.duplicateStatus.dismissed');
|
||||||
|
return this.ui.t('expenses.duplicateStatus.open');
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadExpensesFromCurrentRoute() {
|
||||||
|
const params = this.route.snapshot.queryParamMap;
|
||||||
|
this.loadExpenses({
|
||||||
|
startDate: params.get('startDate') ?? defaultState.startDate,
|
||||||
|
endDate: params.get('endDate') ?? defaultState.endDate,
|
||||||
|
categoryId: params.get('categoryId') ?? defaultState.categoryId,
|
||||||
|
search: params.get('search') ?? defaultState.search,
|
||||||
|
status: params.get('status') ?? defaultState.status,
|
||||||
|
tags: params.get('tags') ?? defaultState.tags,
|
||||||
|
duplicatesOnly: ['1', 'true'].includes((params.get('duplicatesOnly') ?? '').toLowerCase()),
|
||||||
|
page: this.parsePositiveInt(params.get('page'), this.pagination().page),
|
||||||
|
pageSize: this.parsePositiveInt(params.get('pageSize'), this.pagination().pageSize),
|
||||||
|
sortBy: this.parseSortColumn(params.get('sortBy')),
|
||||||
|
sortDir: params.get('sortDir') === 'asc' ? 'asc' : 'desc'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePositiveInt(value: string | null, fallback: number) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSortColumn(value: string | null): SortColumn {
|
||||||
|
return (['expenseDate', 'title', 'amount', 'status', 'category'] as const).includes((value ?? '') as SortColumn)
|
||||||
|
? (value as SortColumn)
|
||||||
|
: defaultState.sortBy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
import { ImageCroppedEvent, ImageCropperComponent } from 'ngx-image-cropper';
|
import { ImageCroppedEvent, ImageCropperComponent } from 'ngx-image-cropper';
|
||||||
import { CategoriesService } from '../../core/services/categories.service';
|
import { CategoriesService } from '../../core/services/categories.service';
|
||||||
@@ -7,7 +9,7 @@ import { ExpensesService } from '../../core/services/expenses.service';
|
|||||||
import { MerchantsService } from '../../core/services/merchants.service';
|
import { MerchantsService } from '../../core/services/merchants.service';
|
||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
import { UiService } from '../../core/services/ui.service';
|
import { UiService } from '../../core/services/ui.service';
|
||||||
import type { DuplicateGroup, Expense, Merchant, Proof } from '../../shared/models';
|
import type { Expense, Merchant, Proof } from '../../shared/models';
|
||||||
|
|
||||||
const formatLocalDate = (date: Date) => {
|
const formatLocalDate = (date: Date) => {
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
@@ -21,26 +23,38 @@ const today = formatLocalDate(new Date());
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-expenses',
|
selector: 'app-expenses',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, DatePipe, ImageCropperComponent],
|
imports: [CommonModule, ReactiveFormsModule, RouterLink, ImageCropperComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="page-header d-print-none mb-3 ec-page-header">
|
<div class="page-header d-print-none mb-3 ec-page-header">
|
||||||
<div class="row align-items-center g-3"><div class="col"><h2 class="page-title mb-1">{{ ui.t('expenses.title') }}</h2><div class="text-secondary">{{ ui.t('expenses.subtitle') }}</div></div></div>
|
<div class="row align-items-center g-3">
|
||||||
|
<div class="col">
|
||||||
|
<h2 class="page-title mb-1">{{ ui.t('expenses.title') }}</h2>
|
||||||
|
<div class="text-secondary">{{ ui.t('expenses.subtitle') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (duplicateGroups().length) {
|
<div class="mb-3">
|
||||||
<div class="alert alert-warning">
|
<nav class="nav nav-pills gap-2">
|
||||||
<div class="fw-semibold mb-2">{{ ui.t('expenses.duplicatesTitle') }}</div>
|
<a class="nav-link active" [routerLink]="['/expenses/add']">{{ ui.t('action.addExpense') }}</a>
|
||||||
<div class="d-grid gap-1">@for (group of duplicateGroups().slice(0, 3); track group.source.id) { <div>{{ group.source.title }} · {{ group.matches.length }} {{ ui.t('expenses.potentialMatches') }}</div> }</div>
|
<a class="nav-link" [routerLink]="['/expenses/list']">{{ ui.t('expenses.listTitle') }}</a>
|
||||||
</div>
|
</nav>
|
||||||
}
|
</div>
|
||||||
|
|
||||||
<div class="row row-cards align-items-start">
|
<div class="row row-cards align-items-start">
|
||||||
<div class="col-xl-7">
|
<div class="col-12">
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center"><h3 class="card-title">{{ editingExpenseId() ? ui.t('expenses.edit') : ui.t('expenses.new') }}</h3>@if (editingExpenseId()) { <button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button> }</div>
|
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||||
|
<h3 class="card-title mb-0">{{ editingExpenseId() ? ui.t('expenses.edit') : ui.t('expenses.new') }}</h3>
|
||||||
|
@if (editingExpenseId()) {
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form [formGroup]="expenseForm" (ngSubmit)="submitExpense()" class="d-grid gap-3" novalidate>
|
<form [formGroup]="expenseForm" (ngSubmit)="submitExpense()" class="d-grid gap-3" novalidate>
|
||||||
@if (submitted() && expenseForm.invalid) { <div class="alert alert-danger mb-0">{{ ui.t('expenses.requiredHint') }}</div> }
|
@if (submitted() && expenseForm.invalid) {
|
||||||
|
<div class="alert alert-danger mb-0">{{ ui.t('expenses.requiredHint') }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-7"><label class="form-label">{{ ui.t('expenses.field.title') }} <span class="text-danger">*</span></label><input class="form-control" formControlName="title" [class.is-invalid]="expenseForm.controls.title.invalid && (expenseForm.controls.title.touched || submitted())" /></div>
|
<div class="col-md-7"><label class="form-label">{{ ui.t('expenses.field.title') }} <span class="text-danger">*</span></label><input class="form-control" formControlName="title" [class.is-invalid]="expenseForm.controls.title.invalid && (expenseForm.controls.title.touched || submitted())" /></div>
|
||||||
@@ -70,25 +84,38 @@ const today = formatLocalDate(new Date());
|
|||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
|
||||||
@if (!editingExpenseId()) {
|
<div class="card bg-body-tertiary overflow-hidden"><div class="card-body d-grid gap-3">
|
||||||
<div class="card bg-body-tertiary overflow-hidden"><div class="card-body d-grid gap-3">
|
<div class="row g-3">
|
||||||
<div class="row g-3">
|
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.proofType') }}</label><select class="form-select" formControlName="proofType"><option value="RECEIPT">{{ ui.t('proof.receipt') }}</option><option value="INVOICE">{{ ui.t('proof.invoice') }}</option><option value="NOTE">{{ ui.t('proof.note') }}</option><option value="BANK_STATEMENT">{{ ui.t('proof.statement') }}</option><option value="OTHER">{{ ui.t('proof.other') }}</option></select></div>
|
||||||
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.proofType') }}</label><select class="form-select" formControlName="proofType"><option value="RECEIPT">{{ ui.t('proof.receipt') }}</option><option value="INVOICE">{{ ui.t('proof.invoice') }}</option><option value="NOTE">{{ ui.t('proof.note') }}</option><option value="BANK_STATEMENT">{{ ui.t('proof.statement') }}</option><option value="OTHER">{{ ui.t('proof.other') }}</option></select></div>
|
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.proofLabel') }}</label><input class="form-control" formControlName="proofLabel" /></div>
|
||||||
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.proofLabel') }}</label><input class="form-control" formControlName="proofLabel" /></div>
|
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.file') }}</label><input class="form-control" type="file" accept="image/*,.pdf" multiple (change)="onProofSelected($event)" /></div>
|
||||||
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.file') }}</label><input class="form-control" type="file" accept="image/*,.pdf" multiple (change)="onProofSelected($event)" /></div>
|
<div class="col-12"><label class="form-label">{{ ui.t('expenses.field.proofNote') }}</label><textarea class="form-control" rows="2" formControlName="proofNote"></textarea></div>
|
||||||
<div class="col-12"><label class="form-label">{{ ui.t('expenses.field.proofNote') }}</label><textarea class="form-control" rows="2" formControlName="proofNote"></textarea></div>
|
</div>
|
||||||
|
|
||||||
|
@if (editingExpenseId() && editingProofs().length) {
|
||||||
|
<div>
|
||||||
|
<div class="form-label">{{ ui.t('expenses.existingProofs') }}</div>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
@for (proof of editingProofs(); track proof.id) {
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-2 border rounded-3 p-2 bg-white">
|
||||||
|
<button class="btn btn-link text-start p-0 text-decoration-none flex-grow-1" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="button" (click)="markProofForRemoval(proof)">{{ ui.t('action.delete') }}</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (showCropper()) {
|
}
|
||||||
<div><div class="form-label">{{ ui.t('expenses.field.crop') }}</div><image-cropper [imageChangedEvent]="imageChangedEvent()" [maintainAspectRatio]="false" format="png" (imageCropped)="onImageCropped($event)"></image-cropper></div>
|
|
||||||
}
|
@if (showCropper()) {
|
||||||
@if (croppedPreview()) {
|
<div><div class="form-label">{{ ui.t('expenses.field.crop') }}</div><image-cropper [imageChangedEvent]="imageChangedEvent()" [maintainAspectRatio]="false" format="png" (imageCropped)="onImageCropped($event)"></image-cropper></div>
|
||||||
<div><div class="form-label">{{ ui.t('expenses.field.cropPreview') }}</div><img class="img-fluid rounded" [src]="croppedPreview()" [alt]="ui.t('expenses.field.cropPreview')" /></div>
|
}
|
||||||
}
|
@if (croppedPreview()) {
|
||||||
@if (selectedFiles().length) {
|
<div><div class="form-label">{{ ui.t('expenses.field.cropPreview') }}</div><img class="img-fluid rounded" [src]="croppedPreview()" [alt]="ui.t('expenses.field.cropPreview')" /></div>
|
||||||
<div><div class="form-label">{{ ui.t('expenses.attachmentsSelected') }}</div><div class="d-flex flex-wrap gap-2">@for (file of selectedFiles(); track file.name + $index) { <span class="badge text-bg-secondary">{{ file.name }}</span> }</div></div>
|
}
|
||||||
}
|
@if (selectedFiles().length) {
|
||||||
</div></div>
|
<div><div class="form-label">{{ ui.t('expenses.attachmentsSelected') }}</div><div class="d-flex flex-wrap gap-2">@for (file of selectedFiles(); track file.name + $index) { <span class="badge text-bg-secondary">{{ file.name }}</span> }</div></div>
|
||||||
}
|
}
|
||||||
|
</div></div>
|
||||||
|
|
||||||
<div class="btn-list flex-wrap">
|
<div class="btn-list flex-wrap">
|
||||||
<button class="btn btn-outline-secondary" type="button" (click)="submitExpense('DRAFT')" [disabled]="saving()">{{ ui.t('action.saveDraft') }}</button>
|
<button class="btn btn-outline-secondary" type="button" (click)="submitExpense('DRAFT')" [disabled]="saving()">{{ ui.t('action.saveDraft') }}</button>
|
||||||
@@ -99,75 +126,12 @@ const today = formatLocalDate(new Date());
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-xl-5">
|
|
||||||
<div class="card overflow-hidden mb-3">
|
|
||||||
<div class="card-header"><h3 class="card-title">{{ ui.t('expenses.filters') }}</h3></div>
|
|
||||||
<div class="card-body"><form [formGroup]="filterForm" (ngSubmit)="loadExpenses()" class="row g-3 align-items-end">
|
|
||||||
<div class="col-sm-6"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
|
|
||||||
<div class="col-sm-6"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
|
|
||||||
<div class="col-sm-6"><label class="form-label">{{ ui.t('expenses.field.category') }}</label><select class="form-select" formControlName="categoryId"><option value="">{{ ui.t('expenses.allCategories') }}</option>@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }</select></div>
|
|
||||||
<div class="col-sm-6"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="">{{ ui.t('common.none') }}</option><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
|
|
||||||
<div class="col-sm-6"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tags" /></div>
|
|
||||||
<div class="col-sm-6"><label class="form-label">{{ ui.t('expenses.search') }}</label><input class="form-control" formControlName="search" /></div>
|
|
||||||
<div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox" formControlName="duplicatesOnly" /><span class="form-check-label">{{ ui.t('expenses.duplicatesOnly') }}</span></label></div>
|
|
||||||
<div class="col-12 d-flex gap-2 flex-wrap"><button class="btn btn-primary" type="submit">{{ ui.t('action.filter') }}</button><button class="btn btn-outline-secondary" type="button" (click)="resetFilters()">{{ ui.t('action.reset') }}</button></div>
|
|
||||||
</form></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card overflow-hidden">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-vcenter card-table mb-0">
|
|
||||||
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('expenses.field.status') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
@for (item of expenses(); track item.id) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="fw-semibold d-flex align-items-center gap-2 flex-wrap">
|
|
||||||
{{ item.title }}
|
|
||||||
@if (item.possibleDuplicate || item.duplicateStatus) {
|
|
||||||
<span class="badge" [ngClass]="duplicateBadgeClass(item)">{{ duplicateLabel(item) }}</span>
|
|
||||||
}
|
|
||||||
@if (item.recurringSourceId) {
|
|
||||||
<span class="badge text-bg-info">{{ ui.t('recurring.badge') }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="text-secondary small">{{ item.expenseDate | date:'yyyy-MM-dd' }} · {{ item.category.name }} · {{ item.merchant || ui.t('expenses.noMerchant') }}</div>
|
|
||||||
@if (item.tags.length) { <div class="mt-1 d-flex flex-wrap gap-1">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div> }
|
|
||||||
@if (customFieldEntries(item).length) { <div class="small text-secondary mt-1">@for (field of customFieldEntries(item); track field[0]) { <span class="me-2">{{ field[0] }}: {{ field[1] }}</span> }</div> }
|
|
||||||
@if (item.proofs.length) { <div class="mt-2 d-flex flex-wrap gap-2">@for (proof of item.proofs; track proof.id) { <button class="btn btn-sm btn-outline-secondary" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button> }</div> }
|
|
||||||
</td>
|
|
||||||
<td><span class="badge" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span></td>
|
|
||||||
<td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<div class="btn-list justify-content-end flex-wrap">
|
|
||||||
@if (item.possibleDuplicate && item.duplicateStatus !== 'CONFIRMED') {
|
|
||||||
<button class="btn btn-sm btn-outline-success" type="button" (click)="reviewDuplicate(item, 'CONFIRM')">OK</button>
|
|
||||||
}
|
|
||||||
@if (item.possibleDuplicate && item.duplicateStatus !== 'DISMISSED') {
|
|
||||||
<button class="btn btn-sm btn-outline-warning" type="button" (click)="reviewDuplicate(item, 'DISMISS')">X</button>
|
|
||||||
}
|
|
||||||
@if (item.duplicateStatus === 'DISMISSED' || item.duplicateStatus === 'CONFIRMED') {
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="reviewDuplicate(item, 'REOPEN')">↺</button>
|
|
||||||
}
|
|
||||||
<button class="btn btn-sm btn-outline-primary" type="button" (click)="startEdit(item)">{{ ui.t('action.edit') }}</button>
|
|
||||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="removeExpense(item)">{{ ui.t('action.delete') }}</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty { <tr><td colspan="4" class="text-secondary">{{ ui.t('expenses.noItems') }}</td></tr> }
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (merchantModalOpen()) {
|
@if (merchantModalOpen()) {
|
||||||
<div class="modal modal-blur fade show d-block" tabindex="-1"><div class="modal-dialog modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">{{ ui.t('merchant.new') }}</h5><button class="btn-close" type="button" (click)="closeMerchantModal()"></button></div><form [formGroup]="merchantForm" (ngSubmit)="saveMerchant()"><div class="modal-body"><div class="d-grid gap-3"><div><label class="form-label">{{ ui.t('merchant.name') }}</label><input class="form-control" formControlName="name" /></div><div><label class="form-label">{{ ui.t('merchant.type') }}</label><select class="form-select" formControlName="kind"><option value="MERCHANT">{{ ui.t('merchant.kind.merchant') }}</option><option value="SERVICE_PROVIDER">{{ ui.t('merchant.kind.service') }}</option><option value="OTHER">{{ ui.t('merchant.kind.other') }}</option></select></div><div><label class="form-label">{{ ui.t('merchant.notes') }}</label><textarea class="form-control" rows="3" formControlName="notes"></textarea></div></div></div><div class="modal-footer"><button class="btn btn-ghost-secondary" type="button" (click)="closeMerchantModal()">{{ ui.t('action.cancel') }}</button><button class="btn btn-success" [disabled]="merchantForm.invalid">{{ ui.t('action.saveMerchant') }}</button></div></form></div></div></div><div class="modal-backdrop fade show"></div>
|
<div class="modal modal-blur fade show d-block" tabindex="-1"><div class="modal-dialog modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">{{ ui.t('merchant.new') }}</h5><button class="btn-close ec-modal-close" type="button" (click)="closeMerchantModal()"></button></div><form [formGroup]="merchantForm" (ngSubmit)="saveMerchant()"><div class="modal-body"><div class="d-grid gap-3"><div><label class="form-label">{{ ui.t('merchant.name') }}</label><input class="form-control" formControlName="name" /></div><div><label class="form-label">{{ ui.t('merchant.type') }}</label><select class="form-select" formControlName="kind"><option value="MERCHANT">{{ ui.t('merchant.kind.merchant') }}</option><option value="SERVICE_PROVIDER">{{ ui.t('merchant.kind.service') }}</option><option value="OTHER">{{ ui.t('merchant.kind.other') }}</option></select></div><div><label class="form-label">{{ ui.t('merchant.notes') }}</label><textarea class="form-control" rows="3" formControlName="notes"></textarea></div></div></div><div class="modal-footer"><button class="btn btn-ghost-secondary" type="button" (click)="closeMerchantModal()">{{ ui.t('action.cancel') }}</button><button class="btn btn-success" [disabled]="merchantForm.invalid">{{ ui.t('action.saveMerchant') }}</button></div></form></div></div></div><div class="modal-backdrop fade show"></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (proofPreview()) {
|
@if (proofPreview()) {
|
||||||
<div class="modal modal-blur fade show d-block" tabindex="-1"><div class="modal-dialog modal-xl modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5><button class="btn-close" type="button" (click)="closeProofPreview()"></button></div><div class="modal-body">@if (isPdf(proofPreview()!)) { <embed [attr.src]="proofPreview()?.fileUrl" type="application/pdf" style="width:100%;height:75vh;" /> } @else { <img class="img-fluid" [src]="proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" /> }</div></div></div></div><div class="modal-backdrop fade show"></div>
|
<div class="modal modal-blur fade show d-block" tabindex="-1"><div class="modal-dialog modal-xl modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5><button class="btn-close ec-modal-close" type="button" (click)="closeProofPreview()"></button></div><div class="modal-body ec-proof-modal-body">@if (isPdf(proofPreview()!)) { <iframe class="ec-proof-frame" [src]="proofPreviewUrl()"></iframe> } @else { <img class="img-fluid ec-proof-preview" [src]="proofPreview()?.previewUrl || proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" /> }</div></div></div></div><div class="modal-backdrop fade show" (click)="closeProofPreview()"></div>
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
@@ -178,22 +142,33 @@ export class ExpensesComponent implements OnInit {
|
|||||||
private readonly merchantsService = inject(MerchantsService);
|
private readonly merchantsService = inject(MerchantsService);
|
||||||
private readonly expensesService = inject(ExpensesService);
|
private readonly expensesService = inject(ExpensesService);
|
||||||
private readonly toast = inject(ToastService);
|
private readonly toast = inject(ToastService);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
|
||||||
readonly categories = this.categoriesService.items;
|
readonly categories = this.categoriesService.items;
|
||||||
readonly merchants = this.merchantsService.items;
|
readonly merchants = this.merchantsService.items;
|
||||||
readonly expenses = signal<Expense[]>([]);
|
|
||||||
readonly duplicateGroups = signal<DuplicateGroup[]>([]);
|
|
||||||
readonly selectedMerchantId = signal('');
|
readonly selectedMerchantId = signal('');
|
||||||
readonly editingExpenseId = signal<string | null>(null);
|
readonly editingExpenseId = signal<string | null>(null);
|
||||||
readonly saving = signal(false);
|
readonly saving = signal(false);
|
||||||
readonly submitted = signal(false);
|
readonly submitted = signal(false);
|
||||||
readonly merchantModalOpen = signal(false);
|
readonly merchantModalOpen = signal(false);
|
||||||
readonly proofPreview = signal<Proof | null>(null);
|
readonly proofPreview = signal<Proof | null>(null);
|
||||||
|
readonly proofPreviewUrl = computed<SafeResourceUrl | null>(() => {
|
||||||
|
const proof = this.proofPreview();
|
||||||
|
if (!proof || !this.isPdf(proof)) return null;
|
||||||
|
const previewUrl = proof.previewUrl || proof.fileUrl;
|
||||||
|
if (!previewUrl) return null;
|
||||||
|
const suffix = previewUrl.includes('#') ? '' : '#toolbar=0&navpanes=0&scrollbar=1&view=FitH';
|
||||||
|
return this.sanitizer.bypassSecurityTrustResourceUrl(`${previewUrl}${suffix}`);
|
||||||
|
});
|
||||||
readonly selectedFiles = signal<File[]>([]);
|
readonly selectedFiles = signal<File[]>([]);
|
||||||
readonly imageChangedEvent = signal<Event | null>(null);
|
readonly imageChangedEvent = signal<Event | null>(null);
|
||||||
readonly croppedFile = signal<File | null>(null);
|
readonly croppedFile = signal<File | null>(null);
|
||||||
readonly croppedPreview = signal<string | null>(null);
|
readonly croppedPreview = signal<string | null>(null);
|
||||||
readonly showCropper = signal(false);
|
readonly showCropper = signal(false);
|
||||||
|
readonly editingProofs = signal<Proof[]>([]);
|
||||||
|
readonly removedProofIds = signal<string[]>([]);
|
||||||
|
|
||||||
readonly expenseForm = this.fb.nonNullable.group({
|
readonly expenseForm = this.fb.nonNullable.group({
|
||||||
title: ['', [Validators.required, Validators.minLength(2)]],
|
title: ['', [Validators.required, Validators.minLength(2)]],
|
||||||
@@ -211,7 +186,6 @@ export class ExpensesComponent implements OnInit {
|
|||||||
customFields: this.fb.array([])
|
customFields: this.fb.array([])
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly filterForm = this.fb.nonNullable.group({ startDate: [''], endDate: [''], categoryId: [''], search: [''], status: [''], tags: [''], duplicatesOnly: [false] });
|
|
||||||
readonly merchantForm = this.fb.nonNullable.group({ name: ['', [Validators.required, Validators.minLength(2)]], kind: ['MERCHANT' as Merchant['kind'], Validators.required], notes: [''] });
|
readonly merchantForm = this.fb.nonNullable.group({ name: ['', [Validators.required, Validators.minLength(2)]], kind: ['MERCHANT' as Merchant['kind'], Validators.required], notes: [''] });
|
||||||
|
|
||||||
get customFields() { return this.expenseForm.controls.customFields as FormArray; }
|
get customFields() { return this.expenseForm.controls.customFields as FormArray; }
|
||||||
@@ -220,26 +194,24 @@ export class ExpensesComponent implements OnInit {
|
|||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.categoriesService.ensureLoaded(true);
|
this.categoriesService.ensureLoaded(true);
|
||||||
this.merchantsService.ensureLoaded(true);
|
this.merchantsService.ensureLoaded(true);
|
||||||
this.loadExpenses();
|
this.route.queryParamMap.subscribe((params) => {
|
||||||
this.loadDuplicates();
|
const editId = params.get('edit');
|
||||||
|
if (editId) this.loadExpenseForEdit(editId);
|
||||||
|
else this.cancelEdit(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addCustomField(key = '', value = '') { this.customFields.push(this.fb.group({ key: [key], value: [value] })); }
|
addCustomField(key = '', value = '') { this.customFields.push(this.fb.group({ key: [key], value: [value] })); }
|
||||||
removeCustomField(index: number) { this.customFields.removeAt(index); }
|
removeCustomField(index: number) { this.customFields.removeAt(index); }
|
||||||
customFieldEntries(item: Expense) { return Object.entries(item.customFields || {}); }
|
|
||||||
|
|
||||||
loadExpenses() {
|
private loadExpenseForEdit(id: string) {
|
||||||
const raw = this.filterForm.getRawValue();
|
this.expensesService.getById(id).subscribe({
|
||||||
this.expensesService.list({ startDate: raw.startDate || undefined, endDate: raw.endDate || undefined, categoryId: raw.categoryId || undefined, search: raw.search || undefined, status: raw.status || undefined, tags: raw.tags || undefined, duplicatesOnly: raw.duplicatesOnly || undefined }).subscribe({ next: (response) => this.expenses.set(response.items) });
|
next: (response) => this.startEdit(response.item),
|
||||||
}
|
error: (error) => {
|
||||||
|
this.toast.error(error.error?.message ?? this.ui.t('expenses.saveError'));
|
||||||
loadDuplicates() {
|
this.cancelEdit();
|
||||||
this.expensesService.duplicates().subscribe({ next: (response) => this.duplicateGroups.set(response.items) });
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
resetFilters() {
|
|
||||||
this.filterForm.reset({ startDate: '', endDate: '', categoryId: '', search: '', status: '', tags: '', duplicatesOnly: false });
|
|
||||||
this.loadExpenses();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
selectMerchant(id: string) {
|
selectMerchant(id: string) {
|
||||||
@@ -283,6 +255,11 @@ export class ExpensesComponent implements OnInit {
|
|||||||
this.croppedPreview.set(event.objectUrl ?? null);
|
this.croppedPreview.set(event.objectUrl ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markProofForRemoval(proof: Proof) {
|
||||||
|
this.removedProofIds.update((ids) => Array.from(new Set([...ids, proof.id])));
|
||||||
|
this.editingProofs.update((items) => items.filter((item) => item.id !== proof.id));
|
||||||
|
}
|
||||||
|
|
||||||
submitExpense(forcedStatus?: Expense['status']) {
|
submitExpense(forcedStatus?: Expense['status']) {
|
||||||
this.submitted.set(true);
|
this.submitted.set(true);
|
||||||
this.expenseForm.markAllAsTouched();
|
this.expenseForm.markAllAsTouched();
|
||||||
@@ -295,21 +272,6 @@ export class ExpensesComponent implements OnInit {
|
|||||||
const status = forcedStatus ?? (raw.status as Expense['status']);
|
const status = forcedStatus ?? (raw.status as Expense['status']);
|
||||||
this.saving.set(true);
|
this.saving.set(true);
|
||||||
|
|
||||||
if (this.editingExpenseId()) {
|
|
||||||
this.expensesService.update(this.editingExpenseId()!, { title: raw.title, amount: raw.amount, expenseDate: raw.expenseDate, categoryId: raw.categoryId, merchant: raw.merchant, paymentMethod: raw.paymentMethod as Expense['paymentMethod'], description: raw.description, currency: 'PLN', status, tags, customFields }).subscribe({
|
|
||||||
next: (response) => {
|
|
||||||
this.finishSave(response.warnings);
|
|
||||||
this.toast.success(this.ui.t('expenses.saved'));
|
|
||||||
this.cancelEdit();
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
this.saving.set(false);
|
|
||||||
this.toast.error(error.error?.message ?? this.ui.t('expenses.saveError'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.set('title', raw.title);
|
formData.set('title', raw.title);
|
||||||
formData.set('amount', String(raw.amount));
|
formData.set('amount', String(raw.amount));
|
||||||
@@ -325,6 +287,7 @@ export class ExpensesComponent implements OnInit {
|
|||||||
formData.set('proofType', raw.proofType);
|
formData.set('proofType', raw.proofType);
|
||||||
formData.set('proofLabel', raw.proofLabel);
|
formData.set('proofLabel', raw.proofLabel);
|
||||||
formData.set('proofNote', raw.proofNote);
|
formData.set('proofNote', raw.proofNote);
|
||||||
|
formData.set('removeProofIds', JSON.stringify(this.removedProofIds()));
|
||||||
|
|
||||||
const selected = this.selectedFiles();
|
const selected = this.selectedFiles();
|
||||||
if (this.croppedFile()) {
|
if (this.croppedFile()) {
|
||||||
@@ -334,39 +297,42 @@ export class ExpensesComponent implements OnInit {
|
|||||||
selected.forEach((file) => formData.append('proofFiles', file));
|
selected.forEach((file) => formData.append('proofFiles', file));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.expensesService.create(formData).subscribe({
|
const request = this.editingExpenseId()
|
||||||
|
? this.expensesService.update(this.editingExpenseId()!, formData)
|
||||||
|
: this.expensesService.create(formData);
|
||||||
|
|
||||||
|
request.subscribe({
|
||||||
next: (response) => {
|
next: (response) => {
|
||||||
this.finishSave(response.warnings);
|
this.saving.set(false);
|
||||||
this.toast.success(status === 'DRAFT' ? this.ui.t('expenses.draftSaved') : this.ui.t('expenses.added'));
|
this.submitted.set(false);
|
||||||
|
response.warnings?.forEach((warning) => this.toast.warning(warning));
|
||||||
|
const wasEditing = Boolean(this.editingExpenseId());
|
||||||
|
this.toast.success(wasEditing ? this.ui.t('expenses.saved') : status === 'DRAFT' ? this.ui.t('expenses.draftSaved') : this.ui.t('expenses.added'));
|
||||||
|
this.resetForm();
|
||||||
|
if (wasEditing) this.router.navigate(['/expenses/add']);
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.saving.set(false);
|
this.saving.set(false);
|
||||||
this.toast.error(error.error?.message ?? this.ui.t('expenses.addError'));
|
this.toast.error(error.error?.message ?? (this.editingExpenseId() ? this.ui.t('expenses.saveError') : this.ui.t('expenses.addError')));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private finishSave(warnings?: string[]) {
|
|
||||||
this.saving.set(false);
|
|
||||||
this.submitted.set(false);
|
|
||||||
warnings?.forEach((warning) => this.toast.warning(warning));
|
|
||||||
this.resetForm();
|
|
||||||
this.loadExpenses();
|
|
||||||
this.loadDuplicates();
|
|
||||||
}
|
|
||||||
|
|
||||||
startEdit(item: Expense) {
|
startEdit(item: Expense) {
|
||||||
this.editingExpenseId.set(item.id);
|
this.editingExpenseId.set(item.id);
|
||||||
|
this.editingProofs.set(item.proofs || []);
|
||||||
|
this.removedProofIds.set([]);
|
||||||
this.submitted.set(false);
|
this.submitted.set(false);
|
||||||
this.customFields.clear();
|
this.customFields.clear();
|
||||||
Object.entries(item.customFields || {}).forEach(([key, value]) => this.addCustomField(key, value));
|
Object.entries(item.customFields || {}).forEach(([key, value]) => this.addCustomField(key, value));
|
||||||
this.expenseForm.patchValue({ title: item.title, amount: item.amount, expenseDate: item.expenseDate, categoryId: item.category.id, merchant: item.merchant ?? '', paymentMethod: item.paymentMethod ?? '', description: item.description ?? '', status: item.status, tagsText: (item.tags || []).join(', '), proofType: 'RECEIPT', proofLabel: '', proofNote: '' });
|
this.expenseForm.patchValue({ title: item.title, amount: item.amount, expenseDate: item.expenseDate, categoryId: item.category.id, merchant: item.merchant ?? '', paymentMethod: item.paymentMethod ?? '', description: item.description ?? '', status: item.status, tagsText: (item.tags || []).join(', '), proofType: 'RECEIPT', proofLabel: '', proofNote: '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelEdit() {
|
cancelEdit(navigate = true) {
|
||||||
this.editingExpenseId.set(null);
|
this.editingExpenseId.set(null);
|
||||||
this.submitted.set(false);
|
this.submitted.set(false);
|
||||||
this.resetForm();
|
this.resetForm();
|
||||||
|
if (navigate) this.router.navigate(['/expenses/add']);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetForm() {
|
private resetForm() {
|
||||||
@@ -376,51 +342,14 @@ export class ExpensesComponent implements OnInit {
|
|||||||
this.selectedFiles.set([]);
|
this.selectedFiles.set([]);
|
||||||
this.croppedFile.set(null);
|
this.croppedFile.set(null);
|
||||||
this.croppedPreview.set(null);
|
this.croppedPreview.set(null);
|
||||||
|
this.imageChangedEvent.set(null);
|
||||||
this.showCropper.set(false);
|
this.showCropper.set(false);
|
||||||
}
|
this.editingProofs.set([]);
|
||||||
|
this.removedProofIds.set([]);
|
||||||
removeExpense(item: Expense) {
|
|
||||||
this.expensesService.delete(item.id).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.toast.success(this.ui.t('expenses.deleted'));
|
|
||||||
this.loadExpenses();
|
|
||||||
this.loadDuplicates();
|
|
||||||
},
|
|
||||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('expenses.deleteError'))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
reviewDuplicate(item: Expense, action: 'CONFIRM' | 'DISMISS' | 'REOPEN') {
|
|
||||||
this.expensesService.reviewDuplicate(item.id, action).subscribe({
|
|
||||||
next: () => {
|
|
||||||
if (action === 'CONFIRM') this.toast.success(this.ui.t('expenses.duplicateConfirmed'));
|
|
||||||
if (action === 'DISMISS') this.toast.success(this.ui.t('expenses.duplicateDismissed'));
|
|
||||||
if (action === 'REOPEN') this.toast.success(this.ui.t('expenses.duplicateReopened'));
|
|
||||||
this.loadExpenses();
|
|
||||||
this.loadDuplicates();
|
|
||||||
},
|
|
||||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('toast.error'))
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openProof(proof: Proof) { this.proofPreview.set(proof); }
|
openProof(proof: Proof) { this.proofPreview.set(proof); }
|
||||||
closeMerchantModal() { this.merchantModalOpen.set(false); }
|
closeMerchantModal() { this.merchantModalOpen.set(false); }
|
||||||
closeProofPreview() { this.proofPreview.set(null); }
|
closeProofPreview() { this.proofPreview.set(null); }
|
||||||
isPdf(proof: Proof) { return (proof.mimeType || '').includes('pdf'); }
|
isPdf(proof: Proof) { return (proof.mimeType || '').toLowerCase().includes('pdf'); }
|
||||||
|
|
||||||
statusBadgeClass(status: string) {
|
|
||||||
return ({ DRAFT: 'text-bg-secondary', PENDING: 'text-bg-warning', APPROVED: 'text-bg-success', REJECTED: 'text-bg-danger' } as Record<string, string>)[status] || 'text-bg-secondary';
|
|
||||||
}
|
|
||||||
|
|
||||||
duplicateBadgeClass(item: Expense) {
|
|
||||||
const state = item.duplicateStatus ?? (item.possibleDuplicate ? 'OPEN' : null);
|
|
||||||
return ({ OPEN: 'text-bg-warning', CONFIRMED: 'text-bg-danger', DISMISSED: 'text-bg-secondary' } as Record<string, string>)[state || 'OPEN'] || 'text-bg-warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
duplicateLabel(item: Expense) {
|
|
||||||
const state = item.duplicateStatus ?? (item.possibleDuplicate ? 'OPEN' : null);
|
|
||||||
if (state === 'CONFIRMED') return this.ui.t('expenses.duplicateStatus.confirmed');
|
|
||||||
if (state === 'DISMISSED') return this.ui.t('expenses.duplicateStatus.dismissed');
|
|
||||||
return this.ui.t('expenses.duplicateStatus.open');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,8 @@ const monthRange = (period: string) => {
|
|||||||
const [yearText, monthText] = safe.split('-');
|
const [yearText, monthText] = safe.split('-');
|
||||||
const year = Number(yearText);
|
const year = Number(yearText);
|
||||||
const month = Number(monthText);
|
const month = Number(monthText);
|
||||||
const nextMonth = month === 12 ? new Date(year + 1, 0, 1) : new Date(year, month, 1);
|
const lastDay = new Date(year, month, 0).getDate();
|
||||||
const end = new Date(nextMonth.getTime() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
return { start: `${safe}-01`, end: `${safe}-${String(lastDay).padStart(2, '0')}` };
|
||||||
return { start: `${safe}-01`, end };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -97,7 +96,7 @@ const monthRange = (period: string) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label">{{ ui.t('integrations.limit') }}</label>
|
<label class="form-label">{{ ui.t('integrations.limit') }}</label>
|
||||||
<input class="form-control" type="number" min="1" max="200" formControlName="limit" />
|
<input class="form-control" type="number" min="1" max="300" formControlName="limit" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
<div class="btn-list justify-content-md-end">
|
<div class="btn-list justify-content-md-end">
|
||||||
@@ -107,14 +106,23 @@ const monthRange = (period: string) => {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="row row-cards">
|
<div class="row row-cards">
|
||||||
<div class="col-md-6"><div class="ec-stat-tile ec-stat-tile-primary"><div class="ec-stat-label">{{ ui.t('integrations.externalSpend') }}</div><div class="ec-stat-value">{{ summaryAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div>
|
<div class="col-md-4"><div class="ec-stat-tile ec-stat-tile-primary"><div class="ec-stat-label">{{ ui.t('integrations.externalLists') }}</div><div class="ec-stat-value">{{ summaryListCount() }}</div></div></div>
|
||||||
<div class="col-md-6"><div class="ec-stat-tile ec-stat-tile-success"><div class="ec-stat-label">{{ ui.t('integrations.externalCount') }}</div><div class="ec-stat-value">{{ summaryCount() }}</div></div></div>
|
<div class="col-md-4"><div class="ec-stat-tile ec-stat-tile-success"><div class="ec-stat-label">{{ ui.t('integrations.externalSpend') }}</div><div class="ec-stat-value">{{ summaryAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div>
|
||||||
|
<div class="col-md-4"><div class="ec-stat-tile"><div class="ec-stat-label">{{ ui.t('integrations.externalCount') }}</div><div class="ec-stat-value">{{ summaryCount() }}</div></div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border rounded-3 p-3 bg-body-tertiary">
|
<div class="border rounded-3 p-3 bg-body-tertiary">
|
||||||
@if (configured()) {
|
@if (configured()) {
|
||||||
<div class="small text-secondary mb-2">{{ ui.t('integrations.summary') }}</div>
|
<div class="d-flex justify-content-between gap-2 flex-wrap align-items-center">
|
||||||
<pre class="mb-0 small">{{ summaryText() }}</pre>
|
<div>
|
||||||
|
<div class="fw-semibold">{{ ui.t('integrations.summary') }}</div>
|
||||||
|
<div class="text-secondary small">{{ historyForm.controls.period.value }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end small text-secondary">
|
||||||
|
{{ ui.t('integrations.summaryLists') }}: <strong>{{ summaryListCount() }}</strong> ·
|
||||||
|
{{ ui.t('integrations.summarySpend') }}: <strong>{{ summaryAmount() | number:'1.2-2' }} PLN</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="text-secondary">{{ ui.t('integrations.notConfigured') }}</div>
|
<div class="text-secondary">{{ ui.t('integrations.notConfigured') }}</div>
|
||||||
}
|
}
|
||||||
@@ -126,18 +134,16 @@ const monthRange = (period: string) => {
|
|||||||
|
|
||||||
<div class="row row-cards mb-3">
|
<div class="row row-cards mb-3">
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<div class="card overflow-hidden h-100">
|
<div class="card overflow-hidden">
|
||||||
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.lists') }}</h3></div>
|
<div class="card-header d-flex justify-content-between align-items-center gap-2">
|
||||||
<div class="list-group list-group-flush">
|
<h3 class="card-title mb-0">{{ ui.t('integrations.lists') }}</h3>
|
||||||
|
<span class="badge text-bg-secondary">{{ visibleLists().length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="list-group list-group-flush ec-scroll-list">
|
||||||
@for (item of visibleLists(); track item.id) {
|
@for (item of visibleLists(); track item.id) {
|
||||||
<button class="list-group-item list-group-item-action text-start" type="button" [class.active]="isSelectedList(item)" (click)="selectList(item)">
|
<button class="list-group-item list-group-item-action text-start" type="button" [class.active]="isSelectedList(item)" (click)="selectList(item)">
|
||||||
<div class="d-flex justify-content-between gap-2 align-items-start">
|
<div class="fw-semibold">{{ listTitle(item) }}</div>
|
||||||
<div>
|
<div class="small text-secondary">{{ listCreatedAt(item) | date:'yyyy-MM-dd' }} · {{ listOwner(item) || ui.t('common.none') }}</div>
|
||||||
<div class="fw-semibold">{{ listTitle(item) }}</div>
|
|
||||||
<div class="text-secondary small">#{{ item.id }} · {{ listOwner(item) || ui.t('common.none') }}</div>
|
|
||||||
</div>
|
|
||||||
<span class="badge text-bg-secondary">{{ listCreatedAt(item) | date:'yyyy-MM-dd' }}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
} @empty {
|
} @empty {
|
||||||
<div class="list-group-item text-secondary">{{ ui.t('common.noData') }}</div>
|
<div class="list-group-item text-secondary">{{ ui.t('common.noData') }}</div>
|
||||||
@@ -147,12 +153,12 @@ const monthRange = (period: string) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
<div class="card overflow-hidden ec-accent-card ec-accent-card-success h-100">
|
<div class="card overflow-hidden ec-accent-card ec-accent-card-success">
|
||||||
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.importTitle') }}</h3></div>
|
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.importTitle') }}</h3></div>
|
||||||
<div class="card-body d-grid gap-3">
|
<div class="card-body d-grid gap-3">
|
||||||
<div class="alert alert-warning mb-0">
|
<div class="alert alert-warning mb-0">
|
||||||
<div class="fw-semibold mb-1">{{ ui.t('integrations.importExplainTitle') }}</div>
|
<div class="fw-semibold mb-1">{{ ui.t('integrations.importExplainTitle') }}</div>
|
||||||
<div>{{ ui.t('integrations.importExplainBody') }}</div>
|
<div>{{ ui.t('integrations.importExplainBodySimple') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="importForm" class="row g-3">
|
<form [formGroup]="importForm" class="row g-3">
|
||||||
@@ -176,91 +182,60 @@ const monthRange = (period: string) => {
|
|||||||
<label class="form-label">{{ ui.t('table.merchant') }}</label>
|
<label class="form-label">{{ ui.t('table.merchant') }}</label>
|
||||||
<input class="form-control" formControlName="merchant" />
|
<input class="form-control" formControlName="merchant" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label">{{ ui.t('integrations.tags') }}</label>
|
|
||||||
<input class="form-control" formControlName="tags" />
|
|
||||||
<div class="form-hint">{{ ui.t('integrations.tagsHint') }}</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="border rounded-3 p-3 h-100 bg-body-tertiary">
|
||||||
|
<div class="fw-semibold mb-1">{{ ui.t('integrations.importMonthTitle') }}</div>
|
||||||
|
<div class="text-secondary small mb-3">{{ ui.t('integrations.importMonthHint') }}</div>
|
||||||
|
<button class="btn btn-primary" type="button" [disabled]="importForm.invalid || !configured()" (click)="importPeriod()">{{ ui.t('integrations.importPeriod') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="border rounded-3 p-3 h-100 bg-body-tertiary">
|
||||||
|
<div class="fw-semibold mb-1">{{ ui.t('integrations.importListTitle') }}</div>
|
||||||
|
<div class="text-secondary small mb-3">{{ selectedList() ? listTitle(selectedList()) : ui.t('integrations.selectListHintSimple') }}</div>
|
||||||
|
<button class="btn btn-success" type="button" [disabled]="importForm.invalid || !selectedList()" (click)="importSelectedList()">{{ ui.t('integrations.importSelectedList') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (selectedList()) {
|
@if (selectedList()) {
|
||||||
<div class="border rounded-3 p-3 bg-body-tertiary">
|
<div class="border rounded-3 p-3 bg-body-tertiary">
|
||||||
<div class="d-flex justify-content-between gap-3 flex-wrap align-items-start">
|
<div class="d-flex justify-content-between gap-2 flex-wrap align-items-start mb-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="fw-semibold">{{ listTitle(selectedList()!) }}</div>
|
<div class="fw-semibold">{{ listTitle(selectedList()) }}</div>
|
||||||
<div class="text-secondary small">#{{ selectedList()!.id }} · {{ listCreatedAt(selectedList()!) | date:'yyyy-MM-dd' }}</div>
|
<div class="small text-secondary">{{ listCreatedAt(selectedList()) | date:'yyyy-MM-dd' }} · {{ listOwner(selectedList()) || ui.t('common.none') }}</div>
|
||||||
<div class="text-secondary small">{{ ui.t('integrations.selectedListSummary') }}: {{ selectedListCount() }} / {{ selectedListTotal() | currency:'PLN':'symbol':'1.2-2' }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" type="button" [disabled]="importForm.invalid || selectedListCount() === 0" (click)="importSelectedList()">
|
<div class="text-end">
|
||||||
{{ ui.t('integrations.importSelectedList') }}
|
<div class="small text-secondary">{{ ui.t('integrations.selectedListSummary') }}</div>
|
||||||
</button>
|
<div class="fw-semibold">{{ selectedListCount() }} / {{ selectedListTotal() | currency:'PLN':'symbol':'1.2-2' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-vcenter mb-0">
|
||||||
|
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@for (item of selectedListExpenses(); track $index) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ itemTitle(item) }}</td>
|
||||||
|
<td>{{ itemDate(item) }}</td>
|
||||||
|
<td class="text-end">{{ itemAmount(item) | currency:'PLN':'symbol':'1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
|
||||||
<div class="alert alert-info mb-0">{{ ui.t('integrations.selectListHint') }}</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row row-cards">
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card overflow-hidden h-100">
|
|
||||||
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.latest') }}</h3></div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-vcenter card-table mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th class="text-end">{{ ui.t('table.actions') }}</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (item of latestExpenses(); track $index) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="fw-semibold">{{ itemTitle(item) }}</div>
|
|
||||||
<div class="text-secondary small">{{ listTitle(item.list) }} · {{ ownerName(item) || ui.t('common.none') }}</div>
|
|
||||||
</td>
|
|
||||||
<td>{{ itemDate(item) }}</td>
|
|
||||||
<td class="text-end">{{ itemAmount(item) | number:'1.2-2' }}</td>
|
|
||||||
<td class="text-end"><button class="btn btn-sm btn-outline-primary" type="button" [disabled]="importForm.invalid" (click)="importItem(item)">{{ ui.t('action.import') }}</button></td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr><td colspan="4" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card overflow-hidden h-100">
|
|
||||||
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.listExpenses') }}</h3></div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-vcenter card-table mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th class="text-end">{{ ui.t('table.actions') }}</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (item of selectedListExpenses(); track $index) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="fw-semibold">{{ itemTitle(item) }}</div>
|
|
||||||
<div class="text-secondary small">{{ ownerName(item) || ui.t('common.none') }}</div>
|
|
||||||
</td>
|
|
||||||
<td>{{ itemDate(item) }}</td>
|
|
||||||
<td class="text-end">{{ itemAmount(item) | number:'1.2-2' }}</td>
|
|
||||||
<td class="text-end"><button class="btn btn-sm btn-outline-primary" type="button" [disabled]="importForm.invalid" (click)="importItem(item)">{{ ui.t('action.import') }}</button></td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr><td colspan="4" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
export class IntegrationsComponent implements OnInit {
|
export class IntegrationsComponent implements OnInit {
|
||||||
@@ -274,7 +249,6 @@ export class IntegrationsComponent implements OnInit {
|
|||||||
readonly configured = signal(false);
|
readonly configured = signal(false);
|
||||||
readonly summary = signal<ShoppingListSummary | null>(null);
|
readonly summary = signal<ShoppingListSummary | null>(null);
|
||||||
readonly allLists = signal<ShoppingListRef[]>([]);
|
readonly allLists = signal<ShoppingListRef[]>([]);
|
||||||
readonly latestExpenses = signal<ShoppingListExpenseItem[]>([]);
|
|
||||||
readonly selectedList = signal<ShoppingListRef | null>(null);
|
readonly selectedList = signal<ShoppingListRef | null>(null);
|
||||||
readonly selectedListExpenses = signal<ShoppingListExpenseItem[]>([]);
|
readonly selectedListExpenses = signal<ShoppingListExpenseItem[]>([]);
|
||||||
|
|
||||||
@@ -289,19 +263,22 @@ export class IntegrationsComponent implements OnInit {
|
|||||||
|
|
||||||
readonly historyForm = this.fb.nonNullable.group({
|
readonly historyForm = this.fb.nonNullable.group({
|
||||||
period: [currentMonth(), Validators.required],
|
period: [currentMonth(), Validators.required],
|
||||||
limit: [50, [Validators.required, Validators.min(1), Validators.max(200)]]
|
limit: [80, [Validators.required, Validators.min(1), Validators.max(300)]]
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly importForm = this.fb.nonNullable.group({
|
readonly importForm = this.fb.nonNullable.group({
|
||||||
categoryId: ['', Validators.required],
|
categoryId: ['', Validators.required],
|
||||||
status: ['PENDING' as 'DRAFT' | 'PENDING', Validators.required],
|
status: ['PENDING' as 'DRAFT' | 'PENDING', Validators.required],
|
||||||
merchant: ['Shopping list API'],
|
merchant: ['Zakupy']
|
||||||
tags: ['shopping-list, external-import']
|
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly summaryAmount = computed(() => Number(this.summary()?.total ?? this.summary()?.amount ?? this.summary()?.meta?.total_amount ?? 0));
|
readonly summaryAmount = computed(() => Number(this.summary()?.total ?? this.summary()?.amount ?? this.summary()?.meta?.total_amount ?? 0));
|
||||||
readonly summaryCount = computed(() => Number(this.summary()?.count ?? this.summary()?.records ?? this.summary()?.meta?.total_count ?? 0));
|
readonly summaryCount = computed(() => Number(this.summary()?.count ?? this.summary()?.records ?? this.summary()?.meta?.total_count ?? 0));
|
||||||
readonly summaryText = computed(() => JSON.stringify(this.summary(), null, 2));
|
readonly summaryListCount = computed(() => {
|
||||||
|
const summary = this.summary();
|
||||||
|
const groups = [summary?.lists, summary?.totals, summary?.aggregates].find((value) => Array.isArray(value));
|
||||||
|
return Array.isArray(groups) ? groups.length : this.visibleLists().length;
|
||||||
|
});
|
||||||
readonly selectedListTotal = computed(() => this.selectedListExpenses().reduce((sum: number, item: ShoppingListExpenseItem) => sum + this.itemAmount(item), 0));
|
readonly selectedListTotal = computed(() => this.selectedListExpenses().reduce((sum: number, item: ShoppingListExpenseItem) => sum + this.itemAmount(item), 0));
|
||||||
readonly selectedListCount = computed(() => this.selectedListExpenses().length);
|
readonly selectedListCount = computed(() => this.selectedListExpenses().length);
|
||||||
|
|
||||||
@@ -315,7 +292,7 @@ export class IntegrationsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.integration.getSettings().subscribe({
|
this.integration.getSettings().subscribe({
|
||||||
next: (response: { item: { enabled: boolean; baseUrl: string; hasToken: boolean; authMode: 'bearer' | 'x-api-token' | 'both'; ownerId: string | null; defaultListId: string | null } }) => {
|
next: (response) => {
|
||||||
const item = response.item;
|
const item = response.item;
|
||||||
this.form.reset({ enabled: item.enabled, baseUrl: item.baseUrl || '', apiToken: '', authMode: item.authMode, ownerId: item.ownerId || '', defaultListId: item.defaultListId || '' });
|
this.form.reset({ enabled: item.enabled, baseUrl: item.baseUrl || '', apiToken: '', authMode: item.authMode, ownerId: item.ownerId || '', defaultListId: item.defaultListId || '' });
|
||||||
this.configured.set(Boolean(item.enabled && item.baseUrl && item.hasToken));
|
this.configured.set(Boolean(item.enabled && item.baseUrl && item.hasToken));
|
||||||
@@ -329,7 +306,7 @@ export class IntegrationsComponent implements OnInit {
|
|||||||
if (this.form.invalid) return;
|
if (this.form.invalid) return;
|
||||||
const raw = this.form.getRawValue();
|
const raw = this.form.getRawValue();
|
||||||
this.integration.updateSettings({ enabled: raw.enabled, baseUrl: raw.baseUrl || null, apiToken: raw.apiToken || undefined, authMode: raw.authMode, ownerId: raw.ownerId || null, defaultListId: raw.defaultListId || null }).subscribe({
|
this.integration.updateSettings({ enabled: raw.enabled, baseUrl: raw.baseUrl || null, apiToken: raw.apiToken || undefined, authMode: raw.authMode, ownerId: raw.ownerId || null, defaultListId: raw.defaultListId || null }).subscribe({
|
||||||
next: (response: { item: { enabled: boolean; baseUrl: string; hasToken: boolean } }) => {
|
next: (response) => {
|
||||||
this.configured.set(Boolean(response.item.enabled && response.item.baseUrl && response.item.hasToken));
|
this.configured.set(Boolean(response.item.enabled && response.item.baseUrl && response.item.hasToken));
|
||||||
this.toast.success(this.ui.t('integrations.saveSuccess'));
|
this.toast.success(this.ui.t('integrations.saveSuccess'));
|
||||||
if (this.configured()) this.refresh();
|
if (this.configured()) this.refresh();
|
||||||
@@ -352,8 +329,7 @@ export class IntegrationsComponent implements OnInit {
|
|||||||
const range = monthRange(history.period);
|
const range = monthRange(history.period);
|
||||||
const filters = { start_date: range.start, end_date: range.end, owner_id: raw.ownerId || undefined, list_id: raw.defaultListId || undefined };
|
const filters = { start_date: range.start, end_date: range.end, owner_id: raw.ownerId || undefined, list_id: raw.defaultListId || undefined };
|
||||||
|
|
||||||
this.integration.summary(filters).subscribe({ next: (response: ShoppingListSummary) => this.summary.set(response), error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.loadError')) });
|
this.integration.summary(filters).subscribe({ next: (response) => this.summary.set(response), error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.loadError')) });
|
||||||
this.integration.latest({ ...filters, limit: history.limit }).subscribe({ next: (response) => this.latestExpenses.set(this.pickItems<ShoppingListExpenseItem>(response)), error: () => this.latestExpenses.set([]) });
|
|
||||||
this.integration.lists({ owner_id: raw.ownerId || undefined, limit: 200 }).subscribe({
|
this.integration.lists({ owner_id: raw.ownerId || undefined, limit: 200 }).subscribe({
|
||||||
next: (response) => {
|
next: (response) => {
|
||||||
const items = this.pickItems<ShoppingListRef>(response);
|
const items = this.pickItems<ShoppingListRef>(response);
|
||||||
@@ -365,7 +341,9 @@ export class IntegrationsComponent implements OnInit {
|
|||||||
if (nextSelected) this.loadListExpenses(nextSelected); else this.selectedListExpenses.set([]);
|
if (nextSelected) this.loadListExpenses(nextSelected); else this.selectedListExpenses.set([]);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.allLists.set([]); this.selectedList.set(null); this.selectedListExpenses.set([]);
|
this.allLists.set([]);
|
||||||
|
this.selectedList.set(null);
|
||||||
|
this.selectedListExpenses.set([]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -381,21 +359,27 @@ export class IntegrationsComponent implements OnInit {
|
|||||||
|
|
||||||
selectList(item: ShoppingListRef) { this.selectedList.set(item); this.loadListExpenses(item); }
|
selectList(item: ShoppingListRef) { this.selectedList.set(item); this.loadListExpenses(item); }
|
||||||
|
|
||||||
importSelectedList() {
|
importPeriod() {
|
||||||
const list = this.selectedList();
|
if (this.importForm.invalid) return;
|
||||||
if (!list || this.importForm.invalid) return;
|
|
||||||
const raw = this.importForm.getRawValue();
|
const raw = this.importForm.getRawValue();
|
||||||
this.integration.importList({ listId: list.id, listTitle: this.listTitle(list), listCreatedAt: this.listCreatedAt(list), categoryId: raw.categoryId, status: raw.status, merchant: raw.merchant || this.listTitle(list), tags: this.normalizedTags() }).subscribe({
|
this.integration.importPeriod({ period: this.historyForm.controls.period.value, categoryId: raw.categoryId, status: raw.status, merchant: raw.merchant || 'Zakupy' }).subscribe({
|
||||||
next: (response) => { this.toast.success(this.ui.t('integrations.importListSuccess')); this.emitWarnings(response.warnings); },
|
next: (response) => {
|
||||||
|
this.toast.success(this.ui.t('integrations.importPeriodSuccess'));
|
||||||
|
this.emitWarnings(response.warnings);
|
||||||
|
},
|
||||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError'))
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError'))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
importItem(item: ShoppingListExpenseItem) {
|
importSelectedList() {
|
||||||
if (this.importForm.invalid) return;
|
const list = this.selectedList();
|
||||||
|
if (!list || this.importForm.invalid) return;
|
||||||
const raw = this.importForm.getRawValue();
|
const raw = this.importForm.getRawValue();
|
||||||
this.integration.importItem({ expenseId: item.expense_id ?? item.id ?? null, listId: item.list?.id ?? this.selectedList()?.id ?? null, listTitle: this.listTitle(item.list ?? this.selectedList()), categoryId: raw.categoryId, status: raw.status, title: this.itemTitle(item), amount: this.itemAmount(item), expenseDate: this.itemDate(item), merchant: raw.merchant || this.listTitle(item.list ?? this.selectedList()), ownerName: this.ownerName(item), tags: this.normalizedTags() }).subscribe({
|
this.integration.importList({ listId: list.id, listTitle: this.listTitle(list), listCreatedAt: this.listCreatedAt(list), categoryId: raw.categoryId, status: raw.status, merchant: raw.merchant || this.listTitle(list), tags: ['shopping-list'] }).subscribe({
|
||||||
next: (response) => { this.toast.success(this.ui.t('integrations.importItemSuccess')); this.emitWarnings(response.warnings); },
|
next: (response) => {
|
||||||
|
this.toast.success(this.ui.t('integrations.importListSuccess'));
|
||||||
|
this.emitWarnings(response.warnings);
|
||||||
|
},
|
||||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError'))
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError'))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -407,17 +391,12 @@ export class IntegrationsComponent implements OnInit {
|
|||||||
itemTitle(item: ShoppingListExpenseItem) { return item.title || item.name || item.list?.title || item.list?.name || `Expense #${item.expense_id ?? item.id ?? '-'}`; }
|
itemTitle(item: ShoppingListExpenseItem) { return item.title || item.name || item.list?.title || item.list?.name || `Expense #${item.expense_id ?? item.id ?? '-'}`; }
|
||||||
itemDate(item: ShoppingListExpenseItem) { return (item.expense_date || item.added_at || item.created_at || today()).slice(0, 10); }
|
itemDate(item: ShoppingListExpenseItem) { return (item.expense_date || item.added_at || item.created_at || today()).slice(0, 10); }
|
||||||
itemAmount(item: ShoppingListExpenseItem) { return Number(item.amount ?? item.total ?? 0); }
|
itemAmount(item: ShoppingListExpenseItem) { return Number(item.amount ?? item.total ?? 0); }
|
||||||
ownerName(item: ShoppingListExpenseItem) { return item.owner?.fullName || item.owner?.name || item.owner?.username || item.owner?.email || null; }
|
|
||||||
|
|
||||||
private loadListExpenses(item: ShoppingListRef) {
|
private loadListExpenses(item: ShoppingListRef) {
|
||||||
const limit = this.historyForm.controls.limit.value;
|
const limit = this.historyForm.controls.limit.value;
|
||||||
this.integration.listExpenses(item.id, limit).subscribe({ next: (response) => this.selectedListExpenses.set(this.pickItems<ShoppingListExpenseItem>(response)), error: () => this.selectedListExpenses.set([]) });
|
this.integration.listExpenses(item.id, limit).subscribe({ next: (response) => this.selectedListExpenses.set(this.pickItems<ShoppingListExpenseItem>(response)), error: () => this.selectedListExpenses.set([]) });
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizedTags() {
|
|
||||||
return Array.from(new Set(this.importForm.controls.tags.value.split(',').map((item) => item.trim()).filter(Boolean)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitWarnings(warnings?: string[]) { (warnings ?? []).forEach((message) => this.toast.warning(message)); }
|
private emitWarnings(warnings?: string[]) { (warnings ?? []).forEach((message) => this.toast.warning(message)); }
|
||||||
private pickItems<T extends { id?: string | number }>(response: { items?: T[]; data?: T[] }) { return response.items ?? response.data ?? []; }
|
private pickItems<T extends { id?: string | number }>(response: { items?: T[]; data?: T[] }) { return response.items ?? response.data ?? []; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CommonModule, CurrencyPipe } from '@angular/common';
|
import { CommonModule, CurrencyPipe } from '@angular/common';
|
||||||
import { AfterViewChecked, Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
|
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
|
||||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { Chart, DoughnutController, ArcElement, Tooltip, Legend, LineController, LineElement, PointElement, CategoryScale, LinearScale } from 'chart.js';
|
import { Chart, DoughnutController, ArcElement, Tooltip, Legend, LineController, LineElement, PointElement, CategoryScale, LinearScale } from 'chart.js';
|
||||||
import { CategoriesService } from '../../core/services/categories.service';
|
import { CategoriesService } from '../../core/services/categories.service';
|
||||||
@@ -41,7 +41,7 @@ const chartPalette = ['#206bc4', '#2fb344', '#f59f00', '#d63939', '#9b4dca', '#4
|
|||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
export class StatsComponent implements OnInit, AfterViewChecked, OnDestroy {
|
export class StatsComponent implements OnInit, OnDestroy {
|
||||||
readonly ui = inject(UiService);
|
readonly ui = inject(UiService);
|
||||||
private readonly fb = inject(FormBuilder);
|
private readonly fb = inject(FormBuilder);
|
||||||
private readonly categoriesService = inject(CategoriesService);
|
private readonly categoriesService = inject(CategoriesService);
|
||||||
@@ -51,12 +51,9 @@ export class StatsComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|||||||
readonly stats = signal<StatsResponse | null>(null);
|
readonly stats = signal<StatsResponse | null>(null);
|
||||||
private categoryChart?: Chart;
|
private categoryChart?: Chart;
|
||||||
private lineChart?: Chart;
|
private lineChart?: Chart;
|
||||||
private chartsPending = false;
|
|
||||||
|
|
||||||
readonly form = this.fb.nonNullable.group({ bucket: ['month' as 'month' | 'quarter' | 'year'], startDate: [''], endDate: [''], categoryIds: [[] as string[]], status: [''], tag: [''] });
|
readonly form = this.fb.nonNullable.group({ bucket: ['month' as 'month' | 'quarter' | 'year'], startDate: [''], endDate: [''], categoryIds: [[] as string[]], status: [''], tag: [''] });
|
||||||
|
|
||||||
ngOnInit() { this.categoriesService.ensureLoaded(true); this.load(); }
|
ngOnInit() { this.categoriesService.ensureLoaded(true); this.load(); }
|
||||||
ngAfterViewChecked() { if (this.chartsPending) { this.chartsPending = false; this.renderCharts(); } }
|
|
||||||
ngOnDestroy() { this.categoryChart?.destroy(); this.lineChart?.destroy(); }
|
ngOnDestroy() { this.categoryChart?.destroy(); this.lineChart?.destroy(); }
|
||||||
setCategoryIds(categoryIds: string[]) { this.form.patchValue({ categoryIds }); }
|
setCategoryIds(categoryIds: string[]) { this.form.patchValue({ categoryIds }); }
|
||||||
hasCategoryData() { return Boolean(this.stats()?.byCategory?.length); }
|
hasCategoryData() { return Boolean(this.stats()?.byCategory?.length); }
|
||||||
@@ -64,11 +61,20 @@ export class StatsComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|||||||
|
|
||||||
load() {
|
load() {
|
||||||
const raw = this.form.getRawValue();
|
const raw = this.form.getRawValue();
|
||||||
this.statsService.overview({ startDate: raw.startDate || undefined, endDate: raw.endDate || undefined, categoryIds: raw.categoryIds.join(',') || undefined, bucket: raw.bucket, status: raw.status || undefined, tag: raw.tag || undefined }).subscribe({ next: (response) => { this.stats.set(response); this.chartsPending = true; } });
|
this.statsService.overview({ startDate: raw.startDate || undefined, endDate: raw.endDate || undefined, categoryIds: raw.categoryIds.join(',') || undefined, bucket: raw.bucket, status: raw.status || undefined, tag: raw.tag || undefined }).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.stats.set(response);
|
||||||
|
this.scheduleChartRender();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() { this.form.reset({ bucket: 'month', startDate: '', endDate: '', categoryIds: [], status: '', tag: '' }); this.load(); }
|
reset() { this.form.reset({ bucket: 'month', startDate: '', endDate: '', categoryIds: [], status: '', tag: '' }); this.load(); }
|
||||||
|
|
||||||
|
private scheduleChartRender() {
|
||||||
|
requestAnimationFrame(() => this.renderCharts());
|
||||||
|
}
|
||||||
|
|
||||||
private renderCharts() {
|
private renderCharts() {
|
||||||
const current = this.stats();
|
const current = this.stats();
|
||||||
const categoryCanvas = document.getElementById('statsCategoryChart') as HTMLCanvasElement | null;
|
const categoryCanvas = document.getElementById('statsCategoryChart') as HTMLCanvasElement | null;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, computed, inject, signal } from '@angular/core';
|
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||||
|
import { NavigationEnd } from '@angular/router';
|
||||||
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
|
import { filter } from 'rxjs/operators';
|
||||||
import { ApiStatusService } from '../core/services/api-status.service';
|
import { ApiStatusService } from '../core/services/api-status.service';
|
||||||
import { AppSettingsService } from '../core/services/app-settings.service';
|
import { AppSettingsService } from '../core/services/app-settings.service';
|
||||||
import { AuthService } from '../core/services/auth.service';
|
import { AuthService } from '../core/services/auth.service';
|
||||||
@@ -13,7 +15,7 @@ import { UiService } from '../core/services/ui.service';
|
|||||||
template: `
|
template: `
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<header class="navbar navbar-expand-md d-print-none pv-navbar">
|
<header class="navbar navbar-expand-md d-print-none pv-navbar">
|
||||||
<div class="container-xl gap-3 align-items-center">
|
<div class="container-xl d-flex align-items-center justify-content-between gap-3 flex-wrap">
|
||||||
<div class="d-flex align-items-center gap-3 flex-grow-1 min-w-0">
|
<div class="d-flex align-items-center gap-3 flex-grow-1 min-w-0">
|
||||||
<button class="btn btn-icon btn-outline-secondary d-md-none" type="button" (click)="toggleMenu()" [attr.aria-label]="ui.t('nav.toggleMenu')">
|
<button class="btn btn-icon btn-outline-secondary d-md-none" type="button" (click)="toggleMenu()" [attr.aria-label]="ui.t('nav.toggleMenu')">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/></svg>
|
||||||
@@ -22,16 +24,33 @@ import { UiService } from '../core/services/ui.service';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ms-auto d-flex align-items-center gap-2 flex-wrap justify-content-end">
|
<div class="ms-auto d-flex align-items-center gap-2 flex-wrap justify-content-end">
|
||||||
<nav class="nav nav-segmented ec-segmented-control" role="tablist" [attr.aria-label]="ui.t('lang.label')">
|
<div class="ec-toolbar-toggle d-inline-flex align-items-center">
|
||||||
<button class="nav-link" type="button" role="tab" [class.active]="ui.language() === 'pl'" (click)="ui.setLanguage('pl')">PL</button>
|
<button class="btn btn-icon btn-ghost-secondary"
|
||||||
<button class="nav-link" type="button" role="tab" [class.active]="ui.language() === 'en'" (click)="ui.setLanguage('en')">EN</button>
|
type="button"
|
||||||
</nav>
|
[attr.aria-label]="ui.t('theme.label')"
|
||||||
<nav class="nav nav-segmented ec-segmented-control" role="tablist" [attr.aria-label]="ui.t('theme.label')">
|
[attr.title]="ui.t('theme.label')"
|
||||||
<button class="nav-link" type="button" role="tab" [class.active]="ui.theme() === 'dark'" (click)="ui.setTheme('dark')">{{ ui.t('theme.dark') }}</button>
|
(click)="ui.toggleTheme()">
|
||||||
<button class="nav-link" type="button" role="tab" [class.active]="ui.theme() === 'light'" (click)="ui.setTheme('light')">{{ ui.t('theme.light') }}</button>
|
@if (ui.theme() === 'dark') {
|
||||||
</nav>
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 3l0 1"/><path d="M12 20l0 1"/><path d="M3 12l1 0"/><path d="M20 12l1 0"/><path d="M5.6 5.6l.7 .7"/><path d="M18.4 18.4l.7 .7"/><path d="M18.4 5.6l-.7 .7"/><path d="M5.6 18.4l-.7 .7"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>
|
||||||
|
} @else {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 3c.132 0 .263 0 .393 .007a9 9 0 1 0 0 17.986a9 9 0 0 1 -.393 -17.993z"/></svg>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-icon btn-ghost-secondary"
|
||||||
|
type="button"
|
||||||
|
[attr.aria-label]="currentLanguageLabel()"
|
||||||
|
[attr.title]="currentLanguageLabel()"
|
||||||
|
(click)="toggleLanguage()">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 5h7"/><path d="M7 4c0 4.846 0 7 .5 8"/><path d="M10 8l-3 4l-3 -4"/><path d="M19 22l0 -3"/><path d="M17 19h4"/><path d="M20 19l-3 -7l-3 7"/><path d="M11 19l4 0"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="pv-navbar-user text-end me-1"><div class="fw-semibold text-truncate">{{ auth.currentUser()?.fullName }}</div><div class="small text-secondary text-truncate">{{ auth.currentUser()?.email }}</div></div>
|
<div class="pv-navbar-user text-end me-1"><div class="fw-semibold text-truncate">{{ auth.currentUser()?.fullName }}</div><div class="small text-secondary text-truncate">{{ auth.currentUser()?.email }}</div></div>
|
||||||
<button class="btn btn-danger btn-sm px-3 flex-shrink-0" type="button" (click)="logout()">{{ ui.t('action.logout') }}</button>
|
<button class="btn btn-primary btn-sm px-3 flex-shrink-0 pv-logout-btn" type="button" (click)="logout()" [attr.aria-label]="ui.t('action.logout')" [attr.title]="ui.t('action.logout')">
|
||||||
|
<span class="pv-logout-btn__content">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M13 12v.01"/><path d="M3 21h18"/><path d="M5 21v-14a2 2 0 0 1 2 -2h5m4 0h1a2 2 0 0 1 2 2v14"/><path d="M14 7l3 -3l3 3"/></svg>
|
||||||
|
<span>{{ ui.t('action.logout') }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -60,11 +79,9 @@ import { UiService } from '../core/services/ui.service';
|
|||||||
<div class="ec-footer-shell">
|
<div class="ec-footer-shell">
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<span class="badge" [class.bg-success]="apiStatus.state() === 'online'" [class.bg-danger]="apiStatus.state() === 'offline'">{{ apiStatus.state() === 'online' ? ui.t('footer.apiOnline') : ui.t('footer.apiOffline') }}</span>
|
<span class="badge" [class.bg-success]="apiStatus.state() === 'online'" [class.bg-danger]="apiStatus.state() === 'offline'">{{ apiStatus.state() === 'online' ? ui.t('footer.apiOnline') : ui.t('footer.apiOffline') }}</span>
|
||||||
<span class="text-secondary small">{{ ui.t('footer.selfHosted') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-3 flex-wrap justify-content-end text-end">
|
<div class="d-flex align-items-center gap-3 flex-wrap justify-content-end text-end">
|
||||||
<a class="link-secondary" href="https://git.linuxiarz.pl/gru/expense-control" target="_blank" rel="noreferrer">{{ ui.t('footer.source') }}</a>
|
<a class="link-secondary fw-semibold" href="https://linuxiarz.pl" target="_blank" rel="noreferrer">linuxiarz.pl</a>
|
||||||
<a class="link-secondary" href="https://git.linuxiarz.pl/gru/lista_zakupowa_live" target="_blank" rel="noreferrer">{{ ui.t('footer.shoppingSource') }}</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,6 +97,8 @@ export class ShellComponent {
|
|||||||
readonly apiStatus = inject(ApiStatusService);
|
readonly apiStatus = inject(ApiStatusService);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
readonly menuOpen = signal(false);
|
readonly menuOpen = signal(false);
|
||||||
|
readonly currentLanguageLabel = computed(() => this.ui.language() === 'pl' ? 'Polski' : 'English');
|
||||||
|
readonly canAccessIntegrations = computed(() => Boolean(this.auth.currentUser()?.integrationsEnabled));
|
||||||
readonly navItems = computed(() => [
|
readonly navItems = computed(() => [
|
||||||
{ path: '/', label: this.ui.t('nav.dashboard'), exact: true },
|
{ path: '/', label: this.ui.t('nav.dashboard'), exact: true },
|
||||||
{ path: '/expenses', label: this.ui.t('nav.expenses') },
|
{ path: '/expenses', label: this.ui.t('nav.expenses') },
|
||||||
@@ -90,11 +109,33 @@ export class ShellComponent {
|
|||||||
{ path: '/merchants', label: this.ui.t('nav.merchants') },
|
{ path: '/merchants', label: this.ui.t('nav.merchants') },
|
||||||
{ path: '/reports', label: this.ui.t('nav.reports') },
|
{ path: '/reports', label: this.ui.t('nav.reports') },
|
||||||
{ path: '/categories', label: this.ui.t('nav.categories') },
|
{ path: '/categories', label: this.ui.t('nav.categories') },
|
||||||
{ path: '/integrations', label: this.ui.t('nav.integrations') },
|
...(this.canAccessIntegrations() ? [{ path: '/integrations', label: this.ui.t('nav.integrations') }] : []),
|
||||||
...(this.auth.isAdmin() ? [{ path: '/admin', label: this.ui.t('nav.admin') }] : [])
|
...(this.auth.isAdmin() ? [{ path: '/admin', label: this.ui.t('nav.admin') }] : [])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if (!this.canAccessIntegrations() && this.router.url.startsWith('/integrations')) {
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.auth.isAuthenticated()) {
|
||||||
|
this.auth.fetchMe().subscribe({ error: () => undefined });
|
||||||
|
this.router.events.pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd)).subscribe(() => {
|
||||||
|
this.auth.fetchMe().subscribe({ error: () => undefined });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleLanguage() { this.ui.setLanguage(this.ui.language() === 'pl' ? 'en' : 'pl'); }
|
||||||
toggleMenu() { this.menuOpen.update((value) => !value); }
|
toggleMenu() { this.menuOpen.update((value) => !value); }
|
||||||
closeMenu() { this.menuOpen.set(false); }
|
closeMenu() { this.menuOpen.set(false); }
|
||||||
logout() { this.auth.logout(); this.router.navigate(['/login']); }
|
logout() {
|
||||||
|
this.closeMenu();
|
||||||
|
this.auth.logout();
|
||||||
|
void this.router.navigateByUrl('/login', { replaceUrl: true }).catch(() => {
|
||||||
|
globalThis.location.href = '/login';
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface User {
|
|||||||
role: 'ADMIN' | 'USER';
|
role: 'ADMIN' | 'USER';
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
defaultCurrency: string;
|
defaultCurrency: string;
|
||||||
|
integrationsEnabled?: boolean;
|
||||||
reportPreferences?: ReportPreferences;
|
reportPreferences?: ReportPreferences;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -36,6 +37,7 @@ export interface Proof {
|
|||||||
mimeType: string | null;
|
mimeType: string | null;
|
||||||
fileSize: number | null;
|
fileSize: number | null;
|
||||||
fileUrl: string | null;
|
fileUrl: string | null;
|
||||||
|
previewUrl?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +71,20 @@ export interface DuplicateGroup {
|
|||||||
matches: Expense[];
|
matches: Expense[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaginationMeta {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasPrev: boolean;
|
||||||
|
hasNext: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpenseListResponse {
|
||||||
|
items: Expense[];
|
||||||
|
pagination?: PaginationMeta;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StatsResponse {
|
export interface StatsResponse {
|
||||||
total: number;
|
total: number;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -148,6 +164,7 @@ export interface AppSettings {
|
|||||||
id: string;
|
id: string;
|
||||||
appName: string;
|
appName: string;
|
||||||
defaultCurrency: string;
|
defaultCurrency: string;
|
||||||
|
integrationsEnabled?: boolean;
|
||||||
registrationEnabled: boolean;
|
registrationEnabled: boolean;
|
||||||
allowedProofTypes: string[];
|
allowedProofTypes: string[];
|
||||||
uiPreferences: Record<string, string | number | boolean>;
|
uiPreferences: Record<string, string | number | boolean>;
|
||||||
@@ -181,6 +198,7 @@ export interface ShoppingListIntegrationSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ShoppingListSummary {
|
export interface ShoppingListSummary {
|
||||||
|
period?: string;
|
||||||
total?: number;
|
total?: number;
|
||||||
amount?: number;
|
amount?: number;
|
||||||
count?: number;
|
count?: number;
|
||||||
@@ -257,3 +275,8 @@ export interface ShoppingListTemplate {
|
|||||||
title?: string;
|
title?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShoppingListPeriodImportResponse {
|
||||||
|
item: Expense;
|
||||||
|
warnings?: string[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ body {
|
|||||||
.toast-host {
|
.toast-host {
|
||||||
z-index: 1080;
|
z-index: 1080;
|
||||||
width: min(420px, 100vw);
|
width: min(420px, 100vw);
|
||||||
top: 5rem !important;
|
top: 1rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-host .toast {
|
.toast-host .toast {
|
||||||
@@ -386,7 +386,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toast-host {
|
.toast-host {
|
||||||
top: 4.5rem !important;
|
top: 0.75rem !important;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -506,3 +506,84 @@ body {
|
|||||||
.pv-subnav-tabs { flex-direction: column; width: 100%; }
|
.pv-subnav-tabs { flex-direction: column; width: 100%; }
|
||||||
.ec-footer-shell { flex-direction: column; align-items: flex-start; }
|
.ec-footer-shell { flex-direction: column; align-items: flex-start; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.ec-modal-close {
|
||||||
|
min-width: 2.75rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-proof-modal-body {
|
||||||
|
min-height: min(70vh, 720px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-proof-frame {
|
||||||
|
width: 100%;
|
||||||
|
min-height: min(72vh, 760px);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-scroll-list {
|
||||||
|
max-height: 36rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-toolbar-toggle {
|
||||||
|
border: 1px solid var(--ec-card-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.125rem;
|
||||||
|
background: rgba(var(--tblr-bg-surface-rgb), 0.75);
|
||||||
|
box-shadow: var(--ec-card-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-toolbar-toggle .btn {
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.pv-logout-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-logout-btn__content {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-proof-modal-body {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-proof-frame {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 78vh;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-modal-close {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|||||||
0
web/todo.txt
Normal file
0
web/todo.txt
Normal file
Reference in New Issue
Block a user