feat(offers,invoices): per-currency 'Celkem' totals beneath the lists (consistent with orders)

Mirrors the orders totals exactly: /stats (offers) + /list-totals (issued +
received invoices) endpoints sum each doc's total per currency across the active
filter (reusing extracted where-builders so they stay in sync with the lists),
rendered as the identical 'Celkem: …' block via the shared formatMultiCurrency.
Existing invoice/received KPI stats untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 22:12:24 +02:00
parent 1e4dd1fbcc
commit 97101c4db7
11 changed files with 760 additions and 66 deletions

View File

@@ -37,15 +37,44 @@ const ALLOWED_SORT_FIELDS = [
"status",
];
interface ListOffersParams {
interface OfferFilterParams {
search?: string;
status?: string;
customer_id?: number;
}
interface ListOffersParams extends OfferFilterParams {
page: number;
limit: number;
skip: number;
sort: string;
order: "asc" | "desc";
search: string;
status?: string;
customer_id?: number;
}
export interface CurrencyAmount {
amount: number;
currency: string;
}
/**
* Shared `where` builder for the offers list and the per-currency stats
* aggregation, so both stay in sync (any new filter applies to both). Offers
* have NO month/year filter — the list filters by search + status + customer_id.
*/
function buildOfferWhere(params: OfferFilterParams): Record<string, unknown> {
const { search, status, customer_id } = params;
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (customer_id) where.customer_id = customer_id;
if (search) {
where.OR = [
{ quotation_number: { contains: search } },
{ project_code: { contains: search } },
{ customers: { name: { contains: search } } },
];
}
return where;
}
function enrichQuotation(q: any) {
@@ -74,20 +103,10 @@ function enrichQuotation(q: any) {
}
export async function listOffers(params: ListOffersParams) {
const { page, limit, skip, sort, order, search, status, customer_id } =
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 = [
{ quotation_number: { contains: search } },
{ project_code: { contains: search } },
{ customers: { name: { contains: search } } },
];
}
const where = buildOfferWhere(params);
const [quotations, total] = await Promise.all([
prisma.quotations.findMany({
@@ -128,6 +147,43 @@ export async function listOffers(params: ListOffersParams) {
return { data: enriched, total, page, limit };
}
/**
* Sum offer TOTAL (incl. VAT) per currency across the WHOLE filtered set (not a
* single page). Reuses `buildOfferWhere` so the filters track the list, and
* `enrichQuotation` so the per-offer math matches the list/detail exactly.
* Returns one entry per currency, rounded to 2dp, zero/empty totals dropped.
*/
export async function getOfferTotals(
params: OfferFilterParams,
): Promise<{ totals: CurrencyAmount[] }> {
const where = buildOfferWhere(params);
const quotations = await prisma.quotations.findMany({
where,
include: {
customers: { select: { id: true, name: true } },
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } },
},
});
const byCurrency: Record<string, number> = {};
for (const q of quotations) {
const { total, currency } = enrichQuotation(q);
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 getOffer(id: number) {
const quotation = await prisma.quotations.findUnique({
where: { id },