From 3a29ad6a6a52399ae47de54ce0a2407bd2843f71 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 4 Sep 2026 13:23:09 +0200 Subject: [PATCH] feat(pos): add pos-slip-layout-service for reading/writing per-letterhead slip layouts --- .../server/pos-slip-layout-service.test.ts | 85 ++++++++++++++++++ src/lib/server/pos-slip-layout-service.ts | 89 +++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 src/lib/server/pos-slip-layout-service.test.ts create mode 100644 src/lib/server/pos-slip-layout-service.ts diff --git a/src/lib/server/pos-slip-layout-service.test.ts b/src/lib/server/pos-slip-layout-service.test.ts new file mode 100644 index 0000000..4d3a385 --- /dev/null +++ b/src/lib/server/pos-slip-layout-service.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from "vitest"; +import { getSlipLayout, saveSlipLayout } from "./pos-slip-layout-service"; + +const mockGetAdminPocketBase = vi.fn(); +vi.mock("./pocketbase-admin", () => ({ + getAdminPocketBase: () => mockGetAdminPocketBase(), +})); + +describe("getSlipLayout", () => { + it("returns null when the letterhead has no saved layout", async () => { + mockGetAdminPocketBase.mockResolvedValue({ + collection: () => ({ + getList: async () => ({ items: [] }), + }), + }); + const result = await getSlipLayout("lh-1"); + expect(result).toBeNull(); + }); + + it("returns the saved layout, defaulting delivery_show_pricing to true when unset", async () => { + mockGetAdminPocketBase.mockResolvedValue({ + collection: () => ({ + getList: async () => ({ + items: [{ + id: "row-1", + letterhead: "lh-1", + sale_blocks: [{ id: "logo", enabled: false }], + delivery_blocks: [], + cashup_blocks: [], + delivery_show_pricing: undefined, + }], + }), + }), + }); + const result = await getSlipLayout("lh-1"); + expect(result).toEqual({ + sale_blocks: [{ id: "logo", enabled: false }], + delivery_blocks: [], + cashup_blocks: [], + delivery_show_pricing: true, + }); + }); +}); + +describe("saveSlipLayout", () => { + it("creates a new row when none exists yet, only writing the fields given", async () => { + const create = vi.fn().mockResolvedValue({ + id: "row-1", + letterhead: "lh-1", + sale_blocks: [{ id: "logo", enabled: false }], + delivery_blocks: [], + cashup_blocks: [], + delivery_show_pricing: true, + }); + mockGetAdminPocketBase.mockResolvedValue({ + collection: () => ({ + getList: async () => ({ items: [] }), + create, + }), + }); + const result = await saveSlipLayout("lh-1", { sale_blocks: [{ id: "logo", enabled: false }] }); + expect(create).toHaveBeenCalledWith({ letterhead: "lh-1", sale_blocks: [{ id: "logo", enabled: false }] }); + expect(result.sale_blocks).toEqual([{ id: "logo", enabled: false }]); + }); + + it("updates the existing row when one exists", async () => { + const update = vi.fn().mockResolvedValue({ + id: "row-1", + letterhead: "lh-1", + sale_blocks: [], + delivery_blocks: [], + cashup_blocks: [], + delivery_show_pricing: false, + }); + mockGetAdminPocketBase.mockResolvedValue({ + collection: () => ({ + getList: async () => ({ items: [{ id: "row-1", letterhead: "lh-1" }] }), + update, + }), + }); + const result = await saveSlipLayout("lh-1", { delivery_show_pricing: false }); + expect(update).toHaveBeenCalledWith("row-1", { letterhead: "lh-1", delivery_show_pricing: false }); + expect(result.delivery_show_pricing).toBe(false); + }); +}); diff --git a/src/lib/server/pos-slip-layout-service.ts b/src/lib/server/pos-slip-layout-service.ts new file mode 100644 index 0000000..c4434aa --- /dev/null +++ b/src/lib/server/pos-slip-layout-service.ts @@ -0,0 +1,89 @@ +import { getAdminPocketBase } from "./pocketbase-admin"; + +/** + * A single row in a slip's block list. `text` is present only on + * blocks that carry editable text (header_title, footer); it's + * null/absent to mean "use the default text". + */ +export type PosSlipBlock = { + id: string; + enabled: boolean; + text?: string | null; +}; + +type PosSlipLayoutRow = { + id: string; + letterhead: string; + sale_blocks: PosSlipBlock[]; + delivery_blocks: PosSlipBlock[]; + cashup_blocks: PosSlipBlock[]; + delivery_show_pricing?: boolean; +}; + +export type PosSlipLayout = { + sale_blocks: PosSlipBlock[]; + delivery_blocks: PosSlipBlock[]; + cashup_blocks: PosSlipBlock[]; + delivery_show_pricing: boolean; +}; + +export type PosSlipLayoutInput = Partial; + +function escapePbValue(value: string): string { + return value.replace(/'/g, "\\'"); +} + +async function findRow( + pb: Awaited>, + letterheadId: string, +): Promise { + const page = await pb.collection("pos_slip_layouts").getList(1, 1, { + filter: `letterhead = '${escapePbValue(letterheadId)}'`, + }); + return page.items[0] || null; +} + +function toLayout(row: PosSlipLayoutRow): PosSlipLayout { + return { + sale_blocks: row.sale_blocks || [], + delivery_blocks: row.delivery_blocks || [], + cashup_blocks: row.cashup_blocks || [], + delivery_show_pricing: row.delivery_show_pricing !== false, + }; +} + +/** + * Fetch the saved slip layout for a letterhead. Returns null when + * nothing has been saved yet — callers fall back to the hardcoded + * defaults in `public/pos/slip-layout.js` (kept there, not here, + * since the till needs the same defaults available when it's offline + * and can't reach this endpoint at all). + */ +export async function getSlipLayout(letterheadId: string): Promise { + const pb = await getAdminPocketBase(); + const row = await findRow(pb, letterheadId); + return row ? toLayout(row) : null; +} + +/** + * Create-or-update the slip layout row for a letterhead. Only the + * fields present in `input` are written — saving the sale slip's + * blocks from the editor doesn't touch delivery_blocks/cashup_blocks. + */ +export async function saveSlipLayout( + letterheadId: string, + input: PosSlipLayoutInput, +): Promise { + const pb = await getAdminPocketBase(); + const existing = await findRow(pb, letterheadId); + const data: Record = { letterhead: letterheadId }; + if (input.sale_blocks !== undefined) data.sale_blocks = input.sale_blocks; + if (input.delivery_blocks !== undefined) data.delivery_blocks = input.delivery_blocks; + if (input.cashup_blocks !== undefined) data.cashup_blocks = input.cashup_blocks; + if (input.delivery_show_pricing !== undefined) data.delivery_show_pricing = input.delivery_show_pricing; + + const row = existing + ? ((await pb.collection("pos_slip_layouts").update(existing.id, data)) as PosSlipLayoutRow) + : ((await pb.collection("pos_slip_layouts").create(data)) as PosSlipLayoutRow); + return toLayout(row); +}