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

@@ -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:");
});
});

View 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);
});
});

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

View File

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

View File

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

View File

@@ -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") },

View File

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

View File

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