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

@@ -104,17 +104,53 @@ function computeMoney<T>(
return { subtotal, vat };
}
interface ListInvoicesParams {
interface InvoiceFilterParams {
search?: string;
status?: string;
customer_id?: number;
month?: number;
year?: number;
}
interface ListInvoicesParams extends InvoiceFilterParams {
page: number;
limit: number;
skip: number;
sort: string;
order: "asc" | "desc";
search: string;
status?: string;
customer_id?: number;
month?: number;
year?: number;
}
export interface CurrencyAmount {
amount: number;
currency: string;
}
/**
* Shared `where` builder for the issued-invoices list and the per-currency
* list-totals aggregation, so both stay in sync (any new filter applies to
* both). month/year filter on issue_date (matching the list).
*/
function buildInvoiceWhere(
params: InvoiceFilterParams,
): Record<string, unknown> {
const { status, customer_id, month, year, search } = params;
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (customer_id) where.customer_id = customer_id;
if (month && year) {
const from = new Date(year, month - 1, 1);
const to = new Date(year, month, 1);
where.issue_date = { gte: from, lt: to };
}
if (search) {
where.OR = [
{ invoice_number: { contains: search } },
{ customers: { name: { contains: search } } },
{ customers: { company_id: { contains: search } } },
];
}
return where;
}
function computeInvoiceTotals(
@@ -159,35 +195,10 @@ export async function markOverdueInvoices() {
}
export async function listInvoices(params: ListInvoicesParams) {
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 (month && year) {
const from = new Date(year, month - 1, 1);
const to = new Date(year, month, 1);
where.issue_date = { gte: from, lt: to };
}
if (search) {
where.OR = [
{ invoice_number: { contains: search } },
{ customers: { name: { contains: search } } },
{ customers: { company_id: { contains: search } } },
];
}
const where = buildInvoiceWhere(params);
const orderBy: Record<string, string> = { [sortField]: order };
@@ -225,6 +236,47 @@ export async function listInvoices(params: ListInvoicesParams) {
return { data: enriched, total, page, limit };
}
/**
* Sum issued-invoice TOTAL (incl. VAT) per currency across the WHOLE filtered
* set (not a single page). Reuses `buildInvoiceWhere` so the filters track the
* list, and `computeInvoiceTotals` so the per-invoice math matches the
* list/detail exactly. Returns one entry per currency, rounded to 2dp,
* zero/empty totals dropped.
*
* NOTE: This is SEPARATE from `getInvoiceStats` (the monthly KPI cards). That
* function is untouched — this only sums the list's visible rows per currency.
*/
export async function getInvoiceListTotals(
params: InvoiceFilterParams,
): Promise<{ totals: CurrencyAmount[] }> {
const where = buildInvoiceWhere(params);
const invoices = await prisma.invoices.findMany({
where,
include: { invoice_items: true },
});
const byCurrency: Record<string, number> = {};
for (const inv of invoices) {
const { total } = computeInvoiceTotals(
inv.invoice_items,
inv.apply_vat,
inv.vat_rate,
);
const cur = inv.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 {
generateInvoiceNumber as getNextInvoiceNumberFormatted,
previewInvoiceNumber as getNextInvoiceNumberPreview,

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