feat: CNB exchange rates, multi-currency KPI stats, invoice PDF VAT in CZK

- ČNB exchange rate service with date-specific rates and caching
- Invoice/received invoice stats convert foreign currencies to CZK
- Dashboard revenue converts all currencies to CZK
- Invoice PDF: VAT recap table always in CZK with CNB rate footer
- Inline styles replaced with utility classes (step 4 cleanup)
- Spinner animation exempt from prefers-reduced-motion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-27 13:44:53 +01:00
parent a3ef37d0d2
commit 8cdf057ab3
13 changed files with 173 additions and 117 deletions

View File

@@ -1,4 +1,5 @@
import prisma from "../config/database";
import { toCzk } from "./exchange-rates";
// Status transition rules matching PHP
const VALID_TRANSITIONS: Record<string, string[]> = {
@@ -186,10 +187,11 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
}));
};
const sumCzk = (invoices: typeof allInvoices) => {
const sumCzk = async (invoices: typeof allInvoices) => {
let total = 0;
for (const inv of invoices) {
total += invoiceTotalWithVat(inv); // Simplified: no real FX conversion
const amount = invoiceTotalWithVat(inv);
total += await toCzk(amount, inv.currency || "CZK");
}
return Math.round(total * 100) / 100;
};
@@ -224,18 +226,24 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
let vatCzk = 0;
for (const [, v] of Object.entries(vatMap)) vatCzk += v;
// VAT also needs conversion
let vatCzkConverted = 0;
for (const [cur, amount] of Object.entries(vatMap)) {
vatCzkConverted += await toCzk(amount, cur);
}
return {
paid_month: aggregateByCurrency(paidInvoices),
paid_month_czk: sumCzk(paidInvoices),
paid_month_czk: await sumCzk(paidInvoices),
paid_month_count: paidInvoices.length,
awaiting: aggregateByCurrency(awaitingInvoices),
awaiting_czk: sumCzk(awaitingInvoices),
awaiting_czk: await sumCzk(awaitingInvoices),
awaiting_count: awaitingInvoices.length,
overdue: aggregateByCurrency(overdueInvoices),
overdue_czk: sumCzk(overdueInvoices),
overdue_czk: await sumCzk(overdueInvoices),
overdue_count: overdueInvoices.length,
vat_month: vatAmounts,
vat_month_czk: Math.round(vatCzk * 100) / 100,
vat_month_czk: Math.round(vatCzkConverted * 100) / 100,
month,
year,
};