Introduce PocketBase workflow support, migration tooling, server APIs and documentation. Adds a set of PocketBase collection JSONs (order_items, order_items_v2, collection_pool, delivery_notes, delivery_note_items, repairs) and a large import manifest for workflow collections; includes docs describing MCP/API layer, migration audit, order-item schema and rollout plans. Adds server-side agent/MCP endpoints and services (api/mcp, api/v1/orders, collection-pool, order-items status, health) plus helper modules (pocketbase-admin, orders-service, order-item-status, agent-auth) and a migration script to create order_items_v2/collection_pool. UI: new pages for collection-pool, delivery-notes, repairs and several updates to orders/quotes pages, sidebar, tooltip and pb helpers/types. Also updates package.json (migration script and related changes) and global styles. Required env vars and suggested migration command are documented (e.g. NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, ORDER_ITEMS_COLLECTION; run npm run pb:migrate:orders-v2).
497 lines
15 KiB
TypeScript
497 lines
15 KiB
TypeScript
import getPocketBase from "./pocketbase";
|
|
import type { Customer, Company, Supplier, Quote, QuoteItem, Order, OrderItem, PurchaseOrder, CollectionPool, DeliveryNote, Repair } from "./types";
|
|
|
|
export async function getCustomers(page = 1, perPage = 50, search = "", sort = "-created") {
|
|
const pb = getPocketBase();
|
|
const filter = search
|
|
? `customer ~ "${search}" || email ~ "${search}" || number ~ "${search}" || company ~ "${search}"`
|
|
: "";
|
|
return pb.collection("customers").getList<Customer>(page, perPage, { filter, sort });
|
|
}
|
|
|
|
export async function getCustomer(id: string) {
|
|
const pb = getPocketBase();
|
|
return pb.collection("customers").getOne<Customer>(id);
|
|
}
|
|
|
|
export async function getAllCustomers() {
|
|
const pb = getPocketBase();
|
|
return pb.collection("customers").getFullList<Customer>({ sort: "-created" });
|
|
}
|
|
|
|
export async function getCompanies(page = 1, perPage = 50, search = "", sort = "-created") {
|
|
const pb = getPocketBase();
|
|
const filter = search
|
|
? `company ~ "${search}" || vendor ~ "${search}"`
|
|
: "";
|
|
return pb.collection("companies").getList<Company>(page, perPage, { filter, sort });
|
|
}
|
|
|
|
export async function getCompany(id: string) {
|
|
const pb = getPocketBase();
|
|
return pb.collection("companies").getOne<Company>(id);
|
|
}
|
|
|
|
export async function getAllCompanies() {
|
|
const pb = getPocketBase();
|
|
return pb.collection("companies").getFullList<Company>({ sort: "-created" });
|
|
}
|
|
|
|
export async function getSuppliers(page = 1, perPage = 50, search = "", sort = "-created") {
|
|
const pb = getPocketBase();
|
|
const filter = search
|
|
? `suppliername ~ "${search}" || email ~ "${search}" || suppliernumber ~ "${search}" || supply_tags ~ "${search}"`
|
|
: "";
|
|
return pb.collection("suppliers").getList<Supplier>(page, perPage, { filter, sort });
|
|
}
|
|
|
|
export async function getSupplier(id: string) {
|
|
const pb = getPocketBase();
|
|
return pb.collection("suppliers").getOne<Supplier>(id);
|
|
}
|
|
|
|
export async function getAllSuppliers() {
|
|
const pb = getPocketBase();
|
|
return pb.collection("suppliers").getFullList<Supplier>({ sort: "-created" });
|
|
}
|
|
|
|
export async function getQuotes(page = 1, perPage = 50, search = "", sort = "-created", status?: string) {
|
|
const pb = getPocketBase();
|
|
const filters: string[] = [];
|
|
if (search) {
|
|
filters.push(`quote_number ~ "${search}" || quote_reference ~ "${search}" || rfq_number ~ "${search}"`);
|
|
}
|
|
if (status) {
|
|
filters.push(`status = "${status}"`);
|
|
}
|
|
const filter = filters.length > 0 ? filters.join(" && ") : "";
|
|
return pb.collection("quotes").getList<Quote>(page, perPage, {
|
|
filter,
|
|
sort,
|
|
expand: "customer,company",
|
|
});
|
|
}
|
|
|
|
export async function getQuote(id: string) {
|
|
const pb = getPocketBase();
|
|
return pb.collection("quotes").getOne<Quote>(id, { expand: "customer,company" });
|
|
}
|
|
|
|
export async function getQuoteItems(quoteId: string) {
|
|
const pb = getPocketBase();
|
|
return pb.collection("quote_items").getFullList<QuoteItem>({
|
|
filter: `quote = "${quoteId}"`,
|
|
expand: "supplier",
|
|
});
|
|
}
|
|
|
|
export async function getOrders(page = 1, perPage = 50, search = "", sort = "-created", status?: string) {
|
|
const pb = getPocketBase();
|
|
const filters: string[] = [];
|
|
if (search) {
|
|
filters.push(`order_number ~ "${search}"`);
|
|
}
|
|
if (status) {
|
|
filters.push(`status = "${status}"`);
|
|
}
|
|
const filter = filters.length > 0 ? filters.join(" && ") : "";
|
|
return pb.collection("orders").getList<Order>(page, perPage, {
|
|
filter,
|
|
sort,
|
|
expand: "company,customer,quote",
|
|
});
|
|
}
|
|
|
|
export async function getOrder(id: string) {
|
|
const pb = getPocketBase();
|
|
return pb.collection("orders").getOne<Order>(id, { expand: "company,customer,quote" });
|
|
}
|
|
|
|
export async function getOrderByQuote(quoteId: string) {
|
|
const pb = getPocketBase();
|
|
try {
|
|
return await pb.collection("orders").getFirstListItem<Order>(`quote = "${quoteId}"`, {
|
|
expand: "company,customer,quote",
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getOrderItems(orderId: string) {
|
|
const pb = getPocketBase();
|
|
return pb.collection("order_items").getFullList<OrderItem>({
|
|
filter: `order = "${orderId}"`,
|
|
expand: "supplier",
|
|
});
|
|
}
|
|
|
|
export async function createOrderFromQuote(quoteId: string) {
|
|
const pb = getPocketBase();
|
|
const existingOrder = await getOrderByQuote(quoteId);
|
|
if (existingOrder) {
|
|
return { order: existingOrder, alreadyExists: true };
|
|
}
|
|
|
|
const quote = await pb.collection("quotes").getOne<Quote>(quoteId);
|
|
const quoteItems = await pb.collection("quote_items").getFullList<QuoteItem>({
|
|
filter: `quote = "${quoteId}"`,
|
|
sort: "created",
|
|
});
|
|
if (quoteItems.length === 0) {
|
|
throw new Error("Quote has no items to convert.");
|
|
}
|
|
|
|
const orderNumber = await generateOrderNumber();
|
|
const order = await pb.collection("orders").create<Order>({
|
|
order_number: orderNumber,
|
|
company: quote.company,
|
|
customer: quote.customer || "",
|
|
quote: quote.id,
|
|
status: "new",
|
|
notes: quote.notes || "",
|
|
quote_matching_attempted: true,
|
|
quote_match_confidence: 1,
|
|
potential_quote: quote.id,
|
|
potential_quote_confidence: 1,
|
|
is_repair_order: false,
|
|
});
|
|
|
|
for (const item of quoteItems) {
|
|
const quantity = Number(item.quantity || 1);
|
|
await pb.collection("order_items").create({
|
|
order: order.id,
|
|
description: item.description || "",
|
|
po_description: item.description || "",
|
|
quantity,
|
|
order_qty: quantity,
|
|
selling_price: Number(item.selling_price || 0),
|
|
supplier: item.supplier || null,
|
|
cost_price: Number(item.cost_price || 0),
|
|
markup: Number(item.markup || 0),
|
|
markup_percent: Number(item.markup || 0),
|
|
item_status: "pending",
|
|
drv_status: "unassigned",
|
|
driver_status: "unassigned",
|
|
notes: item.notes || "",
|
|
quote_item: item.id,
|
|
});
|
|
}
|
|
|
|
return { order, alreadyExists: false };
|
|
}
|
|
|
|
export async function getCollectionPool(page = 1, perPage = 50, status = "") {
|
|
const pb = getPocketBase();
|
|
const filter = status ? `pool_status = "${status}"` : "";
|
|
return pb.collection("collection_pool").getList<CollectionPool>(page, perPage, {
|
|
filter,
|
|
sort: "-created",
|
|
expand: "order_item,order,supplier,drv_id",
|
|
});
|
|
}
|
|
|
|
export async function queueOrderItemForCollection(item: OrderItem, orderId: string) {
|
|
const pb = getPocketBase();
|
|
const existing = await pb.collection("collection_pool").getList<CollectionPool>(1, 1, {
|
|
filter: `order_item = "${item.id}" && pool_status != "completed" && pool_status != "failed"`,
|
|
});
|
|
|
|
if (existing.items.length === 0) {
|
|
await pb.collection("collection_pool").create({
|
|
order: orderId,
|
|
order_item: item.id,
|
|
supplier: item.supplier || null,
|
|
pool_status: "queued",
|
|
priority: "normal",
|
|
pool_notes: item.notes || "",
|
|
});
|
|
}
|
|
|
|
return pb.collection("order_items").update<OrderItem>(item.id, {
|
|
item_status: "driver_pool",
|
|
drv_status: "unassigned",
|
|
});
|
|
}
|
|
|
|
export async function updateOrderItemWorkflowStatus(
|
|
itemId: string,
|
|
patch: Partial<Pick<OrderItem, "item_status" | "drv_status" | "notes">>
|
|
) {
|
|
const pb = getPocketBase();
|
|
return pb.collection("order_items").update<OrderItem>(itemId, patch);
|
|
}
|
|
|
|
export async function updateCollectionPoolStatus(
|
|
poolItem: CollectionPool,
|
|
poolStatus: CollectionPool["pool_status"]
|
|
) {
|
|
const pb = getPocketBase();
|
|
const payload: Record<string, unknown> = { pool_status: poolStatus };
|
|
if (poolStatus === "assigned") {
|
|
payload.assigned_at = new Date().toISOString();
|
|
}
|
|
if (poolStatus === "completed") {
|
|
payload.completed_at = new Date().toISOString();
|
|
}
|
|
|
|
const updated = await pb.collection("collection_pool").update<CollectionPool>(poolItem.id, payload);
|
|
|
|
if (poolStatus === "assigned") {
|
|
await pb.collection("order_items").update(poolItem.order_item, {
|
|
item_status: "assigned",
|
|
drv_status: "assigned",
|
|
});
|
|
}
|
|
|
|
if (poolStatus === "in_progress") {
|
|
await pb.collection("order_items").update(poolItem.order_item, {
|
|
drv_status: "en_route",
|
|
});
|
|
}
|
|
|
|
if (poolStatus === "completed") {
|
|
await pb.collection("order_items").update(poolItem.order_item, {
|
|
item_status: "collected",
|
|
drv_status: "collected",
|
|
});
|
|
}
|
|
|
|
if (poolStatus === "failed") {
|
|
await pb.collection("order_items").update(poolItem.order_item, {
|
|
drv_status: "failed",
|
|
problem_flag: true,
|
|
problem_type: "driver",
|
|
});
|
|
}
|
|
|
|
return updated;
|
|
}
|
|
|
|
export async function getDeliveryNotes(page = 1, perPage = 50, search = "") {
|
|
const pb = getPocketBase();
|
|
const filter = search ? `delivery_note_number ~ "${search}" || notes ~ "${search}"` : "";
|
|
return pb.collection("delivery_notes").getList<DeliveryNote>(page, perPage, {
|
|
filter,
|
|
sort: "-created",
|
|
expand: "order,company,customer",
|
|
});
|
|
}
|
|
|
|
export async function generateDeliveryNoteNumber(): Promise<string> {
|
|
const pb = getPocketBase();
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
const prefix = `DN-${year}-${month}-`;
|
|
|
|
const existing = await pb.collection("delivery_notes").getList<DeliveryNote>(1, 1, {
|
|
filter: `delivery_note_number ~ "${prefix}"`,
|
|
sort: "-delivery_note_number",
|
|
});
|
|
|
|
let seq = 1;
|
|
if (existing.items.length > 0) {
|
|
const lastNum = parseInt(existing.items[0].delivery_note_number.split("-").pop() || "0", 10);
|
|
seq = lastNum + 1;
|
|
}
|
|
|
|
return `${prefix}${String(seq).padStart(3, "0")}`;
|
|
}
|
|
|
|
export async function createDeliveryNoteFromOrder(order: Order, items: OrderItem[]) {
|
|
const pb = getPocketBase();
|
|
const eligible = items.filter((item) => ["ready_delivery", "delivered"].includes(item.item_status));
|
|
if (eligible.length === 0) {
|
|
throw new Error("No ready or delivered items available for a delivery note.");
|
|
}
|
|
|
|
const deliveryNoteNumber = await generateDeliveryNoteNumber();
|
|
const deliveryNote = await pb.collection("delivery_notes").create<DeliveryNote>({
|
|
delivery_note_number: deliveryNoteNumber,
|
|
order: order.id,
|
|
company: order.company || null,
|
|
customer: order.customer || null,
|
|
status: "draft",
|
|
notes: order.notes || "",
|
|
});
|
|
|
|
for (const item of eligible) {
|
|
await pb.collection("delivery_note_items").create({
|
|
delivery_note: deliveryNote.id,
|
|
order_item: item.id,
|
|
description: item.description,
|
|
quantity: item.quantity,
|
|
});
|
|
}
|
|
|
|
return deliveryNote;
|
|
}
|
|
|
|
export async function getRepairs(page = 1, perPage = 50, search = "", status = "") {
|
|
const pb = getPocketBase();
|
|
const filters: string[] = [];
|
|
if (search) {
|
|
filters.push(`repair_number ~ "${search}" || item_description ~ "${search}" || fault_description ~ "${search}"`);
|
|
}
|
|
if (status) {
|
|
filters.push(`status = "${status}"`);
|
|
}
|
|
return pb.collection("repairs").getList<Repair>(page, perPage, {
|
|
filter: filters.join(" && "),
|
|
sort: "-created",
|
|
expand: "customer,company,order",
|
|
});
|
|
}
|
|
|
|
export async function generateRepairNumber(): Promise<string> {
|
|
const pb = getPocketBase();
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
const prefix = `REP-${year}-${month}-`;
|
|
|
|
const existing = await pb.collection("repairs").getList<Repair>(1, 1, {
|
|
filter: `repair_number ~ "${prefix}"`,
|
|
sort: "-repair_number",
|
|
});
|
|
|
|
let seq = 1;
|
|
if (existing.items.length > 0) {
|
|
const lastNum = parseInt(existing.items[0].repair_number.split("-").pop() || "0", 10);
|
|
seq = lastNum + 1;
|
|
}
|
|
|
|
return `${prefix}${String(seq).padStart(3, "0")}`;
|
|
}
|
|
|
|
export async function createRepair(data: {
|
|
customer?: string;
|
|
company?: string;
|
|
order?: string;
|
|
item_description: string;
|
|
fault_description: string;
|
|
notes?: string;
|
|
}) {
|
|
const pb = getPocketBase();
|
|
const repairNumber = await generateRepairNumber();
|
|
return pb.collection("repairs").create<Repair>({
|
|
repair_number: repairNumber,
|
|
customer: data.customer || null,
|
|
company: data.company || null,
|
|
order: data.order || null,
|
|
status: "new",
|
|
item_description: data.item_description,
|
|
fault_description: data.fault_description,
|
|
notes: data.notes || "",
|
|
});
|
|
}
|
|
|
|
export async function generateQuoteNumber(): Promise<string> {
|
|
const pb = getPocketBase();
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
const prefix = `Q-${year}-${month}-`;
|
|
|
|
const existing = await pb.collection("quotes").getList<Quote>(1, 1, {
|
|
filter: `quote_number ~ "${prefix}"`,
|
|
sort: "-quote_number",
|
|
});
|
|
|
|
let seq = 1;
|
|
if (existing.items.length > 0) {
|
|
const lastRef = existing.items[0].quote_number;
|
|
const lastNum = parseInt(lastRef.split("-").pop() || "0", 10);
|
|
seq = lastNum + 1;
|
|
}
|
|
|
|
return `${prefix}${String(seq).padStart(3, "0")}`;
|
|
}
|
|
|
|
export async function generateOrderNumber(): Promise<string> {
|
|
const pb = getPocketBase();
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
const prefix = `ORD-${year}-${month}-`;
|
|
|
|
const existing = await pb.collection("orders").getList<Order>(1, 1, {
|
|
filter: `order_number ~ "${prefix}"`,
|
|
sort: "-order_number",
|
|
});
|
|
|
|
let seq = 1;
|
|
if (existing.items.length > 0) {
|
|
const lastNum = parseInt(existing.items[0].order_number.split("-").pop() || "0", 10);
|
|
seq = lastNum + 1;
|
|
}
|
|
|
|
return `${prefix}${String(seq).padStart(3, "0")}`;
|
|
}
|
|
|
|
export async function generatePONumber(): Promise<string> {
|
|
const pb = getPocketBase();
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
const prefix = `PO-${year}-${month}-`;
|
|
|
|
const existing = await pb.collection("purchase_orders").getList<PurchaseOrder>(1, 1, {
|
|
filter: `po_number ~ "${prefix}"`,
|
|
sort: "-po_number",
|
|
});
|
|
|
|
let seq = 1;
|
|
if (existing.items.length > 0) {
|
|
const lastNum = parseInt(existing.items[0].po_number.split("-").pop() || "0", 10);
|
|
seq = lastNum + 1;
|
|
}
|
|
|
|
return `${prefix}${String(seq).padStart(3, "0")}`;
|
|
}
|
|
|
|
export function calculateTotals(items: { selling_price: number; quantity: number }[]) {
|
|
const subtotal = Number(items.reduce((sum, item) => sum + item.selling_price * item.quantity, 0).toFixed(2));
|
|
const vat_amount = Number((subtotal * 0.15).toFixed(2));
|
|
const grand_total = Number((subtotal + vat_amount).toFixed(2));
|
|
return { subtotal, vat_amount, grand_total };
|
|
}
|
|
|
|
export function formatCurrency(amount: number): string {
|
|
return new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR" }).format(amount);
|
|
}
|
|
|
|
export function formatDate(dateStr: string): string {
|
|
if (!dateStr) return "";
|
|
return new Date(dateStr).toLocaleDateString("en-ZA", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
export function exportToCSV(data: Record<string, unknown>[], filename: string) {
|
|
if (data.length === 0) return;
|
|
const headers = Object.keys(data[0]);
|
|
const csv = [
|
|
headers.join(","),
|
|
...data.map((row) =>
|
|
headers
|
|
.map((h) => {
|
|
const val = row[h];
|
|
const str = val === null || val === undefined ? "" : String(val);
|
|
return str.includes(",") || str.includes('"') ? `"${str.replace(/"/g, '""')}"` : str;
|
|
})
|
|
.join(",")
|
|
),
|
|
].join("\n");
|
|
|
|
const blob = new Blob([csv], { type: "text/csv" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `${filename}.csv`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|