diff --git a/src/__tests__/offer-invoice-totals.test.ts b/src/__tests__/offer-invoice-totals.test.ts
new file mode 100644
index 0000000..83e03d9
--- /dev/null
+++ b/src/__tests__/offer-invoice-totals.test.ts
@@ -0,0 +1,280 @@
+import { describe, it, expect, afterEach } from "vitest";
+import prisma from "../config/database";
+import { createOffer, getOfferTotals } from "../services/offers.service";
+import {
+ createInvoice,
+ getInvoiceListTotals,
+} from "../services/invoices.service";
+import { getReceivedInvoiceListTotals } from "../routes/admin/received-invoices";
+
+// Per-currency total aggregation for the offers, issued-invoices and
+// received-invoices lists. These hit the real `app_test` DB via the service
+// layer (suite convention) and prove the totals fns sum each document's TOTAL
+// per currency across the WHOLE filtered set, and that the filtering `where`
+// (search / month-year / status) is respected — mirroring order-totals.test.ts.
+//
+// For invoices a far-future month/year is used so seeded/other-test rows can't
+// fall in the window and skew the deterministic sums. Offers have NO date
+// filter, so they are isolated by a unique `project_code` searched on instead.
+// Received invoices store amount as GROSS (VAT-inclusive), summed directly.
+
+const STATS_YEAR = 2097;
+const STATS_MONTH = 8; // -> invoice issue_date window [2097-08-01, 2097-09-01)
+
+// Unique marker so the offers test (which has no date filter) can scope its
+// aggregation to only the rows it created via a `search` on project_code.
+const OFFER_MARKER = `TOTALS-TEST-${STATS_YEAR}`;
+
+const createdOfferIds: number[] = [];
+const createdInvoiceIds: number[] = [];
+const createdReceivedIds: number[] = [];
+
+afterEach(async () => {
+ for (const id of createdOfferIds)
+ await prisma.quotations.deleteMany({ where: { id } });
+ for (const id of createdInvoiceIds)
+ await prisma.invoices.deleteMany({ where: { id } });
+ for (const id of createdReceivedIds)
+ await prisma.received_invoices.deleteMany({ where: { id } });
+ createdOfferIds.length = 0;
+ createdInvoiceIds.length = 0;
+ createdReceivedIds.length = 0;
+ // Finalized offers/invoices consume their sequences — reset so we don't leak
+ // a consumed number into sibling test files.
+ await prisma.number_sequences.deleteMany({
+ where: { type: { in: ["offer", "invoice"] } },
+ });
+});
+
+function amountFor(
+ totals: { currency: string; amount: number }[],
+ currency: string,
+): number | undefined {
+ return totals.find((t) => t.currency === currency)?.amount;
+}
+
+describe("getOfferTotals per-currency aggregation", () => {
+ it("sums offer TOTAL incl. VAT per currency over the full filtered set", async () => {
+ // Two CZK offers + one EUR. 21% VAT applied on the whole subtotal
+ // (enrichQuotation math).
+ // CZK #1: 2 x 1000 = 2000 net -> +21% = 2420
+ // CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025
+ // EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363
+ const mk = async (currency: string, qty: number, price: number) => {
+ const res = await createOffer({
+ status: "draft", // draft so no offer number is consumed
+ currency,
+ vat_rate: 21,
+ apply_vat: true,
+ project_code: OFFER_MARKER,
+ items: [{ description: "X", quantity: qty, unit_price: price }],
+ });
+ if ("error" in res) throw new Error(JSON.stringify(res));
+ createdOfferIds.push(res.id);
+ return res.id;
+ };
+
+ await mk("CZK", 2, 1000);
+ await mk("CZK", 1, 500);
+ await mk("EUR", 3, 100);
+
+ const { totals } = await getOfferTotals({ search: OFFER_MARKER });
+ expect(amountFor(totals, "CZK")).toBe(3025);
+ expect(amountFor(totals, "EUR")).toBe(363);
+ });
+
+ it("respects the where: a status filter narrows the result", async () => {
+ const mk = async (status: string) => {
+ const res = await createOffer({
+ status,
+ currency: "CZK",
+ vat_rate: 21,
+ apply_vat: true,
+ project_code: OFFER_MARKER,
+ items: [{ description: "X", quantity: 1, unit_price: 1000 }],
+ });
+ if ("error" in res) throw new Error(JSON.stringify(res));
+ createdOfferIds.push(res.id);
+ return res.id;
+ };
+
+ // Two drafts + one invalidated, all sharing the marker.
+ await mk("draft");
+ await mk("draft");
+ await mk("invalidated");
+
+ // Marker only: all three count -> 3 x 1210 = 3630.
+ const all = await getOfferTotals({ search: OFFER_MARKER });
+ expect(amountFor(all.totals, "CZK")).toBe(3630);
+
+ // Status filter narrows to the two drafts -> 2 x 1210 = 2420.
+ const onlyDraft = await getOfferTotals({
+ search: OFFER_MARKER,
+ status: "draft",
+ });
+ expect(amountFor(onlyDraft.totals, "CZK")).toBe(2420);
+
+ // Invalidated alone -> 1210.
+ const onlyInvalid = await getOfferTotals({
+ search: OFFER_MARKER,
+ status: "invalidated",
+ });
+ expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1210);
+ });
+});
+
+describe("getInvoiceListTotals (issued invoices) per-currency aggregation", () => {
+ // issue_date is settable at create, so no post-create pin needed. VAT is
+ // applied per-line (computeInvoiceTotals) but with a single line per invoice
+ // here the result matches the whole-subtotal math.
+ const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
+
+ const mk = async (
+ currency: string,
+ qty: number,
+ price: number,
+ status = "draft",
+ ) => {
+ const inv = await createInvoice({
+ status,
+ currency,
+ vat_rate: 21,
+ apply_vat: true,
+ issue_date: dateStr,
+ items: [{ description: "X", quantity: qty, unit_price: price }],
+ });
+ createdInvoiceIds.push(inv.id);
+ return inv.id;
+ };
+
+ it("sums invoice TOTAL incl. VAT per currency over the full filtered set", async () => {
+ // CZK #1: 2 x 1000 @21% = 2420
+ // CZK #2: 1 x 500 @21% = 605 => CZK total 3025
+ // EUR : 3 x 100 @21% = 363 => EUR total 363
+ await mk("CZK", 2, 1000);
+ await mk("CZK", 1, 500);
+ await mk("EUR", 3, 100);
+
+ const { totals } = await getInvoiceListTotals({
+ month: STATS_MONTH,
+ year: STATS_YEAR,
+ });
+ expect(amountFor(totals, "CZK")).toBe(3025);
+ expect(amountFor(totals, "EUR")).toBe(363);
+ });
+
+ it("respects the where: a different month and a status filter change the result", async () => {
+ const nextDateStr = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
+
+ // Target month: one draft, one issued (both 1210). Next month: one draft.
+ await mk("CZK", 1, 1000, "draft");
+ // An already-issued invoice numbers immediately; that's fine for the sum.
+ const issued = await createInvoice({
+ status: "issued",
+ currency: "CZK",
+ vat_rate: 21,
+ apply_vat: true,
+ issue_date: dateStr,
+ items: [{ description: "X", quantity: 1, unit_price: 1000 }],
+ });
+ createdInvoiceIds.push(issued.id);
+
+ const next = await createInvoice({
+ status: "draft",
+ currency: "CZK",
+ vat_rate: 21,
+ apply_vat: true,
+ issue_date: nextDateStr,
+ items: [{ description: "X", quantity: 1, unit_price: 1000 }],
+ });
+ createdInvoiceIds.push(next.id);
+
+ // Whole target month: both count -> 2 x 1210 = 2420.
+ const whole = await getInvoiceListTotals({
+ month: STATS_MONTH,
+ year: STATS_YEAR,
+ });
+ expect(amountFor(whole.totals, "CZK")).toBe(2420);
+
+ // Status filter narrows to the single 'issued' in the target month -> 1210.
+ const onlyIssued = await getInvoiceListTotals({
+ month: STATS_MONTH,
+ year: STATS_YEAR,
+ status: "issued",
+ });
+ expect(amountFor(onlyIssued.totals, "CZK")).toBe(1210);
+
+ // Next month sees only the one invoice there -> 1210.
+ const nextMonth = await getInvoiceListTotals({
+ month: STATS_MONTH + 1,
+ year: STATS_YEAR,
+ });
+ expect(amountFor(nextMonth.totals, "CZK")).toBe(1210);
+ });
+});
+
+describe("received-invoices list-totals per-currency aggregation (GROSS)", () => {
+ // No service create for received invoices — rows are written directly. The
+ // list/totals `where` filters by the month/year COLUMNS (not issue_date).
+ // amount is GROSS, summed directly per currency.
+ const mkReceived = async (currency: string, amount: number) => {
+ const row = await prisma.received_invoices.create({
+ data: {
+ month: STATS_MONTH,
+ year: STATS_YEAR,
+ supplier_name: "Totals Test Supplier",
+ amount,
+ currency,
+ vat_rate: 21,
+ status: "unpaid",
+ },
+ });
+ createdReceivedIds.push(row.id);
+ return row.id;
+ };
+
+ it("sums GROSS amount per currency over the full filtered set", async () => {
+ // Two CZK (2420 + 605) + one EUR (363). Amounts are GROSS — summed as-is.
+ await mkReceived("CZK", 2420);
+ await mkReceived("CZK", 605);
+ await mkReceived("EUR", 363);
+
+ const { totals } = await getReceivedInvoiceListTotals({
+ month: STATS_MONTH,
+ year: STATS_YEAR,
+ });
+ expect(amountFor(totals, "CZK")).toBe(3025);
+ expect(amountFor(totals, "EUR")).toBe(363);
+ });
+
+ it("respects the where: a different month and a search filter change the result", async () => {
+ await mkReceived("CZK", 1000);
+ // A row in the NEXT month must not count toward the target month's total.
+ const other = await prisma.received_invoices.create({
+ data: {
+ month: STATS_MONTH + 1,
+ year: STATS_YEAR,
+ supplier_name: "Totals Test Supplier",
+ amount: 9999,
+ currency: "CZK",
+ vat_rate: 21,
+ status: "unpaid",
+ },
+ });
+ createdReceivedIds.push(other.id);
+
+ const { totals } = await getReceivedInvoiceListTotals({
+ month: STATS_MONTH,
+ year: STATS_YEAR,
+ });
+ expect(amountFor(totals, "CZK")).toBe(1000);
+
+ // Search on the supplier name still scopes to the target-month row.
+ const { totals: searched } = await getReceivedInvoiceListTotals({
+ month: STATS_MONTH,
+ year: STATS_YEAR,
+ search: "Totals Test Supplier",
+ });
+ expect(amountFor(searched, "CZK")).toBe(1000);
+ });
+});
diff --git a/src/admin/lib/queries/invoices.ts b/src/admin/lib/queries/invoices.ts
index 64a13be..ac8899d 100644
--- a/src/admin/lib/queries/invoices.ts
+++ b/src/admin/lib/queries/invoices.ts
@@ -192,6 +192,31 @@ export const invoiceStatsOptions = (month: number, year: number) =>
),
});
+// Per-currency total over the WHOLE filtered set of the ISSUED-invoice list.
+// Distinct from `invoiceStatsOptions` (the KPI cards) — hits /list-totals and
+// passes the same filters the issued list uses (search/status/month/year).
+export const invoiceTotalsOptions = (filters: {
+ search?: string;
+ status?: string;
+ month?: number;
+ year?: number;
+}) =>
+ queryOptions({
+ queryKey: ["invoices", "list-totals", filters],
+ queryFn: async () => {
+ const params = new URLSearchParams();
+ if (filters.search) params.set("search", filters.search);
+ if (filters.status) params.set("status", filters.status);
+ if (filters.month) params.set("month", String(filters.month));
+ if (filters.year) params.set("year", String(filters.year));
+ const qs = params.toString();
+ const data = await jsonQuery<{ totals?: CurrencyAmount[] }>(
+ `/api/admin/invoices/list-totals${qs ? `?${qs}` : ""}`,
+ );
+ return data.totals ?? [];
+ },
+ });
+
export const receivedInvoiceStatsOptions = (month: number, year: number) =>
queryOptions({
queryKey: ["invoices", "received", "stats", month, year],
@@ -201,6 +226,30 @@ export const receivedInvoiceStatsOptions = (month: number, year: number) =>
),
});
+// Per-currency total over the WHOLE filtered set of the received-invoice list.
+// Distinct from `receivedInvoiceStatsOptions` (the KPI cards) — hits
+// /list-totals and passes the same filters the received list uses
+// (month/year/search). `amount` is GROSS (VAT-inclusive).
+export const receivedInvoiceTotalsOptions = (filters: {
+ month?: number;
+ year?: number;
+ search?: string;
+}) =>
+ queryOptions({
+ queryKey: ["invoices", "received", "list-totals", filters],
+ queryFn: async () => {
+ const params = new URLSearchParams();
+ if (filters.month) params.set("month", String(filters.month));
+ if (filters.year) params.set("year", String(filters.year));
+ if (filters.search) params.set("search", filters.search);
+ const qs = params.toString();
+ const data = await jsonQuery<{ totals?: CurrencyAmount[] }>(
+ `/api/admin/received-invoices/list-totals${qs ? `?${qs}` : ""}`,
+ );
+ return data.totals ?? [];
+ },
+ });
+
export const invoiceDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["invoices", id],
diff --git a/src/admin/lib/queries/offers.ts b/src/admin/lib/queries/offers.ts
index e6634f0..4ae0500 100644
--- a/src/admin/lib/queries/offers.ts
+++ b/src/admin/lib/queries/offers.ts
@@ -1,6 +1,11 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
+export interface CurrencyAmount {
+ amount: number;
+ currency: string;
+}
+
export interface ItemTemplate {
id: number;
name: string;
@@ -91,6 +96,27 @@ export const offerListOptions = (filters: {
},
});
+export const offerStatsOptions = (filters: {
+ search?: string;
+ status?: string;
+ customer_id?: number;
+}) =>
+ queryOptions({
+ queryKey: ["offers", "stats", filters],
+ queryFn: async () => {
+ const params = new URLSearchParams();
+ if (filters.search) params.set("search", filters.search);
+ if (filters.status) params.set("status", filters.status);
+ if (filters.customer_id)
+ params.set("customer_id", String(filters.customer_id));
+ const qs = params.toString();
+ const data = await jsonQuery<{ totals?: CurrencyAmount[] }>(
+ `/api/admin/offers/stats${qs ? `?${qs}` : ""}`,
+ );
+ return data.totals ?? [];
+ },
+ });
+
export interface OfferItemData {
id?: number;
description: string;
diff --git a/src/admin/pages/Invoices.tsx b/src/admin/pages/Invoices.tsx
index c98fca8..7d9b56e 100644
--- a/src/admin/pages/Invoices.tsx
+++ b/src/admin/pages/Invoices.tsx
@@ -23,6 +23,7 @@ import useTableSort from "../hooks/useTableSort";
import {
invoiceListOptions,
invoiceStatsOptions,
+ invoiceTotalsOptions,
type Invoice,
type CurrencyAmount,
} from "../lib/queries/invoices";
@@ -283,6 +284,18 @@ export default function Invoices() {
}),
);
+ // Per-currency total over the WHOLE filtered set of the ISSUED list (not one
+ // page). Uses the SAME filters as the issued list query so the summary
+ // matches what's shown. Separate from the KPI `statsQuery` above.
+ const listTotalsQuery = useQuery(
+ invoiceTotalsOptions({
+ search,
+ status: statusFilter || undefined,
+ month: statsMonth,
+ year: statsYear,
+ }),
+ );
+
// Mark first load done in an effect (not during render) to avoid a
// render-phase ref mutation.
useEffect(() => {
@@ -755,6 +768,27 @@ export default function Invoices() {
)
}
/>
+ {(listTotalsQuery.data?.length ?? 0) > 0 && (
+
+ Celkem:
+
+ {formatMultiCurrency(listTotalsQuery.data ?? [])}
+
+
+ )}
{
@@ -875,6 +894,27 @@ export default function Offers() {
)
}
/>
+ {(statsQuery.data?.length ?? 0) > 0 && (
+
+ Celkem:
+
+ {formatMultiCurrency(statsQuery.data ?? [])}
+
+
+ )}
)}
+ {(listTotalsQuery.data?.length ?? 0) > 0 && (
+
+ Celkem:
+
+ {formatMultiCurrency(listTotalsQuery.data ?? [])}
+
+
+ )}
diff --git a/src/routes/admin/invoices.ts b/src/routes/admin/invoices.ts
index 6813841..03b0c6c 100644
--- a/src/routes/admin/invoices.ts
+++ b/src/routes/admin/invoices.ts
@@ -12,6 +12,7 @@ import {
import {
markOverdueInvoices,
listInvoices,
+ getInvoiceListTotals,
getNextInvoiceNumberFormatted,
getNextInvoiceNumberPreview,
getInvoiceStats,
@@ -77,7 +78,7 @@ export default async function invoicesRoutes(
},
);
- // GET /api/admin/invoices/stats
+ // GET /api/admin/invoices/stats — monthly KPI cards (paid/awaiting/overdue/VAT).
fastify.get(
"/stats",
{ preHandler: requirePermission("invoices.view") },
@@ -90,6 +91,27 @@ export default async function invoicesRoutes(
},
);
+ // GET /api/admin/invoices/list-totals — per-currency total (incl. VAT) summed
+ // over the WHOLE filtered set (not one page), matching the issued-invoice
+ // list's filters (search/status/month/year). Distinct path from "/stats" (the
+ // KPI cards) to avoid colliding with that endpoint. Registered BEFORE "/:id"
+ // so the literal path isn't captured as an invoice id.
+ fastify.get(
+ "/list-totals",
+ { preHandler: requirePermission("invoices.view") },
+ async (request, reply) => {
+ const query = request.query as Record;
+ const result = await getInvoiceListTotals({
+ search: query.search ? String(query.search) : undefined,
+ status: query.status ? String(query.status) : undefined,
+ customer_id: query.customer_id ? Number(query.customer_id) : undefined,
+ month: query.month ? Number(query.month) : undefined,
+ year: query.year ? Number(query.year) : undefined,
+ });
+ return success(reply, { totals: result.totals });
+ },
+ );
+
// GET /api/admin/invoices/order-data/:id
fastify.get<{ Params: { id: string } }>(
"/order-data/:id",
diff --git a/src/routes/admin/quotations.ts b/src/routes/admin/quotations.ts
index 822afe7..bb86f23 100644
--- a/src/routes/admin/quotations.ts
+++ b/src/routes/admin/quotations.ts
@@ -11,6 +11,7 @@ import {
import prisma from "../../config/database";
import {
listOffers,
+ getOfferTotals,
getOffer,
createOffer,
updateOffer,
@@ -52,6 +53,24 @@ export default async function quotationsRoutes(
},
);
+ // GET /api/admin/offers/stats — per-currency total (incl. VAT) summed over the
+ // WHOLE filtered set (not one page). Offers have NO month/year filter; the
+ // list filters by search + status + customer_id, so the totals use the same.
+ // Registered BEFORE "/:id" so the literal "stats" path isn't captured as an id.
+ fastify.get(
+ "/stats",
+ { preHandler: requirePermission("offers.view") },
+ async (request, reply) => {
+ const query = request.query as Record;
+ const result = await getOfferTotals({
+ search: query.search ? String(query.search) : undefined,
+ status: query.status ? String(query.status) : undefined,
+ customer_id: query.customer_id ? Number(query.customer_id) : undefined,
+ });
+ return success(reply, { totals: result.totals });
+ },
+ );
+
// GET /api/admin/offers/next-number
fastify.get(
"/next-number",
diff --git a/src/routes/admin/received-invoices.ts b/src/routes/admin/received-invoices.ts
index c344693..d3b0c44 100644
--- a/src/routes/admin/received-invoices.ts
+++ b/src/routes/admin/received-invoices.ts
@@ -41,6 +41,71 @@ const ALLOWED_SORT_FIELDS = [
"created_at",
];
+interface ReceivedInvoiceFilterParams {
+ year?: number;
+ month?: number;
+ status?: string;
+ supplier_name?: string;
+ search?: string;
+}
+
+/**
+ * Shared `where` builder for the received-invoices list and the per-currency
+ * list-totals aggregation, so both stay in sync. The list filters by
+ * year/month/status/supplier_name/search.
+ */
+export function buildReceivedWhere(
+ params: ReceivedInvoiceFilterParams,
+): Record {
+ const { year, month, status, supplier_name, search } = params;
+ const where: Record = {};
+ if (year) where.year = year;
+ if (month) where.month = month;
+ if (status) where.status = status;
+ if (supplier_name) where.supplier_name = { contains: supplier_name };
+ if (search) {
+ where.OR = [
+ { supplier_name: { contains: search } },
+ { invoice_number: { contains: search } },
+ { description: { contains: search } },
+ ];
+ }
+ return where;
+}
+
+/**
+ * Sum received-invoice GROSS `amount` per currency over the WHOLE filtered set
+ * (not one page), reusing `buildReceivedWhere` so the totals track the list.
+ * `amount` is VAT-inclusive (GROSS), so it is summed directly. Returns one
+ * entry per currency, rounded to 2dp, zero/empty totals dropped. Exported so
+ * the route and the totals test share one implementation.
+ */
+export async function getReceivedInvoiceListTotals(
+ params: ReceivedInvoiceFilterParams,
+): Promise<{ totals: Array<{ amount: number; currency: string }> }> {
+ const where = buildReceivedWhere(params);
+
+ const invoices = await prisma.received_invoices.findMany({
+ where,
+ select: { amount: true, currency: true },
+ });
+
+ const byCurrency: Record = {};
+ for (const inv of invoices) {
+ const cur = inv.currency || "CZK";
+ byCurrency[cur] = (byCurrency[cur] || 0) + (Number(inv.amount) || 0);
+ }
+
+ const totals = Object.entries(byCurrency)
+ .map(([currency, amount]) => ({
+ currency,
+ amount: Math.round(amount * 100) / 100,
+ }))
+ .filter((t) => t.amount > 0);
+
+ return { totals };
+}
+
export default async function receivedInvoicesRoutes(
fastify: FastifyInstance,
): Promise {
@@ -53,22 +118,15 @@ export default async function receivedInvoicesRoutes(
const query = request.query as Record;
const { page, limit, skip, order } = parsePagination(query);
- const where: Record = {};
- if (query.year) where.year = Number(query.year);
- if (query.month) where.month = Number(query.month);
- if (query.status) where.status = String(query.status);
- if (query.supplier_name)
- where.supplier_name = { contains: String(query.supplier_name) };
-
- // Search across supplier_name, invoice_number, description
- if (query.search) {
- const search = String(query.search);
- where.OR = [
- { supplier_name: { contains: search } },
- { invoice_number: { contains: search } },
- { description: { contains: search } },
- ];
- }
+ const where = buildReceivedWhere({
+ year: query.year ? Number(query.year) : undefined,
+ month: query.month ? Number(query.month) : undefined,
+ status: query.status ? String(query.status) : undefined,
+ supplier_name: query.supplier_name
+ ? String(query.supplier_name)
+ : undefined,
+ search: query.search ? String(query.search) : undefined,
+ });
// Sort field whitelisting
const sortField =
@@ -168,6 +226,30 @@ export default async function receivedInvoicesRoutes(
},
);
+ // GET /api/admin/received-invoices/list-totals — per-currency total summed
+ // over the WHOLE filtered set (not one page), matching the received-invoice
+ // list's filters (year/month/status/supplier_name/search). `amount` is GROSS
+ // (VAT-inclusive), so it is summed directly per currency. Distinct path from
+ // "/stats" (the monthly KPI cards) to avoid colliding with that endpoint.
+ // Registered BEFORE "/:id" so the literal path isn't captured as an id.
+ fastify.get(
+ "/list-totals",
+ { preHandler: requirePermission("invoices.view") },
+ async (request, reply) => {
+ const query = request.query as Record;
+ const result = await getReceivedInvoiceListTotals({
+ year: query.year ? Number(query.year) : undefined,
+ month: query.month ? Number(query.month) : undefined,
+ status: query.status ? String(query.status) : undefined,
+ supplier_name: query.supplier_name
+ ? String(query.supplier_name)
+ : undefined,
+ search: query.search ? String(query.search) : undefined,
+ });
+ return success(reply, { totals: result.totals });
+ },
+ );
+
// GET /api/admin/received-invoices/suppliers — distinct supplier names for autocomplete
fastify.get(
"/suppliers",
diff --git a/src/services/invoices.service.ts b/src/services/invoices.service.ts
index 92d9c13..b8022f3 100644
--- a/src/services/invoices.service.ts
+++ b/src/services/invoices.service.ts
@@ -104,17 +104,53 @@ function computeMoney(
return { subtotal, vat };
}
-interface ListInvoicesParams {
+interface InvoiceFilterParams {
+ search?: string;
+ status?: string;
+ customer_id?: number;
+ month?: number;
+ year?: number;
+}
+
+interface ListInvoicesParams extends InvoiceFilterParams {
page: number;
limit: number;
skip: number;
sort: string;
order: "asc" | "desc";
search: string;
- status?: string;
- customer_id?: number;
- month?: number;
- year?: number;
+}
+
+export interface CurrencyAmount {
+ amount: number;
+ currency: string;
+}
+
+/**
+ * Shared `where` builder for the issued-invoices list and the per-currency
+ * list-totals aggregation, so both stay in sync (any new filter applies to
+ * both). month/year filter on issue_date (matching the list).
+ */
+function buildInvoiceWhere(
+ params: InvoiceFilterParams,
+): Record {
+ const { status, customer_id, month, year, search } = params;
+ const where: Record = {};
+ if (status) where.status = status;
+ if (customer_id) where.customer_id = customer_id;
+ if (month && year) {
+ const from = new Date(year, month - 1, 1);
+ const to = new Date(year, month, 1);
+ where.issue_date = { gte: from, lt: to };
+ }
+ if (search) {
+ where.OR = [
+ { invoice_number: { contains: search } },
+ { customers: { name: { contains: search } } },
+ { customers: { company_id: { contains: search } } },
+ ];
+ }
+ return where;
}
function computeInvoiceTotals(
@@ -159,35 +195,10 @@ export async function markOverdueInvoices() {
}
export async function listInvoices(params: ListInvoicesParams) {
- const {
- page,
- limit,
- skip,
- sort,
- order,
- search,
- status,
- customer_id,
- month,
- year,
- } = params;
+ const { page, limit, skip, sort, order } = params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
- const where: Record = {};
- if (status) where.status = status;
- if (customer_id) where.customer_id = customer_id;
- if (month && year) {
- const from = new Date(year, month - 1, 1);
- const to = new Date(year, month, 1);
- where.issue_date = { gte: from, lt: to };
- }
- if (search) {
- where.OR = [
- { invoice_number: { contains: search } },
- { customers: { name: { contains: search } } },
- { customers: { company_id: { contains: search } } },
- ];
- }
+ const where = buildInvoiceWhere(params);
const orderBy: Record = { [sortField]: order };
@@ -225,6 +236,47 @@ export async function listInvoices(params: ListInvoicesParams) {
return { data: enriched, total, page, limit };
}
+/**
+ * Sum issued-invoice TOTAL (incl. VAT) per currency across the WHOLE filtered
+ * set (not a single page). Reuses `buildInvoiceWhere` so the filters track the
+ * list, and `computeInvoiceTotals` so the per-invoice math matches the
+ * list/detail exactly. Returns one entry per currency, rounded to 2dp,
+ * zero/empty totals dropped.
+ *
+ * NOTE: This is SEPARATE from `getInvoiceStats` (the monthly KPI cards). That
+ * function is untouched — this only sums the list's visible rows per currency.
+ */
+export async function getInvoiceListTotals(
+ params: InvoiceFilterParams,
+): Promise<{ totals: CurrencyAmount[] }> {
+ const where = buildInvoiceWhere(params);
+
+ const invoices = await prisma.invoices.findMany({
+ where,
+ include: { invoice_items: true },
+ });
+
+ const byCurrency: Record = {};
+ for (const inv of invoices) {
+ const { total } = computeInvoiceTotals(
+ inv.invoice_items,
+ inv.apply_vat,
+ inv.vat_rate,
+ );
+ const cur = inv.currency || "CZK";
+ byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0);
+ }
+
+ const totals: CurrencyAmount[] = Object.entries(byCurrency)
+ .map(([currency, amount]) => ({
+ currency,
+ amount: Math.round(amount * 100) / 100,
+ }))
+ .filter((t) => t.amount > 0);
+
+ return { totals };
+}
+
export {
generateInvoiceNumber as getNextInvoiceNumberFormatted,
previewInvoiceNumber as getNextInvoiceNumberPreview,
diff --git a/src/services/offers.service.ts b/src/services/offers.service.ts
index f81fa98..3f43142 100644
--- a/src/services/offers.service.ts
+++ b/src/services/offers.service.ts
@@ -37,15 +37,44 @@ const ALLOWED_SORT_FIELDS = [
"status",
];
-interface ListOffersParams {
+interface OfferFilterParams {
+ search?: string;
+ status?: string;
+ customer_id?: number;
+}
+
+interface ListOffersParams extends OfferFilterParams {
page: number;
limit: number;
skip: number;
sort: string;
order: "asc" | "desc";
search: string;
- status?: string;
- customer_id?: number;
+}
+
+export interface CurrencyAmount {
+ amount: number;
+ currency: string;
+}
+
+/**
+ * Shared `where` builder for the offers list and the per-currency stats
+ * aggregation, so both stay in sync (any new filter applies to both). Offers
+ * have NO month/year filter — the list filters by search + status + customer_id.
+ */
+function buildOfferWhere(params: OfferFilterParams): Record {
+ const { search, status, customer_id } = params;
+ const where: Record = {};
+ if (status) where.status = status;
+ if (customer_id) where.customer_id = customer_id;
+ if (search) {
+ where.OR = [
+ { quotation_number: { contains: search } },
+ { project_code: { contains: search } },
+ { customers: { name: { contains: search } } },
+ ];
+ }
+ return where;
}
function enrichQuotation(q: any) {
@@ -74,20 +103,10 @@ function enrichQuotation(q: any) {
}
export async function listOffers(params: ListOffersParams) {
- const { page, limit, skip, sort, order, search, status, customer_id } =
- params;
+ const { page, limit, skip, sort, order } = params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
- const where: Record = {};
- if (status) where.status = status;
- if (customer_id) where.customer_id = customer_id;
- if (search) {
- where.OR = [
- { quotation_number: { contains: search } },
- { project_code: { contains: search } },
- { customers: { name: { contains: search } } },
- ];
- }
+ const where = buildOfferWhere(params);
const [quotations, total] = await Promise.all([
prisma.quotations.findMany({
@@ -128,6 +147,43 @@ export async function listOffers(params: ListOffersParams) {
return { data: enriched, total, page, limit };
}
+/**
+ * Sum offer TOTAL (incl. VAT) per currency across the WHOLE filtered set (not a
+ * single page). Reuses `buildOfferWhere` so the filters track the list, and
+ * `enrichQuotation` so the per-offer math matches the list/detail exactly.
+ * Returns one entry per currency, rounded to 2dp, zero/empty totals dropped.
+ */
+export async function getOfferTotals(
+ params: OfferFilterParams,
+): Promise<{ totals: CurrencyAmount[] }> {
+ const where = buildOfferWhere(params);
+
+ const quotations = await prisma.quotations.findMany({
+ where,
+ include: {
+ customers: { select: { id: true, name: true } },
+ quotation_items: { orderBy: { position: "asc" } },
+ scope_sections: { orderBy: { position: "asc" } },
+ },
+ });
+
+ const byCurrency: Record = {};
+ for (const q of quotations) {
+ const { total, currency } = enrichQuotation(q);
+ const cur = currency || "CZK";
+ byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0);
+ }
+
+ const totals: CurrencyAmount[] = Object.entries(byCurrency)
+ .map(([currency, amount]) => ({
+ currency,
+ amount: Math.round(amount * 100) / 100,
+ }))
+ .filter((t) => t.amount > 0);
+
+ return { totals };
+}
+
export async function getOffer(id: number) {
const quotation = await prisma.quotations.findUnique({
where: { id },