feat(orders): PDF footer = issuer name+email, 'Zobrazit objednávku' button, per-currency list totals

- Issued-order PDF: drop the Vystavil/Schválil signature row; footer now shows
  'Vystavil: <name>' + 'E-mail: <email>' from the logged-in user (authData).
- IssuedOrderDetail PDF button 'Export PDF' -> 'Zobrazit objednávku' (already
  opens inline like invoices' 'Zobrazit fakturu').
- New /orders/stats + /issued-orders/stats endpoints sum order total (incl VAT)
  per currency across the active filter; rendered as 'Celkem: …' beneath both
  the received and issued lists. formatMultiCurrency lifted to a shared util.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 21:50:05 +02:00
parent 78cd7cf2d8
commit 5462fcf944
14 changed files with 627 additions and 84 deletions

View File

@@ -70,6 +70,33 @@ export const issuedOrderListOptions = (filters: {
},
});
export interface CurrencyAmount {
amount: number;
currency: string;
}
export const issuedOrderStatsOptions = (filters: {
search?: string;
status?: string;
month?: number;
year?: number;
}) =>
queryOptions({
queryKey: ["issued-orders", "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.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/issued-orders/stats${qs ? `?${qs}` : ""}`,
);
return data.totals ?? [];
},
});
export const issuedOrderDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["issued-orders", id],

View File

@@ -82,6 +82,33 @@ export const orderListOptions = (filters: {
},
});
export interface CurrencyAmount {
amount: number;
currency: string;
}
export const orderStatsOptions = (filters: {
search?: string;
status?: string;
month?: number;
year?: number;
}) =>
queryOptions({
queryKey: ["orders", "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.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/orders/stats${qs ? `?${qs}` : ""}`,
);
return data.totals ?? [];
},
});
export const orderDetailOptions = (id: string | undefined) =>
queryOptions({
queryKey: ["orders", id],

View File

@@ -12,6 +12,7 @@ import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import {
formatCurrency,
formatMultiCurrency,
formatDate,
czechPlural,
todayLocalStr,
@@ -70,11 +71,6 @@ const MONTH_NAMES = [
"prosinec",
];
function formatMultiCurrency(amounts: CurrencyAmount[]): string {
if (!Array.isArray(amounts) || amounts.length === 0) return "0 Kč";
return amounts.map((a) => formatCurrency(a.amount, a.currency)).join(" · ");
}
function formatCzkWithDetail(
amounts: CurrencyAmount[],
totalCzk: number | null | undefined,

View File

@@ -956,7 +956,7 @@ export default function IssuedOrderDetail() {
)
}
>
Export PDF
Zobrazit objednávku
</Button>
)}
{/* ── Create mode: two-button save ── */}

View File

@@ -1,11 +1,16 @@
import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { formatCurrency, formatDate } from "../utils/formatters";
import {
formatCurrency,
formatDate,
formatMultiCurrency,
} from "../utils/formatters";
import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
@@ -13,6 +18,7 @@ import { useApiMutation } from "../lib/queries/mutations";
import apiFetch from "../utils/api";
import {
issuedOrderListOptions,
issuedOrderStatsOptions,
type IssuedOrder,
} from "../lib/queries/issued-orders";
import {
@@ -142,6 +148,17 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
}),
);
// 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(
issuedOrderStatsOptions({
search: debouncedSearch,
status: status || undefined,
month,
year,
}),
);
// Mark first load done in an effect (not during render) to avoid a
// render-phase ref mutation.
useEffect(() => {
@@ -363,6 +380,27 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
)
}
/>
{(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

@@ -7,12 +7,17 @@ import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate } from "../utils/formatters";
import {
formatCurrency,
formatDate,
formatMultiCurrency,
} from "../utils/formatters";
import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import {
orderListOptions,
orderStatsOptions,
orderNextNumberOptions,
} from "../lib/queries/orders";
import { offerCustomersOptions } from "../lib/queries/offers";
@@ -317,6 +322,17 @@ export default function OrdersReceived({
}),
);
// 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(
orderStatsOptions({
search: debouncedSearch,
status: status || undefined,
month,
year,
}),
);
// Mark first load done in an effect (not during render) to avoid a
// render-phase ref mutation.
useEffect(() => {
@@ -543,6 +559,27 @@ export default function OrdersReceived({
)
}
/>
{(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

@@ -17,6 +17,20 @@ export function formatCurrency(
}
}
/**
* Joins per-currency amounts into a single display string
* (e.g. `"1 234,00 Kč · 50,00 €"`). Typed structurally so it stays decoupled
* from the React Query layer's `CurrencyAmount`. Empty/invalid input →
* `formatCurrency(0, "CZK")`.
*/
export function formatMultiCurrency(
amounts: Array<{ amount: number; currency: string }>,
): string {
if (!Array.isArray(amounts) || amounts.length === 0)
return formatCurrency(0, "CZK");
return amounts.map((a) => formatCurrency(a.amount, a.currency)).join(" · ");
}
export function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return "—";
const d = new Date(dateStr);