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:
@@ -276,6 +276,8 @@ describe("renderIssuedOrderHtml", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const issuer = { name: "Jan Novák", email: "jan.novak@firma.cz" };
|
||||
|
||||
it("renders the PO number, items, and both party names (PO direction)", () => {
|
||||
const html = renderIssuedOrderHtml(
|
||||
order,
|
||||
@@ -283,6 +285,7 @@ describe("renderIssuedOrderHtml", () => {
|
||||
{ name: "Dodavatel s.r.o." },
|
||||
{ company_name: "Naše firma" },
|
||||
"cs",
|
||||
issuer,
|
||||
);
|
||||
expect(html).toContain("26720001");
|
||||
expect(html).toContain("Materiál");
|
||||
@@ -297,8 +300,28 @@ describe("renderIssuedOrderHtml", () => {
|
||||
});
|
||||
|
||||
it("strips script tags from notes", () => {
|
||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs");
|
||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).not.toContain("alert(1)");
|
||||
});
|
||||
|
||||
it("footer shows the logged-in user's name + e-mail, not the Schválil column", () => {
|
||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||
// New footer: Vystavil <name> + E-mail <email> from authData.
|
||||
expect(html).toContain("Jan Novák");
|
||||
expect(html).toContain("jan.novak@firma.cz");
|
||||
expect(html).toContain("E-mail:");
|
||||
// The old two-column signature footer is gone.
|
||||
expect(html).not.toContain("footer-row");
|
||||
expect(html).not.toContain("Schválil:");
|
||||
});
|
||||
|
||||
it("omits the e-mail line when the issuer has no e-mail", () => {
|
||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", {
|
||||
name: "Jan Novák",
|
||||
email: "",
|
||||
});
|
||||
expect(html).toContain("Jan Novák");
|
||||
expect(html).not.toContain("E-mail:");
|
||||
});
|
||||
});
|
||||
|
||||
245
src/__tests__/order-totals.test.ts
Normal file
245
src/__tests__/order-totals.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { createOrder, getOrderTotals } from "../services/orders.service";
|
||||
import {
|
||||
createIssuedOrder,
|
||||
getIssuedOrderTotals,
|
||||
} from "../services/issued-orders.service";
|
||||
|
||||
// Per-currency total aggregation for both order lists. These hit the real
|
||||
// `app_test` DB via the service layer (suite convention) and prove the /stats
|
||||
// endpoints sum order TOTAL (incl. VAT) per currency across the WHOLE filtered
|
||||
// set, and that the where (month/year + status) is respected.
|
||||
//
|
||||
// A far-future month/year is used so seeded/other-test rows can't fall in the
|
||||
// window and skew the deterministic sums (mirrors drafts-aggregation.test.ts).
|
||||
|
||||
const STATS_YEAR = 2097;
|
||||
const STATS_MONTH = 8; // -> window [2097-08-01, 2097-09-01)
|
||||
|
||||
const createdOrderIds: number[] = [];
|
||||
const createdIssuedIds: number[] = [];
|
||||
const createdCustomerIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of createdOrderIds)
|
||||
await prisma.orders.deleteMany({ where: { id } });
|
||||
for (const id of createdIssuedIds)
|
||||
await prisma.issued_orders.deleteMany({ where: { id } });
|
||||
for (const id of createdCustomerIds)
|
||||
await prisma.customers.deleteMany({ where: { id } });
|
||||
createdOrderIds.length = 0;
|
||||
createdIssuedIds.length = 0;
|
||||
createdCustomerIds.length = 0;
|
||||
// Finalized orders consume the shared/issued sequences — reset so we don't
|
||||
// leak a consumed number into sibling test files.
|
||||
await prisma.number_sequences.deleteMany({
|
||||
where: { type: { in: ["shared", "issued_order", "project"] } },
|
||||
});
|
||||
});
|
||||
|
||||
function amountFor(
|
||||
totals: { currency: string; amount: number }[],
|
||||
currency: string,
|
||||
): number | undefined {
|
||||
return totals.find((t) => t.currency === currency)?.amount;
|
||||
}
|
||||
|
||||
describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
||||
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
|
||||
// Two CZK orders + one EUR, all in the same far-future month. 21% VAT,
|
||||
// applied on the whole subtotal (enrichOrder 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 createOrder({
|
||||
status: "prijata",
|
||||
currency,
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
create_project: false,
|
||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||
});
|
||||
if (!("id" in res)) throw new Error(JSON.stringify(res));
|
||||
createdOrderIds.push(res.id!);
|
||||
// created_at is auto-managed; pin it into the stats window so the
|
||||
// month/year filter is deterministic.
|
||||
await prisma.orders.update({
|
||||
where: { id: res.id },
|
||||
data: {
|
||||
created_at: new Date(STATS_YEAR, STATS_MONTH - 1, 15, 12, 0, 0),
|
||||
},
|
||||
});
|
||||
return res.id!;
|
||||
};
|
||||
|
||||
await mk("CZK", 2, 1000);
|
||||
await mk("CZK", 1, 500);
|
||||
await mk("EUR", 3, 100);
|
||||
|
||||
const { totals } = await getOrderTotals({
|
||||
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 mk = async (status: string, monthOffset: number) => {
|
||||
const res = await createOrder({
|
||||
status,
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
create_project: false,
|
||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||
});
|
||||
if (!("id" in res)) throw new Error(JSON.stringify(res));
|
||||
createdOrderIds.push(res.id!);
|
||||
await prisma.orders.update({
|
||||
where: { id: res.id },
|
||||
data: {
|
||||
created_at: new Date(
|
||||
STATS_YEAR,
|
||||
STATS_MONTH - 1 + monthOffset,
|
||||
15,
|
||||
12,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
},
|
||||
});
|
||||
return res.id!;
|
||||
};
|
||||
|
||||
// One 'prijata' in the target month, one 'stornovana' in the target month,
|
||||
// one 'prijata' in the NEXT month.
|
||||
await mk("prijata", 0);
|
||||
await mk("stornovana", 0);
|
||||
await mk("prijata", 1);
|
||||
|
||||
// Whole month: both same-month orders count -> 2 x 1210 = 2420.
|
||||
const whole = await getOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2420);
|
||||
|
||||
// Status filter narrows to the single 'prijata' in the target month -> 1210.
|
||||
const onlyPrijata = await getOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
status: "prijata",
|
||||
});
|
||||
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1210);
|
||||
|
||||
// Next month sees only the one 'prijata' there -> 1210.
|
||||
const nextMonth = await getOrderTotals({
|
||||
month: STATS_MONTH + 1,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(nextMonth.totals, "CZK")).toBe(1210);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () => {
|
||||
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
|
||||
// order_date is settable at create, so no post-create pin needed. VAT is
|
||||
// applied per-line (computeIssuedOrderTotals) but with a single line per
|
||||
// order here the result matches the on-the-whole subtotal math.
|
||||
// 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
|
||||
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
||||
const mk = async (currency: string, qty: number, price: number) => {
|
||||
const o = await createIssuedOrder({
|
||||
currency,
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
order_date: dateStr,
|
||||
items: [
|
||||
{ description: "X", quantity: qty, unit_price: price, vat_rate: 21 },
|
||||
],
|
||||
});
|
||||
createdIssuedIds.push(o.id);
|
||||
return o.id;
|
||||
};
|
||||
|
||||
await mk("CZK", 2, 1000);
|
||||
await mk("CZK", 1, 500);
|
||||
await mk("EUR", 3, 100);
|
||||
|
||||
const { totals } = await getIssuedOrderTotals({
|
||||
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 inMonth = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
||||
const nextMonth = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
|
||||
|
||||
// Target month: one draft, one already-sent (both 1210). Next month: one.
|
||||
const draft = await createIssuedOrder({
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
order_date: inMonth,
|
||||
items: [
|
||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||
],
|
||||
});
|
||||
createdIssuedIds.push(draft.id);
|
||||
|
||||
const sent = await createIssuedOrder({
|
||||
status: "sent",
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
order_date: inMonth,
|
||||
items: [
|
||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||
],
|
||||
});
|
||||
createdIssuedIds.push(sent.id);
|
||||
|
||||
const next = await createIssuedOrder({
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
order_date: nextMonth,
|
||||
items: [
|
||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||
],
|
||||
});
|
||||
createdIssuedIds.push(next.id);
|
||||
|
||||
// Whole target month: both count -> 2 x 1210 = 2420.
|
||||
const whole = await getIssuedOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(whole.totals, "CZK")).toBe(2420);
|
||||
|
||||
// Status filter narrows to the single 'sent' in the target month -> 1210.
|
||||
const onlySent = await getIssuedOrderTotals({
|
||||
month: STATS_MONTH,
|
||||
year: STATS_YEAR,
|
||||
status: "sent",
|
||||
});
|
||||
expect(amountFor(onlySent.totals, "CZK")).toBe(1210);
|
||||
|
||||
// Next month sees only the one order there -> 1210.
|
||||
const nextStats = await getIssuedOrderTotals({
|
||||
month: STATS_MONTH + 1,
|
||||
year: STATS_YEAR,
|
||||
});
|
||||
expect(amountFor(nextStats.totals, "CZK")).toBe(1210);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -187,7 +187,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
delivery_terms: "Dodací podmínky:",
|
||||
payment_terms: "Platební podmínky:",
|
||||
issued_by: "Vystavil:",
|
||||
approved_by: "Schválil:",
|
||||
email: "E-mail:",
|
||||
ico: "IČ: ",
|
||||
dic: "DIČ: ",
|
||||
},
|
||||
@@ -215,7 +215,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
delivery_terms: "Delivery terms:",
|
||||
payment_terms: "Payment terms:",
|
||||
issued_by: "Issued by:",
|
||||
approved_by: "Approved by:",
|
||||
email: "E-mail:",
|
||||
ico: "Reg. No.: ",
|
||||
dic: "Tax ID: ",
|
||||
},
|
||||
@@ -249,6 +249,7 @@ export function renderIssuedOrderHtml(
|
||||
customer: Record<string, unknown> | null,
|
||||
settings: Record<string, unknown> | null,
|
||||
lang: Lang,
|
||||
issuer: { name: string; email: string },
|
||||
): string {
|
||||
const t = translations[lang];
|
||||
const applyVat = order.apply_vat !== false;
|
||||
@@ -642,21 +643,6 @@ export function renderIssuedOrderHtml(
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Prevzal / razitko */
|
||||
.footer-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 4mm;
|
||||
font-size: 9pt;
|
||||
border-top: 0.5pt solid #aaa;
|
||||
padding-top: 2mm;
|
||||
min-height: 15mm;
|
||||
}
|
||||
.footer-row .col {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Poznamky */
|
||||
.invoice-notes {
|
||||
margin-top: 4mm;
|
||||
@@ -798,18 +784,9 @@ ${indentCSS}
|
||||
</div><!-- /.invoice-content -->
|
||||
<div class="invoice-footer">
|
||||
|
||||
<!-- Vystavil -->
|
||||
${
|
||||
order.issued_by
|
||||
? `<div class="issued-by"><span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(order.issued_by)}</div>`
|
||||
: ""
|
||||
}
|
||||
|
||||
<!-- Vystavil / Schvalil -->
|
||||
<div class="footer-row">
|
||||
<div class="col">${escapeHtml(t.issued_by)}</div>
|
||||
<div class="col" style="text-align:right">${escapeHtml(t.approved_by)}</div>
|
||||
</div>
|
||||
<!-- Vystavil (jmeno + e-mail prihlaseneho uzivatele) -->
|
||||
<div class="issued-by"><span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuer.name)}</div>
|
||||
${issuer.email ? `<div class="issued-by"><span class="lbl">${escapeHtml(t.email)}</span> ${escapeHtml(issuer.email)}</div>` : ""}
|
||||
|
||||
</div><!-- /.invoice-footer -->
|
||||
</div><!-- /.invoice-page -->
|
||||
@@ -854,12 +831,21 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
// Footer issuer = the logged-in user (there is no user FK on the
|
||||
// order). The route runs under requirePermission("orders.view") so
|
||||
// authData is always present here. Mirrors orders-pdf.ts userName.
|
||||
const issuer = {
|
||||
name: `${request.authData?.firstName ?? ""} ${request.authData?.lastName ?? ""}`.trim(),
|
||||
email: request.authData?.email ?? "",
|
||||
};
|
||||
|
||||
const html = renderIssuedOrderHtml(
|
||||
order,
|
||||
items,
|
||||
customer,
|
||||
settings,
|
||||
lang,
|
||||
issuer,
|
||||
);
|
||||
|
||||
// ?save=1 → archive the PDF to NAS instead of returning it (mirrors
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../../schemas/issued-orders.schema";
|
||||
import {
|
||||
listIssuedOrders,
|
||||
getIssuedOrderTotals,
|
||||
getIssuedOrder,
|
||||
createIssuedOrder,
|
||||
updateIssuedOrder,
|
||||
@@ -54,6 +55,25 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/issued-orders/stats — per-currency total (incl. VAT) summed
|
||||
// over the WHOLE filtered set (not one page). Registered BEFORE "/:id" so the
|
||||
// literal "stats" path isn't captured as an order id.
|
||||
fastify.get(
|
||||
"/stats",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const result = await getIssuedOrderTotals({
|
||||
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/issued-orders/:id
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../../schemas/orders.schema";
|
||||
import {
|
||||
listOrders,
|
||||
getOrderTotals,
|
||||
getOrder,
|
||||
getOrderAttachment,
|
||||
createOrderFromQuotation,
|
||||
@@ -68,6 +69,25 @@ export default async function ordersRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/orders/stats — per-currency total (incl. VAT) summed over the
|
||||
// WHOLE filtered set (not one page). Must be registered BEFORE "/:id" so the
|
||||
// literal "stats" path isn't captured as an order id.
|
||||
fastify.get(
|
||||
"/stats",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const result = await getOrderTotals({
|
||||
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 });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("orders.view") },
|
||||
|
||||
@@ -37,12 +37,7 @@ export interface IssuedOrderInput {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ListIssuedOrdersParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
skip: number;
|
||||
sort: string;
|
||||
order: string;
|
||||
interface IssuedOrderFilterParams {
|
||||
search?: string;
|
||||
status?: string;
|
||||
customer_id?: number;
|
||||
@@ -50,6 +45,46 @@ interface ListIssuedOrdersParams {
|
||||
year?: number;
|
||||
}
|
||||
|
||||
interface ListIssuedOrdersParams extends IssuedOrderFilterParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
skip: number;
|
||||
sort: string;
|
||||
order: string;
|
||||
}
|
||||
|
||||
export interface CurrencyAmount {
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared `where` builder for the issued-order list and the per-currency stats
|
||||
* aggregation, so both stay in sync. month/year filter on order_date (matching
|
||||
* the list).
|
||||
*/
|
||||
function buildIssuedOrderWhere(
|
||||
params: IssuedOrderFilterParams,
|
||||
): Record<string, unknown> {
|
||||
const { search, status, customer_id, month, year } = params;
|
||||
const where: Record<string, unknown> = {};
|
||||
if (status) where.status = status;
|
||||
if (customer_id) where.customer_id = customer_id;
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ po_number: { contains: search } },
|
||||
{ customers: { name: { contains: search } } },
|
||||
{ customers: { company_id: { contains: search } } },
|
||||
];
|
||||
}
|
||||
if (month && year) {
|
||||
const from = new Date(year, month - 1, 1);
|
||||
const to = new Date(year, month, 1);
|
||||
where.order_date = { gte: from, lt: to };
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||
draft: ["sent", "cancelled"],
|
||||
sent: ["confirmed", "cancelled"],
|
||||
@@ -95,35 +130,10 @@ export function computeIssuedOrderTotals(
|
||||
}
|
||||
|
||||
export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
||||
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 (search) {
|
||||
where.OR = [
|
||||
{ po_number: { contains: search } },
|
||||
{ customers: { name: { contains: search } } },
|
||||
{ customers: { company_id: { contains: search } } },
|
||||
];
|
||||
}
|
||||
if (month && year) {
|
||||
const from = new Date(year, month - 1, 1);
|
||||
const to = new Date(year, month, 1);
|
||||
where.order_date = { gte: from, lt: to };
|
||||
}
|
||||
const where = buildIssuedOrderWhere(params);
|
||||
|
||||
// Normalize the direction so an unexpected value can't reach Prisma at runtime.
|
||||
const dir: "asc" | "desc" = order === "asc" ? "asc" : "desc";
|
||||
@@ -165,6 +175,44 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
||||
return { data: enriched, total, page, limit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum issued-order TOTAL (incl. VAT) per currency across the WHOLE filtered set
|
||||
* (not a single page). Reuses `buildIssuedOrderWhere` so filters track the list,
|
||||
* and `computeIssuedOrderTotals` so per-order math matches the list/detail.
|
||||
* Currency defaults to "CZK" when the column is null. Returns one entry per
|
||||
* currency, rounded to 2dp, zero/empty totals dropped.
|
||||
*/
|
||||
export async function getIssuedOrderTotals(
|
||||
params: IssuedOrderFilterParams,
|
||||
): Promise<{ totals: CurrencyAmount[] }> {
|
||||
const where = buildIssuedOrderWhere(params);
|
||||
|
||||
const rows = await prisma.issued_orders.findMany({
|
||||
where,
|
||||
include: { issued_order_items: true },
|
||||
});
|
||||
|
||||
const byCurrency: Record<string, number> = {};
|
||||
for (const o of rows) {
|
||||
const { total } = computeIssuedOrderTotals(
|
||||
o.issued_order_items,
|
||||
o.apply_vat,
|
||||
o.vat_rate,
|
||||
);
|
||||
const cur = o.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 getIssuedOrder(id: number) {
|
||||
const order = await prisma.issued_orders.findUnique({
|
||||
where: { id },
|
||||
|
||||
@@ -102,12 +102,7 @@ function enrichOrder(o: any) {
|
||||
};
|
||||
}
|
||||
|
||||
interface ListOrdersParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
skip: number;
|
||||
sort: string;
|
||||
order: "asc" | "desc";
|
||||
interface OrderFilterParams {
|
||||
status?: string;
|
||||
customer_id?: number;
|
||||
search?: string;
|
||||
@@ -115,15 +110,24 @@ interface ListOrdersParams {
|
||||
year?: number;
|
||||
}
|
||||
|
||||
export async function listOrders(params: ListOrdersParams) {
|
||||
const { page, limit, skip, order, search, month, year } = params;
|
||||
const sortField = ORDER_ALLOWED_SORT_FIELDS.includes(params.sort)
|
||||
? params.sort
|
||||
: "id";
|
||||
interface ListOrdersParams extends OrderFilterParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
skip: number;
|
||||
sort: string;
|
||||
order: "asc" | "desc";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared `where` builder for the orders list and the per-currency stats
|
||||
* aggregation, so both stay in sync (any new filter applies to both). month/year
|
||||
* filters on created_at (matching the list).
|
||||
*/
|
||||
function buildOrderWhere(params: OrderFilterParams): Record<string, unknown> {
|
||||
const { status, customer_id, search, month, year } = params;
|
||||
const where: Record<string, unknown> = {};
|
||||
if (params.status) where.status = params.status;
|
||||
if (params.customer_id) where.customer_id = params.customer_id;
|
||||
if (status) where.status = status;
|
||||
if (customer_id) where.customer_id = customer_id;
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ order_number: { contains: search } },
|
||||
@@ -136,6 +140,16 @@ export async function listOrders(params: ListOrdersParams) {
|
||||
const to = new Date(year, month, 1);
|
||||
where.created_at = { gte: from, lt: to };
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
export async function listOrders(params: ListOrdersParams) {
|
||||
const { page, limit, skip, order } = params;
|
||||
const sortField = ORDER_ALLOWED_SORT_FIELDS.includes(params.sort)
|
||||
? params.sort
|
||||
: "id";
|
||||
|
||||
const where = buildOrderWhere(params);
|
||||
|
||||
const [orders, total] = await Promise.all([
|
||||
prisma.orders.findMany({
|
||||
@@ -163,6 +177,54 @@ export async function listOrders(params: ListOrdersParams) {
|
||||
return { data: enriched, total, page, limit };
|
||||
}
|
||||
|
||||
export interface CurrencyAmount {
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum order TOTAL (incl. VAT) per currency across the WHOLE filtered set (not a
|
||||
* single page). Reuses `buildOrderWhere` so the filters track the list, and
|
||||
* `enrichOrder` so the per-order math matches the list/detail exactly. Returns
|
||||
* one entry per currency, rounded to 2dp, zero/empty totals dropped.
|
||||
*/
|
||||
export async function getOrderTotals(
|
||||
params: OrderFilterParams,
|
||||
): Promise<{ totals: CurrencyAmount[] }> {
|
||||
const where = buildOrderWhere(params);
|
||||
|
||||
const orders = await prisma.orders.findMany({
|
||||
where,
|
||||
include: {
|
||||
customers: { select: { id: true, name: true } },
|
||||
order_items: { orderBy: { position: "asc" } },
|
||||
order_sections: { orderBy: { position: "asc" } },
|
||||
quotations: { select: { quotation_number: true, project_code: true } },
|
||||
invoices: {
|
||||
select: { id: true, invoice_number: true },
|
||||
take: 1,
|
||||
orderBy: { id: "desc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const byCurrency: Record<string, number> = {};
|
||||
for (const o of orders) {
|
||||
const { total, currency } = enrichOrder(o);
|
||||
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 getOrder(id: number) {
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id },
|
||||
|
||||
Reference in New Issue
Block a user