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:
@@ -41,6 +41,71 @@ const ALLOWED_SORT_FIELDS = [
|
||||
"created_at",
|
||||
];
|
||||
|
||||
interface ReceivedInvoiceFilterParams {
|
||||
year?: number;
|
||||
month?: number;
|
||||
status?: string;
|
||||
supplier_name?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared `where` builder for the received-invoices list and the per-currency
|
||||
* list-totals aggregation, so both stay in sync. The list filters by
|
||||
* year/month/status/supplier_name/search.
|
||||
*/
|
||||
export function buildReceivedWhere(
|
||||
params: ReceivedInvoiceFilterParams,
|
||||
): Record<string, unknown> {
|
||||
const { year, month, status, supplier_name, search } = params;
|
||||
const where: Record<string, unknown> = {};
|
||||
if (year) where.year = year;
|
||||
if (month) where.month = month;
|
||||
if (status) where.status = status;
|
||||
if (supplier_name) where.supplier_name = { contains: supplier_name };
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ supplier_name: { contains: search } },
|
||||
{ invoice_number: { contains: search } },
|
||||
{ description: { contains: search } },
|
||||
];
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum received-invoice GROSS `amount` per currency over the WHOLE filtered set
|
||||
* (not one page), reusing `buildReceivedWhere` so the totals track the list.
|
||||
* `amount` is VAT-inclusive (GROSS), so it is summed directly. Returns one
|
||||
* entry per currency, rounded to 2dp, zero/empty totals dropped. Exported so
|
||||
* the route and the totals test share one implementation.
|
||||
*/
|
||||
export async function getReceivedInvoiceListTotals(
|
||||
params: ReceivedInvoiceFilterParams,
|
||||
): Promise<{ totals: Array<{ amount: number; currency: string }> }> {
|
||||
const where = buildReceivedWhere(params);
|
||||
|
||||
const invoices = await prisma.received_invoices.findMany({
|
||||
where,
|
||||
select: { amount: true, currency: true },
|
||||
});
|
||||
|
||||
const byCurrency: Record<string, number> = {};
|
||||
for (const inv of invoices) {
|
||||
const cur = inv.currency || "CZK";
|
||||
byCurrency[cur] = (byCurrency[cur] || 0) + (Number(inv.amount) || 0);
|
||||
}
|
||||
|
||||
const totals = Object.entries(byCurrency)
|
||||
.map(([currency, amount]) => ({
|
||||
currency,
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
}))
|
||||
.filter((t) => t.amount > 0);
|
||||
|
||||
return { totals };
|
||||
}
|
||||
|
||||
export default async function receivedInvoicesRoutes(
|
||||
fastify: FastifyInstance,
|
||||
): Promise<void> {
|
||||
@@ -53,22 +118,15 @@ export default async function receivedInvoicesRoutes(
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const { page, limit, skip, order } = parsePagination(query);
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (query.year) where.year = Number(query.year);
|
||||
if (query.month) where.month = Number(query.month);
|
||||
if (query.status) where.status = String(query.status);
|
||||
if (query.supplier_name)
|
||||
where.supplier_name = { contains: String(query.supplier_name) };
|
||||
|
||||
// Search across supplier_name, invoice_number, description
|
||||
if (query.search) {
|
||||
const search = String(query.search);
|
||||
where.OR = [
|
||||
{ supplier_name: { contains: search } },
|
||||
{ invoice_number: { contains: search } },
|
||||
{ description: { contains: search } },
|
||||
];
|
||||
}
|
||||
const where = buildReceivedWhere({
|
||||
year: query.year ? Number(query.year) : undefined,
|
||||
month: query.month ? Number(query.month) : undefined,
|
||||
status: query.status ? String(query.status) : undefined,
|
||||
supplier_name: query.supplier_name
|
||||
? String(query.supplier_name)
|
||||
: undefined,
|
||||
search: query.search ? String(query.search) : undefined,
|
||||
});
|
||||
|
||||
// Sort field whitelisting
|
||||
const sortField =
|
||||
@@ -168,6 +226,30 @@ export default async function receivedInvoicesRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/received-invoices/list-totals — per-currency total summed
|
||||
// over the WHOLE filtered set (not one page), matching the received-invoice
|
||||
// list's filters (year/month/status/supplier_name/search). `amount` is GROSS
|
||||
// (VAT-inclusive), so it is summed directly per currency. Distinct path from
|
||||
// "/stats" (the monthly KPI cards) to avoid colliding with that endpoint.
|
||||
// Registered BEFORE "/:id" so the literal path isn't captured as an id.
|
||||
fastify.get(
|
||||
"/list-totals",
|
||||
{ preHandler: requirePermission("invoices.view") },
|
||||
async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const result = await getReceivedInvoiceListTotals({
|
||||
year: query.year ? Number(query.year) : undefined,
|
||||
month: query.month ? Number(query.month) : undefined,
|
||||
status: query.status ? String(query.status) : undefined,
|
||||
supplier_name: query.supplier_name
|
||||
? String(query.supplier_name)
|
||||
: undefined,
|
||||
search: query.search ? String(query.search) : undefined,
|
||||
});
|
||||
return success(reply, { totals: result.totals });
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/admin/received-invoices/suppliers — distinct supplier names for autocomplete
|
||||
fastify.get(
|
||||
"/suppliers",
|
||||
|
||||
Reference in New Issue
Block a user