WFDASH-WEBAPP/src/lib/server/counter-service.ts
Dave 18397bc9be feat(counter): add standalone counter order PWA
This commit adds a full unauthenticated PWA for in-store retail order taking:
- Dedicated `/new-order` route with complete order form, customer/company picker
- Public `/api/counter/*` endpoints for search, order creation, and photo uploads
- Photo capture and gallery selection with 10-photo per order limit
- Offline caching via dedicated service worker and app manifest
- Shortcut redirect at `/counter` for quick installation
- Comprehensive setup and usage documentation in `docs/counter-pwa.md`
- Minor cleanup to root layout metadata and service worker registration
- Small layout fix for collection pool row text truncation

Orders submitted via the PWA appear immediately in the main CRM's New tab, and the app is designed for trusted office kiosk phones without requiring user login.
2026-08-13 09:27:20 +02:00

327 lines
No EOL
10 KiB
TypeScript

import { getAdminPocketBase } from "./pocketbase-admin";
import {
createCompany,
createCustomer,
} from "./management-service";
import { createOrder } from "./orders-service";
/* ============================================================================
* Counter service — backing the standalone PWA order form.
*
* Counter phones run /new-order without PocketBase user login (the office
* staff are logged in already at the main desktop terminal). These
* endpoints are intentionally public — they sit behind nothing more than
* network-level isolation. Do not expose the /api/counter/* routes
* publicly without revisiting this assumption.
*
* Search endpoints return a small JSON shape (NOT the full PB row) so
* the browser can render the combined customer/company combobox
* without hauling the entire PB record down the wire.
* ========================================================================= */
export interface CustomerLite {
id: string;
customer: string;
email: string;
number: string;
company_id: string;
company_name: string;
}
export interface CompanyLite {
id: string;
company: string;
address: string;
vendor: string;
}
export interface CreateCounterOrderInput {
// Pick OR inline-create. Resolved to ids server-side.
customer_id?: string;
company_id?: string;
inline_customer?: {
customer: string;
email?: string;
number?: string;
buyer_email?: string;
company_id?: string;
};
inline_company?: {
company: string;
address?: string;
vendor?: string;
};
contact_name?: string;
notes?: string;
description?: string;
rep?: string;
items: Array<{
description: string;
quantity?: number;
selling_price?: number;
notes?: string;
}>;
}
export interface CreateCounterOrderResult {
order_id: string;
order_number: string;
customer_id: string;
company_id: string;
items_created: number;
items_failed: Array<{ description: string; error: string }>;
}
/* ──────────────────────────────────────────────────────────────────────────
* Search helpers
* ──────────────────────────────────────────────────────────────────────── */
const SEARCH_PER_PAGE = 200;
export async function searchCustomersLite(
query: string,
): Promise<CustomerLite[]> {
const pb = await getAdminPocketBase();
const trimmed = query.trim();
const filter = trimmed
? `customer ~ "${escapePbString(trimmed)}" || email ~ "${escapePbString(
trimmed,
)}" || number ~ "${escapePbString(trimmed)}"`
: "";
const rows = await pb.collection("customers").getList(1, SEARCH_PER_PAGE, {
filter,
sort: "customer",
});
const ids = Array.from(
new Set(rows.items.map((r) => (r as { company?: string }).company).filter(Boolean)),
) as string[];
const companiesById = new Map<string, string>();
if (ids.length > 0) {
const companies = await pb.collection("companies").getFullList({
filter: ids.map((id) => `id = "${id}"`).join(" || "),
});
for (const co of companies) {
companiesById.set(co.id, (co as { company?: string }).company || "");
}
}
return rows.items.map((row) => {
const r = row as {
id: string;
customer?: string;
email?: string;
number?: string;
company?: string;
};
return {
id: r.id,
customer: r.customer || "",
email: r.email || "",
number: r.number || "",
company_id: r.company || "",
company_name: companiesById.get(r.company || "") || "",
};
});
}
export async function searchCompaniesLite(
query: string,
): Promise<CompanyLite[]> {
const pb = await getAdminPocketBase();
const trimmed = query.trim();
const filter = trimmed
? `company ~ "${escapePbString(trimmed)}" || vendor ~ "${escapePbString(
trimmed,
)}"`
: "";
const rows = await pb.collection("companies").getList(1, SEARCH_PER_PAGE, {
filter,
sort: "company",
});
return rows.items.map((row) => {
const r = row as {
id: string;
company?: string;
address?: string;
vendor?: string;
};
return {
id: r.id,
company: r.company || "",
address: r.address || "",
vendor: r.vendor || "",
};
});
}
/* ──────────────────────────────────────────────────────────────────────────
* Mutations
* ──────────────────────────────────────────────────────────────────────── */
export async function createCounterCompany(input: {
company: string;
address?: string;
vendor?: string;
}): Promise<CompanyLite> {
const pb = await getAdminPocketBase();
const row = (await createCompany(pb, input)) as {
id: string;
company?: string;
address?: string;
vendor?: string;
};
return {
id: row.id,
company: row.company || "",
address: row.address || "",
vendor: row.vendor || "",
};
}
export async function createCounterCustomer(input: {
customer: string;
email?: string;
number?: string;
buyer_email?: string;
company?: string;
}): Promise<CustomerLite> {
const pb = await getAdminPocketBase();
const row = (await createCustomer(pb, input)) as {
id: string;
customer?: string;
email?: string;
number?: string;
company?: string;
};
let companyName = "";
if (row.company) {
try {
const co = (await pb.collection("companies").getOne(row.company)) as {
company?: string;
};
companyName = co.company || "";
} catch {
companyName = "";
}
}
return {
id: row.id,
customer: row.customer || "",
email: row.email || "",
number: row.number || "",
company_id: row.company || "",
company_name: companyName,
};
}
export async function createCounterOrder(
input: CreateCounterOrderInput,
): Promise<CreateCounterOrderResult> {
// Resolve company_id — pick OR inline-create.
let companyId = (input.company_id || "").trim();
if (!companyId && input.inline_company) {
const created = await createCounterCompany(input.inline_company);
companyId = created.id;
}
if (!companyId) {
throw new Error("company_id or inline_company is required");
}
// Resolve customer_id — pick OR inline-create.
let customerId = (input.customer_id || "").trim();
if (!customerId && input.inline_customer) {
const payload = {
...input.inline_customer,
// If the inline customer references a company that doesn't exist yet,
// createCompany above gave us a fresh id — but inline_customer carries
// its own company_id (possibly empty). Stitch them together here.
company: input.inline_customer.company_id || companyId,
};
const created = await createCounterCustomer(payload);
customerId = created.id;
}
if (!customerId) {
throw new Error("customer_id or inline_customer is required");
}
if (!Array.isArray(input.items) || input.items.length === 0) {
throw new Error("items must be a non-empty array");
}
for (const [i, it] of input.items.entries()) {
if (!it.description || !it.description.trim()) {
throw new Error(`items[${i}].description is required`);
}
}
const result = await createOrder({
customer_id: customerId,
company_id: companyId,
notes: input.notes,
contact_name: input.contact_name,
description: input.description,
rep: input.rep,
items: input.items.map((it) => ({
description: it.description.trim(),
quantity: typeof it.quantity === "number" ? it.quantity : null,
selling_price:
typeof it.selling_price === "number" ? it.selling_price : undefined,
notes: it.notes,
})),
});
return {
order_id: result.order.id,
order_number: result.order.order_number,
customer_id: customerId,
company_id: companyId,
items_created: result.items_created.length,
items_failed: result.items_failed.map((f) => ({
description: f.draft.description,
error: f.error,
})),
};
}
/**
* Upload photos to an existing order's `order_attachements` field.
*
* Photos are uploaded AFTER the order is created so a flaky network
* doesn't lose the order. The route handler maps the incoming
* multipart/form-data `files` field into a `File[]` and passes them
* to the PB admin SDK.
*/
export async function uploadCounterOrderAttachments(
orderId: string,
files: File[],
): Promise<{ order_id: string; count: number }> {
if (!orderId) throw new Error("orderId is required");
if (!Array.isArray(files) || files.length === 0) {
return { order_id: orderId, count: 0 };
}
const pb = await getAdminPocketBase();
// Verify the order exists before we try to patch it. Without this
// check a stale id would silently succeed and PB would just noop.
await pb.collection("orders").getOne(orderId);
const form = new FormData();
for (const file of files) {
form.append("order_attachements", file);
}
await pb.collection("orders").update(orderId, form);
return { order_id: orderId, count: files.length };
}
/* ──────────────────────────────────────────────────────────────────────────
* Helpers
* ──────────────────────────────────────────────────────────────────────── */
/**
* Escape a string for use inside a PB filter expression. PB uses
* double-quoted strings and the only character that needs escaping
* inside them is the double-quote itself.
*/
function escapePbString(input: string): string {
return input.replace(/"/g, '\\"');
}