327 lines
10 KiB
TypeScript
327 lines
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, '\\"');
|
||
|
|
}
|