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)", () => {
|
it("renders the PO number, items, and both party names (PO direction)", () => {
|
||||||
const html = renderIssuedOrderHtml(
|
const html = renderIssuedOrderHtml(
|
||||||
order,
|
order,
|
||||||
@@ -283,6 +285,7 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
{ name: "Dodavatel s.r.o." },
|
{ name: "Dodavatel s.r.o." },
|
||||||
{ company_name: "Naše firma" },
|
{ company_name: "Naše firma" },
|
||||||
"cs",
|
"cs",
|
||||||
|
issuer,
|
||||||
);
|
);
|
||||||
expect(html).toContain("26720001");
|
expect(html).toContain("26720001");
|
||||||
expect(html).toContain("Materiál");
|
expect(html).toContain("Materiál");
|
||||||
@@ -297,8 +300,28 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("strips script tags from notes", () => {
|
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("<script>");
|
||||||
expect(html).not.toContain("alert(1)");
|
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) =>
|
export const issuedOrderDetailOptions = (id: string | undefined) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["issued-orders", id],
|
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) =>
|
export const orderDetailOptions = (id: string | undefined) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["orders", id],
|
queryKey: ["orders", id],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Forbidden from "../components/Forbidden";
|
|||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import {
|
import {
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
|
formatMultiCurrency,
|
||||||
formatDate,
|
formatDate,
|
||||||
czechPlural,
|
czechPlural,
|
||||||
todayLocalStr,
|
todayLocalStr,
|
||||||
@@ -70,11 +71,6 @@ const MONTH_NAMES = [
|
|||||||
"prosinec",
|
"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(
|
function formatCzkWithDetail(
|
||||||
amounts: CurrencyAmount[],
|
amounts: CurrencyAmount[],
|
||||||
totalCzk: number | null | undefined,
|
totalCzk: number | null | undefined,
|
||||||
|
|||||||
@@ -956,7 +956,7 @@ export default function IssuedOrderDetail() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Export PDF
|
Zobrazit objednávku
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* ── Create mode: two-button save ── */}
|
{/* ── Create mode: two-button save ── */}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate, Link as RouterLink } from "react-router-dom";
|
import { useNavigate, Link as RouterLink } from "react-router-dom";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { formatCurrency, formatDate } from "../utils/formatters";
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatDate,
|
||||||
|
formatMultiCurrency,
|
||||||
|
} from "../utils/formatters";
|
||||||
import useTableSort from "../hooks/useTableSort";
|
import useTableSort from "../hooks/useTableSort";
|
||||||
import useDebounce from "../hooks/useDebounce";
|
import useDebounce from "../hooks/useDebounce";
|
||||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||||
@@ -13,6 +18,7 @@ import { useApiMutation } from "../lib/queries/mutations";
|
|||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import {
|
import {
|
||||||
issuedOrderListOptions,
|
issuedOrderListOptions,
|
||||||
|
issuedOrderStatsOptions,
|
||||||
type IssuedOrder,
|
type IssuedOrder,
|
||||||
} from "../lib/queries/issued-orders";
|
} from "../lib/queries/issued-orders";
|
||||||
import {
|
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
|
// Mark first load done in an effect (not during render) to avoid a
|
||||||
// render-phase ref mutation.
|
// render-phase ref mutation.
|
||||||
useEffect(() => {
|
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
|
<Pagination
|
||||||
page={page}
|
page={page}
|
||||||
pageCount={pagination?.total_pages ?? 1}
|
pageCount={pagination?.total_pages ?? 1}
|
||||||
|
|||||||
@@ -7,12 +7,17 @@ import { useAlert } from "../context/AlertContext";
|
|||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { formatCurrency, formatDate } from "../utils/formatters";
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatDate,
|
||||||
|
formatMultiCurrency,
|
||||||
|
} from "../utils/formatters";
|
||||||
import useTableSort from "../hooks/useTableSort";
|
import useTableSort from "../hooks/useTableSort";
|
||||||
import useDebounce from "../hooks/useDebounce";
|
import useDebounce from "../hooks/useDebounce";
|
||||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||||
import {
|
import {
|
||||||
orderListOptions,
|
orderListOptions,
|
||||||
|
orderStatsOptions,
|
||||||
orderNextNumberOptions,
|
orderNextNumberOptions,
|
||||||
} from "../lib/queries/orders";
|
} from "../lib/queries/orders";
|
||||||
import { offerCustomersOptions } from "../lib/queries/offers";
|
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
|
// Mark first load done in an effect (not during render) to avoid a
|
||||||
// render-phase ref mutation.
|
// render-phase ref mutation.
|
||||||
useEffect(() => {
|
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
|
<Pagination
|
||||||
page={page}
|
page={page}
|
||||||
pageCount={pagination?.total_pages ?? 1}
|
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 {
|
export function formatDate(dateStr: string | null | undefined): string {
|
||||||
if (!dateStr) return "—";
|
if (!dateStr) return "—";
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
delivery_terms: "Dodací podmínky:",
|
delivery_terms: "Dodací podmínky:",
|
||||||
payment_terms: "Platební podmínky:",
|
payment_terms: "Platební podmínky:",
|
||||||
issued_by: "Vystavil:",
|
issued_by: "Vystavil:",
|
||||||
approved_by: "Schválil:",
|
email: "E-mail:",
|
||||||
ico: "IČ: ",
|
ico: "IČ: ",
|
||||||
dic: "DIČ: ",
|
dic: "DIČ: ",
|
||||||
},
|
},
|
||||||
@@ -215,7 +215,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
delivery_terms: "Delivery terms:",
|
delivery_terms: "Delivery terms:",
|
||||||
payment_terms: "Payment terms:",
|
payment_terms: "Payment terms:",
|
||||||
issued_by: "Issued by:",
|
issued_by: "Issued by:",
|
||||||
approved_by: "Approved by:",
|
email: "E-mail:",
|
||||||
ico: "Reg. No.: ",
|
ico: "Reg. No.: ",
|
||||||
dic: "Tax ID: ",
|
dic: "Tax ID: ",
|
||||||
},
|
},
|
||||||
@@ -249,6 +249,7 @@ export function renderIssuedOrderHtml(
|
|||||||
customer: Record<string, unknown> | null,
|
customer: Record<string, unknown> | null,
|
||||||
settings: Record<string, unknown> | null,
|
settings: Record<string, unknown> | null,
|
||||||
lang: Lang,
|
lang: Lang,
|
||||||
|
issuer: { name: string; email: string },
|
||||||
): string {
|
): string {
|
||||||
const t = translations[lang];
|
const t = translations[lang];
|
||||||
const applyVat = order.apply_vat !== false;
|
const applyVat = order.apply_vat !== false;
|
||||||
@@ -642,21 +643,6 @@ export function renderIssuedOrderHtml(
|
|||||||
line-height: 1.3;
|
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 */
|
/* Poznamky */
|
||||||
.invoice-notes {
|
.invoice-notes {
|
||||||
margin-top: 4mm;
|
margin-top: 4mm;
|
||||||
@@ -798,18 +784,9 @@ ${indentCSS}
|
|||||||
</div><!-- /.invoice-content -->
|
</div><!-- /.invoice-content -->
|
||||||
<div class="invoice-footer">
|
<div class="invoice-footer">
|
||||||
|
|
||||||
<!-- Vystavil -->
|
<!-- Vystavil (jmeno + e-mail prihlaseneho uzivatele) -->
|
||||||
${
|
<div class="issued-by"><span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuer.name)}</div>
|
||||||
order.issued_by
|
${issuer.email ? `<div class="issued-by"><span class="lbl">${escapeHtml(t.email)}</span> ${escapeHtml(issuer.email)}</div>` : ""}
|
||||||
? `<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>
|
|
||||||
|
|
||||||
</div><!-- /.invoice-footer -->
|
</div><!-- /.invoice-footer -->
|
||||||
</div><!-- /.invoice-page -->
|
</div><!-- /.invoice-page -->
|
||||||
@@ -854,12 +831,21 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
|||||||
unknown
|
unknown
|
||||||
> | null;
|
> | 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(
|
const html = renderIssuedOrderHtml(
|
||||||
order,
|
order,
|
||||||
items,
|
items,
|
||||||
customer,
|
customer,
|
||||||
settings,
|
settings,
|
||||||
lang,
|
lang,
|
||||||
|
issuer,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ?save=1 → archive the PDF to NAS instead of returning it (mirrors
|
// ?save=1 → archive the PDF to NAS instead of returning it (mirrors
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "../../schemas/issued-orders.schema";
|
} from "../../schemas/issued-orders.schema";
|
||||||
import {
|
import {
|
||||||
listIssuedOrders,
|
listIssuedOrders,
|
||||||
|
getIssuedOrderTotals,
|
||||||
getIssuedOrder,
|
getIssuedOrder,
|
||||||
createIssuedOrder,
|
createIssuedOrder,
|
||||||
updateIssuedOrder,
|
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
|
// GET /api/admin/issued-orders/:id
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "../../schemas/orders.schema";
|
} from "../../schemas/orders.schema";
|
||||||
import {
|
import {
|
||||||
listOrders,
|
listOrders,
|
||||||
|
getOrderTotals,
|
||||||
getOrder,
|
getOrder,
|
||||||
getOrderAttachment,
|
getOrderAttachment,
|
||||||
createOrderFromQuotation,
|
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 } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requirePermission("orders.view") },
|
{ preHandler: requirePermission("orders.view") },
|
||||||
|
|||||||
@@ -37,12 +37,7 @@ export interface IssuedOrderInput {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListIssuedOrdersParams {
|
interface IssuedOrderFilterParams {
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
skip: number;
|
|
||||||
sort: string;
|
|
||||||
order: string;
|
|
||||||
search?: string;
|
search?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
customer_id?: number;
|
customer_id?: number;
|
||||||
@@ -50,6 +45,46 @@ interface ListIssuedOrdersParams {
|
|||||||
year?: number;
|
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[]> = {
|
const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||||
draft: ["sent", "cancelled"],
|
draft: ["sent", "cancelled"],
|
||||||
sent: ["confirmed", "cancelled"],
|
sent: ["confirmed", "cancelled"],
|
||||||
@@ -95,35 +130,10 @@ export function computeIssuedOrderTotals(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
||||||
const {
|
const { page, limit, skip, sort, order } = params;
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
skip,
|
|
||||||
sort,
|
|
||||||
order,
|
|
||||||
search,
|
|
||||||
status,
|
|
||||||
customer_id,
|
|
||||||
month,
|
|
||||||
year,
|
|
||||||
} = params;
|
|
||||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const where = buildIssuedOrderWhere(params);
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize the direction so an unexpected value can't reach Prisma at runtime.
|
// Normalize the direction so an unexpected value can't reach Prisma at runtime.
|
||||||
const dir: "asc" | "desc" = order === "asc" ? "asc" : "desc";
|
const dir: "asc" | "desc" = order === "asc" ? "asc" : "desc";
|
||||||
@@ -165,6 +175,44 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
|||||||
return { data: enriched, total, page, limit };
|
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) {
|
export async function getIssuedOrder(id: number) {
|
||||||
const order = await prisma.issued_orders.findUnique({
|
const order = await prisma.issued_orders.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@@ -102,12 +102,7 @@ function enrichOrder(o: any) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListOrdersParams {
|
interface OrderFilterParams {
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
skip: number;
|
|
||||||
sort: string;
|
|
||||||
order: "asc" | "desc";
|
|
||||||
status?: string;
|
status?: string;
|
||||||
customer_id?: number;
|
customer_id?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -115,15 +110,24 @@ interface ListOrdersParams {
|
|||||||
year?: number;
|
year?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listOrders(params: ListOrdersParams) {
|
interface ListOrdersParams extends OrderFilterParams {
|
||||||
const { page, limit, skip, order, search, month, year } = params;
|
page: number;
|
||||||
const sortField = ORDER_ALLOWED_SORT_FIELDS.includes(params.sort)
|
limit: number;
|
||||||
? params.sort
|
skip: number;
|
||||||
: "id";
|
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> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (params.status) where.status = params.status;
|
if (status) where.status = status;
|
||||||
if (params.customer_id) where.customer_id = params.customer_id;
|
if (customer_id) where.customer_id = customer_id;
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ order_number: { contains: search } },
|
{ order_number: { contains: search } },
|
||||||
@@ -136,6 +140,16 @@ export async function listOrders(params: ListOrdersParams) {
|
|||||||
const to = new Date(year, month, 1);
|
const to = new Date(year, month, 1);
|
||||||
where.created_at = { gte: from, lt: to };
|
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([
|
const [orders, total] = await Promise.all([
|
||||||
prisma.orders.findMany({
|
prisma.orders.findMany({
|
||||||
@@ -163,6 +177,54 @@ export async function listOrders(params: ListOrdersParams) {
|
|||||||
return { data: enriched, total, page, limit };
|
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) {
|
export async function getOrder(id: number) {
|
||||||
const order = await prisma.orders.findUnique({
|
const order = await prisma.orders.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
Reference in New Issue
Block a user