feat(offers,invoices): per-currency 'Celkem' totals beneath the lists (consistent with orders)
Mirrors the orders totals exactly: /stats (offers) + /list-totals (issued + received invoices) endpoints sum each doc's total per currency across the active filter (reusing extracted where-builders so they stay in sync with the lists), rendered as the identical 'Celkem: …' block via the shared formatMultiCurrency. Existing invoice/received KPI stats untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
280
src/__tests__/offer-invoice-totals.test.ts
Normal file
280
src/__tests__/offer-invoice-totals.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
mt: 1.5,
|
||||
px: 1,
|
||||
gap: 1,
|
||||
color: "text.secondary",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
<span>Celkem:</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||
>
|
||||
{formatMultiCurrency(listTotalsQuery.data ?? [])}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
|
||||
@@ -13,12 +13,21 @@ import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
|
||||
import apiFetch from "../utils/api";
|
||||
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatMultiCurrency,
|
||||
czechPlural,
|
||||
} from "../utils/formatters";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import { offerListOptions, offerCustomersOptions } from "../lib/queries/offers";
|
||||
import {
|
||||
offerListOptions,
|
||||
offerStatsOptions,
|
||||
offerCustomersOptions,
|
||||
} from "../lib/queries/offers";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import {
|
||||
Button,
|
||||
@@ -370,6 +379,16 @@ export default function Offers() {
|
||||
}),
|
||||
);
|
||||
|
||||
// Per-currency total over the WHOLE filtered set (not one page). Uses the
|
||||
// SAME filters as the list query so the summary matches what's shown.
|
||||
const statsQuery = useQuery(
|
||||
offerStatsOptions({
|
||||
search: debouncedSearch,
|
||||
status: statusFilter || undefined,
|
||||
customer_id: customerFilter || undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
// Mark first load done in an effect (not during render) to avoid a
|
||||
// render-phase ref mutation.
|
||||
useEffect(() => {
|
||||
@@ -875,6 +894,27 @@ export default function Offers() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
{(statsQuery.data?.length ?? 0) > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
mt: 1.5,
|
||||
px: 1,
|
||||
gap: 1,
|
||||
color: "text.secondary",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
<span>Celkem:</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||
>
|
||||
{formatMultiCurrency(statsQuery.data ?? [])}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { supplierListOptions } from "../lib/queries/common";
|
||||
import {
|
||||
receivedInvoiceListOptions,
|
||||
receivedInvoiceStatsOptions,
|
||||
receivedInvoiceTotalsOptions,
|
||||
type ReceivedInvoice,
|
||||
type CurrencyAmount,
|
||||
} from "../lib/queries/invoices";
|
||||
@@ -277,6 +278,18 @@ export default function ReceivedInvoices({
|
||||
receivedInvoiceStatsOptions(statsMonth, statsYear),
|
||||
);
|
||||
|
||||
// Per-currency total over the WHOLE filtered set (not one page). Uses the
|
||||
// SAME filters as the list query (month/year/search) so the summary matches
|
||||
// what's shown. `amount` is GROSS (VAT-inclusive). Separate from the KPI
|
||||
// `statsQuery` above.
|
||||
const listTotalsQuery = useQuery(
|
||||
receivedInvoiceTotalsOptions({
|
||||
month: statsMonth,
|
||||
year: statsYear,
|
||||
search,
|
||||
}),
|
||||
);
|
||||
|
||||
// Derive list data from query (paginatedJsonQuery returns { data, pagination })
|
||||
const invoices = listQuery.data?.data ?? [];
|
||||
|
||||
@@ -841,6 +854,27 @@ export default function ReceivedInvoices({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(listTotalsQuery.data?.length ?? 0) > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
mt: 1.5,
|
||||
px: 1,
|
||||
gap: 1,
|
||||
color: "text.secondary",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
<span>Celkem:</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||
>
|
||||
{formatMultiCurrency(listTotalsQuery.data ?? [])}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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",
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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",
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
const { year, month, status, supplier_name, search } = params;
|
||||
const where: Record<string, unknown> = {};
|
||||
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<string, number> = {};
|
||||
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<void> {
|
||||
@@ -53,22 +118,15 @@ export default async function receivedInvoicesRoutes(
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order } = parsePagination(query);
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
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",
|
||||
|
||||
@@ -104,17 +104,53 @@ function computeMoney<T>(
|
||||
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<string, unknown> {
|
||||
const { status, customer_id, month, year, search } = params;
|
||||
const where: Record<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
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<string, string> = { [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<string, number> = {};
|
||||
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,
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
const { search, status, customer_id } = params;
|
||||
const where: Record<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
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<string, number> = {};
|
||||
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 },
|
||||
|
||||
Reference in New Issue
Block a user