WFDASH-WEBAPP/src/lib/server/management-service.ts
SJCoetz 6c07a91693 refactor: drop order-level status, shift to item-driven workflow
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
2026-06-29 13:06:01 +02:00

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