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:
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -956,7 +956,7 @@ export default function IssuedOrderDetail() {
|
||||
)
|
||||
}
|
||||
>
|
||||
Export PDF
|
||||
Zobrazit objednávku
|
||||
</Button>
|
||||
)}
|
||||
{/* ── Create mode: two-button save ── */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user