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:
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user