feat(pos): add pos-slip-layout-service for reading/writing per-letterhead slip layouts

This commit is contained in:
Dave 2026-09-04 13:23:09 +02:00
parent e0ce1310e0
commit 3a29ad6a6a
2 changed files with 174 additions and 0 deletions

View file

@ -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);
});
});

View file

@ -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<PosSlipLayout>;
function escapePbValue(value: string): string {
return value.replace(/'/g, "\\'");
}
async function findRow(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
letterheadId: string,
): Promise<PosSlipLayoutRow | null> {
const page = await pb.collection("pos_slip_layouts").getList<PosSlipLayoutRow>(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<PosSlipLayout | null> {
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<PosSlipLayout> {
const pb = await getAdminPocketBase();
const existing = await findRow(pb, letterheadId);
const data: Record<string, unknown> = { 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);
}