From 3b48bd0523a841b0984439f80f735b390a2a3660 Mon Sep 17 00:00:00 2001 From: BOHA Date: Tue, 9 Jun 2026 13:22:39 +0200 Subject: [PATCH] feat(orders): month/year filter on both order lists + fix received-orders search Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/issued-orders.test.ts | 21 +++++ src/__tests__/orders-list.test.ts | 114 +++++++++++++++++++++++++ src/admin/lib/queries/issued-orders.ts | 4 + src/admin/lib/queries/orders.ts | 4 + src/routes/admin/issued-orders.ts | 2 + src/routes/admin/orders.ts | 5 +- src/services/issued-orders.service.ts | 21 ++++- src/services/orders.service.ts | 20 ++++- 8 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/orders-list.test.ts diff --git a/src/__tests__/issued-orders.test.ts b/src/__tests__/issued-orders.test.ts index 9e1be63..d9e7ec3 100644 --- a/src/__tests__/issued-orders.test.ts +++ b/src/__tests__/issued-orders.test.ts @@ -10,6 +10,7 @@ import { computeIssuedOrderTotals, createIssuedOrder, getIssuedOrder, + listIssuedOrders, updateIssuedOrder, deleteIssuedOrder, } from "../services/issued-orders.service"; @@ -219,6 +220,26 @@ describe("deleteIssuedOrder", () => { }); }); +describe("listIssuedOrders month filter", () => { + it("returns only orders whose order_date is in the given month", async () => { + const inMonth = await createIssuedOrder({ order_date: "2026-03-15" }); + const outMonth = await createIssuedOrder({ order_date: "2026-04-15" }); + createdIds.push(inMonth.id, outMonth.id); + const res = await listIssuedOrders({ + page: 1, + limit: 50, + skip: 0, + sort: "id", + order: "desc", + month: 3, + year: 2026, + }); + const ids = res.data.map((o) => o.id); + expect(ids).toContain(inMonth.id); + expect(ids).not.toContain(outMonth.id); + }); +}); + describe("renderIssuedOrderHtml", () => { const order = { po_number: "26720001", diff --git a/src/__tests__/orders-list.test.ts b/src/__tests__/orders-list.test.ts new file mode 100644 index 0000000..0a16010 --- /dev/null +++ b/src/__tests__/orders-list.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, afterEach } from "vitest"; +import prisma from "../config/database"; +import { createOrder, listOrders } from "../services/orders.service"; + +const createdOrderIds: number[] = []; +const createdCustomerIds: number[] = []; + +afterEach(async () => { + for (const id of createdOrderIds) + await prisma.orders.deleteMany({ where: { id } }); + for (const id of createdCustomerIds) + await prisma.customers.deleteMany({ where: { id } }); + createdOrderIds.length = 0; + createdCustomerIds.length = 0; + await prisma.number_sequences.deleteMany({ where: { type: "shared" } }); +}); + +const baseOrder = { + status: "prijata" as const, + currency: "CZK", + language: "cs", + vat_rate: 21, + apply_vat: true, + exchange_rate: 1, +}; + +function failResult(res: unknown): never { + throw new Error(`expected a success result, got ${JSON.stringify(res)}`); +} + +async function makeCustomer() { + const c = await prisma.customers.create({ + data: { name: "Odběratel a.s." }, + }); + createdCustomerIds.push(c.id); + return c; +} + +const baseListParams = { + page: 1, + limit: 50, + skip: 0, + sort: "id", + order: "desc" as const, +}; + +describe("listOrders search filter", () => { + it("returns the order when search matches its order_number", async () => { + const customer = await makeCustomer(); + const res = await createOrder({ + ...baseOrder, + customer_id: customer.id, + create_project: false, + }); + if (!("id" in res)) failResult(res); + createdOrderIds.push(res.id!); + + const list = await listOrders({ + ...baseListParams, + search: res.order_number!, + }); + expect(list.data.map((o) => o.id)).toContain(res.id); + }); + + it("excludes the order when search matches nothing", async () => { + const customer = await makeCustomer(); + const res = await createOrder({ + ...baseOrder, + customer_id: customer.id, + create_project: false, + }); + if (!("id" in res)) failResult(res); + createdOrderIds.push(res.id!); + + const list = await listOrders({ + ...baseListParams, + search: "ZZZ-NO-SUCH-ORDER-ZZZ", + }); + expect(list.data.map((o) => o.id)).not.toContain(res.id); + }); +}); + +describe("listOrders month filter", () => { + it("returns only orders whose created_at is in the given month", async () => { + const customer = await makeCustomer(); + const res = await createOrder({ + ...baseOrder, + customer_id: customer.id, + create_project: false, + }); + if (!("id" in res)) failResult(res); + createdOrderIds.push(res.id!); + + // Pin created_at to a known month so the range filter is deterministic. + await prisma.orders.update({ + where: { id: res.id }, + data: { created_at: new Date(2026, 2, 15, 12, 0, 0) }, + }); + + const inMonth = await listOrders({ + ...baseListParams, + month: 3, + year: 2026, + }); + expect(inMonth.data.map((o) => o.id)).toContain(res.id); + + const otherMonth = await listOrders({ + ...baseListParams, + month: 4, + year: 2026, + }); + expect(otherMonth.data.map((o) => o.id)).not.toContain(res.id); + }); +}); diff --git a/src/admin/lib/queries/issued-orders.ts b/src/admin/lib/queries/issued-orders.ts index 2223ad7..8d274c7 100644 --- a/src/admin/lib/queries/issued-orders.ts +++ b/src/admin/lib/queries/issued-orders.ts @@ -48,6 +48,8 @@ export const issuedOrderListOptions = (filters: { page?: number; perPage?: number; status?: string; + month?: number; + year?: number; }) => queryOptions({ queryKey: ["issued-orders", "list", filters], @@ -59,6 +61,8 @@ export const issuedOrderListOptions = (filters: { if (filters.page) params.set("page", String(filters.page)); if (filters.perPage) params.set("per_page", String(filters.perPage)); 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(); return paginatedJsonQuery( `/api/admin/issued-orders${qs ? `?${qs}` : ""}`, diff --git a/src/admin/lib/queries/orders.ts b/src/admin/lib/queries/orders.ts index 715d2e0..df2f06b 100644 --- a/src/admin/lib/queries/orders.ts +++ b/src/admin/lib/queries/orders.ts @@ -61,6 +61,8 @@ export const orderListOptions = (filters: { order?: string; page?: number; perPage?: number; + month?: number; + year?: number; }) => queryOptions({ queryKey: ["orders", "list", filters], @@ -71,6 +73,8 @@ export const orderListOptions = (filters: { if (filters.order) params.set("order", filters.order); if (filters.page) params.set("page", String(filters.page)); if (filters.perPage) params.set("per_page", String(filters.perPage)); + if (filters.month) params.set("month", String(filters.month)); + if (filters.year) params.set("year", String(filters.year)); const qs = params.toString(); return paginatedJsonQuery(`/api/admin/orders${qs ? `?${qs}` : ""}`); }, diff --git a/src/routes/admin/issued-orders.ts b/src/routes/admin/issued-orders.ts index cafe4bf..679bb63 100644 --- a/src/routes/admin/issued-orders.ts +++ b/src/routes/admin/issued-orders.ts @@ -34,6 +34,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) { search, 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 paginated( reply, diff --git a/src/routes/admin/orders.ts b/src/routes/admin/orders.ts index c83c3ad..e11989a 100644 --- a/src/routes/admin/orders.ts +++ b/src/routes/admin/orders.ts @@ -45,7 +45,7 @@ export default async function ordersRoutes( { preHandler: requirePermission("orders.view") }, async (request, reply) => { const query = request.query as Record; - const { page, limit, skip, sort, order } = parsePagination(query); + const { page, limit, skip, sort, order, search } = parsePagination(query); const result = await listOrders({ page, @@ -53,8 +53,11 @@ export default async function ordersRoutes( skip, sort, order, + search, 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 paginated( diff --git a/src/services/issued-orders.service.ts b/src/services/issued-orders.service.ts index 6cc9962..040fa5b 100644 --- a/src/services/issued-orders.service.ts +++ b/src/services/issued-orders.service.ts @@ -45,6 +45,8 @@ interface ListIssuedOrdersParams { search?: string; status?: string; customer_id?: number; + month?: number; + year?: number; } const VALID_TRANSITIONS: Record = { @@ -92,8 +94,18 @@ export function computeIssuedOrderTotals( } export async function listIssuedOrders(params: ListIssuedOrdersParams) { - const { page, limit, skip, sort, order, search, status, customer_id } = - params; + const { + page, + limit, + skip, + sort, + order, + search, + status, + customer_id, + month, + year, + } = params; const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id"; const where: Record = {}; @@ -106,6 +118,11 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) { { 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. const dir: "asc" | "desc" = order === "asc" ? "asc" : "desc"; diff --git a/src/services/orders.service.ts b/src/services/orders.service.ts index ba49900..c79cb21 100644 --- a/src/services/orders.service.ts +++ b/src/services/orders.service.ts @@ -108,10 +108,13 @@ interface ListOrdersParams { order: "asc" | "desc"; status?: string; customer_id?: number; + search?: string; + month?: number; + year?: number; } export async function listOrders(params: ListOrdersParams) { - const { page, limit, skip, order } = params; + const { page, limit, skip, order, search, month, year } = params; const sortField = ORDER_ALLOWED_SORT_FIELDS.includes(params.sort) ? params.sort : "id"; @@ -119,13 +122,26 @@ export async function listOrders(params: ListOrdersParams) { const where: Record = {}; if (params.status) where.status = params.status; if (params.customer_id) where.customer_id = params.customer_id; + if (search) { + where.OR = [ + { order_number: { contains: search } }, + { customer_order_number: { contains: search } }, + { customers: { name: { contains: search } } }, + ]; + } + if (month && year) { + const from = new Date(year, month - 1, 1); + const to = new Date(year, month, 1); + where.created_at = { gte: from, lt: to }; + } const [orders, total] = await Promise.all([ prisma.orders.findMany({ where, skip, take: limit, - orderBy: { [sortField]: order }, + // Tiebreak on id so same-second created_at rows sort deterministically. + orderBy: [{ [sortField]: order }, { id: order }], include: { customers: { select: { id: true, name: true } }, order_items: { orderBy: { position: "asc" } },