This commit overhauls the order management system to use item-level status tracking instead of a single order-level status: - Remove deprecated `orders.status` field and add `invoiced` and quote match audit fields - Add `isOrderComplete` utility with tests to validate fully delivered orders - Add purchase order PDF rendering support and tooling - Add geo coordinate fields for companies/customers with map link UI - Add REST management APIs for customers, companies, and suppliers - Update customer/company services to use relation fields instead of free text - Add status pill and tab filter components for item status visualization - Add quote-to-order matching logic and unit tests - Update all pages, components, and tests to match the new schema - Include all required PocketBase migrations and schema updates
258 lines
No EOL
8.4 KiB
TypeScript
258 lines
No EOL
8.4 KiB
TypeScript
import PocketBase from "pocketbase";
|
|
|
|
/**
|
|
* Management CRUD — server-side helpers used by the MCP layer.
|
|
*
|
|
* Why a separate module from `lib/pb-helpers.ts`?
|
|
* - `pb-helpers.ts` is the *client* surface (browser auth, getX / getAllX
|
|
* reads for React components). It uses the cookie-auth pocketbase
|
|
* instance and is bundled into the browser.
|
|
* - These helpers run *server-side* with the admin PB (full read/write
|
|
* across all collections, no per-row auth rules) and are called from
|
|
* `/api/mcp`. The MCP layer is what the Hermes agents in n8n hit.
|
|
*
|
|
* Naming convention mirrors pb-helpers.ts but with the verb prefix
|
|
* (createCompany / updateCompany / deleteCompany) — caller's pb
|
|
* instance is passed in so we can share auth state with other server
|
|
* helpers (e.g. findOrCreateCustomer in quotes-service.ts).
|
|
*
|
|
* Field shape notes (post 2026-06-28 schema changes):
|
|
* - Company.coordinates, Customer.coordinates: `{ lon: number, lat: number }`
|
|
* (PB geoPoint). Null Island `{0,0}` is treated as "unset" by the UI.
|
|
* - Customer.company: PB relation → companies (pass a company id).
|
|
* - Customer.rep: PB relation → users (pass a user id).
|
|
* - Supplier.coordinates: text "lat, lng" (unchanged from pre-migration).
|
|
*/
|
|
|
|
// ─── Companies ─────────────────────────────────────────────────────────────
|
|
|
|
export async function createCompany(
|
|
pb: PocketBase,
|
|
input: {
|
|
company: string;
|
|
address?: string;
|
|
vendor?: string;
|
|
coordinates?: { lon: number; lat: number };
|
|
}
|
|
) {
|
|
if (!input.company?.trim()) {
|
|
throw new Error("company name is required");
|
|
}
|
|
return pb.collection("companies").create({
|
|
company: input.company.trim(),
|
|
address: input.address || "",
|
|
vendor: input.vendor || "",
|
|
coordinates: input.coordinates || { lon: 0, lat: 0 },
|
|
});
|
|
}
|
|
|
|
export async function updateCompany(
|
|
pb: PocketBase,
|
|
id: string,
|
|
patch: Partial<{
|
|
company: string;
|
|
address: string;
|
|
vendor: string;
|
|
coordinates: { lon: number; lat: number };
|
|
}>
|
|
) {
|
|
return pb.collection("companies").update(id, patch);
|
|
}
|
|
|
|
export async function deleteCompany(pb: PocketBase, id: string) {
|
|
return pb.collection("companies").delete(id);
|
|
}
|
|
|
|
// ─── Customers ─────────────────────────────────────────────────────────────
|
|
|
|
export async function createCustomer(
|
|
pb: PocketBase,
|
|
input: {
|
|
customer: string;
|
|
email?: string;
|
|
alternative_email?: string;
|
|
buyer_email?: string;
|
|
number?: string;
|
|
/**
|
|
* Either a company record id (preferred) or a company name. If a name
|
|
* is passed we look it up case-insensitively; if no match, the
|
|
* relation is left empty so the operator can link it in the UI.
|
|
*/
|
|
company?: string;
|
|
/**
|
|
* Either a user id (preferred) or a user's name / email. Same
|
|
* name-resolution behaviour as `company`.
|
|
*/
|
|
rep?: string;
|
|
coordinates?: { lon: number; lat: number };
|
|
description?: string;
|
|
}
|
|
) {
|
|
if (!input.customer?.trim()) {
|
|
throw new Error("customer name is required");
|
|
}
|
|
const companyId = await resolveCompanyRef(pb, input.company);
|
|
const repId = await resolveUserRef(pb, input.rep);
|
|
return pb.collection("customers").create({
|
|
customer: input.customer.trim(),
|
|
email: input.email || "",
|
|
alternative_email: input.alternative_email || "",
|
|
buyer_email: input.buyer_email || "",
|
|
number: input.number || "",
|
|
company: companyId,
|
|
rep: repId,
|
|
coordinates: input.coordinates || { lon: 0, lat: 0 },
|
|
description: input.description || "",
|
|
dateadded: new Date().toISOString().slice(0, 10),
|
|
lastvisiteddate: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
export async function updateCustomer(
|
|
pb: PocketBase,
|
|
id: string,
|
|
patch: Partial<{
|
|
customer: string;
|
|
email: string;
|
|
alternative_email: string;
|
|
buyer_email: string;
|
|
number: string;
|
|
/** id or name; "" to unset the relation. */
|
|
company: string;
|
|
/** id or name; "" to unset the relation. */
|
|
rep: string;
|
|
coordinates: { lon: number; lat: number };
|
|
description: string;
|
|
}>
|
|
) {
|
|
const next: Record<string, unknown> = { ...patch };
|
|
if ("company" in patch) {
|
|
next.company = patch.company ? await resolveCompanyRef(pb, patch.company) : "";
|
|
}
|
|
if ("rep" in patch) {
|
|
next.rep = patch.rep ? await resolveUserRef(pb, patch.rep) : "";
|
|
}
|
|
return pb.collection("customers").update(id, next);
|
|
}
|
|
|
|
export async function deleteCustomer(pb: PocketBase, id: string) {
|
|
return pb.collection("customers").delete(id);
|
|
}
|
|
|
|
// ─── Suppliers ─────────────────────────────────────────────────────────────
|
|
|
|
export async function createSupplier(
|
|
pb: PocketBase,
|
|
input: {
|
|
suppliername: string;
|
|
email?: string;
|
|
suppliernumber?: string;
|
|
supplieraddress?: string;
|
|
supply_tags?: string;
|
|
/** Combined "lat, lng" text — suppliers still use the old format. */
|
|
coordinates?: string;
|
|
closingtime?: string;
|
|
}
|
|
) {
|
|
if (!input.suppliername?.trim()) {
|
|
throw new Error("supplier name is required");
|
|
}
|
|
return pb.collection("suppliers").create({
|
|
suppliername: input.suppliername.trim(),
|
|
email: input.email || "",
|
|
suppliernumber: input.suppliernumber || "",
|
|
supplieraddress: input.supplieraddress || "",
|
|
supply_tags: input.supply_tags || "",
|
|
coordinates: input.coordinates || "",
|
|
closingtime: input.closingtime || "",
|
|
});
|
|
}
|
|
|
|
export async function updateSupplier(
|
|
pb: PocketBase,
|
|
id: string,
|
|
patch: Partial<{
|
|
suppliername: string;
|
|
email: string;
|
|
suppliernumber: string;
|
|
supplieraddress: string;
|
|
supply_tags: string;
|
|
coordinates: string;
|
|
closingtime: string;
|
|
}>
|
|
) {
|
|
return pb.collection("suppliers").update(id, patch);
|
|
}
|
|
|
|
export async function deleteSupplier(pb: PocketBase, id: string) {
|
|
return pb.collection("suppliers").delete(id);
|
|
}
|
|
|
|
// ─── Reference resolution helpers ────────────────────────────────────────
|
|
|
|
/**
|
|
* Accepts either a company record id or a company name. If it looks
|
|
* like a 15-char PocketBase id, return as-is. Otherwise look up by
|
|
* name (case-insensitive, trimmed). Returns "" when no match.
|
|
*
|
|
* The 15-char check is fuzzy — PocketBase ids are alphanumeric and
|
|
* default to exactly 15 chars. We err on the side of "if it has a
|
|
* passable name shape, look it up".
|
|
*/
|
|
async function resolveCompanyRef(pb: PocketBase, ref?: string): Promise<string> {
|
|
if (!ref) return "";
|
|
const trimmed = ref.trim();
|
|
if (!trimmed) return "";
|
|
// Fast path: if it parses as a PB id and the row exists, use it.
|
|
if (isPocketBaseId(trimmed)) {
|
|
try {
|
|
await pb.collection("companies").getOne(trimmed);
|
|
return trimmed;
|
|
} catch {
|
|
// Fall through to name lookup.
|
|
}
|
|
}
|
|
const match = await findCompanyByName(pb, trimmed);
|
|
return match?.id || "";
|
|
}
|
|
|
|
async function resolveUserRef(pb: PocketBase, ref?: string): Promise<string> {
|
|
if (!ref) return "";
|
|
const trimmed = ref.trim();
|
|
if (!trimmed) return "";
|
|
if (isPocketBaseId(trimmed)) {
|
|
try {
|
|
await pb.collection("users").getOne(trimmed);
|
|
return trimmed;
|
|
} catch {
|
|
// Fall through.
|
|
}
|
|
}
|
|
// Try name match, then email match.
|
|
const byName = await pb.collection("users").getFirstListItem(`name = "${trimmed.replace(/"/g, '\\"')}"`)
|
|
.catch(() => null);
|
|
if (byName) return byName.id;
|
|
const byEmail = await pb.collection("users").getFirstListItem(`email = "${trimmed.toLowerCase().replace(/"/g, '\\"')}"`)
|
|
.catch(() => null);
|
|
return byEmail?.id || "";
|
|
}
|
|
|
|
async function findCompanyByName(pb: PocketBase, name: string) {
|
|
const trimmed = name.trim();
|
|
if (!trimmed) return null;
|
|
const escaped = trimmed.replace(/"/g, '\\"');
|
|
try {
|
|
return await pb
|
|
.collection("companies")
|
|
.getFirstListItem(`company = "${escaped}"`);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isPocketBaseId(s: string): boolean {
|
|
// PocketBase ids are 15 alphanumeric chars by default. We don't try
|
|
// to be stricter than that — false positives just fall through to
|
|
// the name lookup which is harmless.
|
|
return /^[a-z0-9]{15}$/i.test(s);
|
|
} |