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:
BOHA
2026-06-09 22:12:24 +02:00
parent 1e4dd1fbcc
commit 97101c4db7
11 changed files with 760 additions and 66 deletions

View File

@@ -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],

View File

@@ -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;

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>