Add full sprint pricing workflow and supporting infrastructure: - New sidebar navigation entry for the sprint dashboard - PocketBase category collection schema and migration script - Server API routes for sprint item claims, attachments, counts, and file proxying - Dev identity utilities and UI switcher for multi-user testing - Reusable FilterTabs component replacing ItemStatusTabs - Updated TypeScript types for sprint-related entities Also include: - Fix PocketBase file URL method casing (getUrl → getURL) across codebase - Add development utility, smoke test, and helper scripts - Update package.json with new sprint migration command
316 lines
No EOL
9.3 KiB
TypeScript
316 lines
No EOL
9.3 KiB
TypeScript
/* eslint-disable */
|
|
/**
|
|
* Test data seeder — creates 5 dummy grinders in `price_list` and
|
|
* a fresh test quote with 3 items exercising different matcher
|
|
* scenarios (exact SKU, fuzzy description, no match).
|
|
*
|
|
* Markers (so cleanup is a one-liner later):
|
|
* - All 5 price_list rows have brand = "TEST_GRINDER"
|
|
* - The test quote's description starts with "[TEST-DATA]"
|
|
*
|
|
* Cleanup commands (printed at the end):
|
|
* - Delete the 5 grinders:
|
|
* pb.collection("price_list").delete(ids…)
|
|
* - Delete the test quote (cascades to quote_items via PB rule,
|
|
* or do both explicitly if cascade isn't configured):
|
|
* pb.collection("quotes").delete(quoteId)
|
|
*
|
|
* Run with:
|
|
* env PB_ADMIN_EMAIL=… PB_ADMIN_PASSWORD=… NEXT_PUBLIC_POCKETBASE_URL=…
|
|
* npx tsx scripts/dev/seed-test-grinders.ts
|
|
*/
|
|
import PocketBase from "pocketbase";
|
|
|
|
const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL;
|
|
const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
|
|
const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
|
|
|
|
if (!PB_URL || !ADMIN_EMAIL || !ADMIN_PASSWORD) {
|
|
throw new Error(
|
|
"Set PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, NEXT_PUBLIC_POCKETBASE_URL."
|
|
);
|
|
}
|
|
|
|
const pb = new PocketBase(PB_URL);
|
|
|
|
async function main() {
|
|
await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD).catch((e) => {
|
|
console.error("Admin auth failed:", e);
|
|
process.exit(1);
|
|
});
|
|
|
|
/** Build search_text the same way the migration backfill does:
|
|
* sku + description + brand + category + subcategory + unit +
|
|
* prices + markup + spec kv pairs + synonyms joined. Lowercased,
|
|
* single-space separated. Tier 3 fuzzy matching uses this field. */
|
|
function buildSearchText(row: {
|
|
sku: string;
|
|
description: string;
|
|
brand: string;
|
|
category: string;
|
|
unit: string;
|
|
cost_price: number;
|
|
selling_price: number;
|
|
markup: number;
|
|
specifications?: Record<string, unknown>;
|
|
}): string {
|
|
const bits: string[] = [];
|
|
bits.push(row.sku.toLowerCase());
|
|
bits.push(row.description.toLowerCase());
|
|
bits.push(row.brand.toLowerCase());
|
|
bits.push(row.category.toLowerCase());
|
|
bits.push(row.unit.toLowerCase());
|
|
bits.push(String(row.cost_price));
|
|
bits.push(String(Math.round(row.cost_price)));
|
|
bits.push(String(row.selling_price));
|
|
bits.push(String(Math.round(row.selling_price)));
|
|
bits.push(String(row.markup));
|
|
const specs = row.specifications || {};
|
|
for (const [k, v] of Object.entries(specs)) {
|
|
bits.push(k.toLowerCase());
|
|
if (Array.isArray(v)) {
|
|
for (const item of v) bits.push(String(item).toLowerCase());
|
|
} else if (v != null) {
|
|
bits.push(String(v).toLowerCase());
|
|
}
|
|
}
|
|
return bits.join(" ").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
/** Strip non-alphanumeric, uppercase — the canonical sku_normalised form
|
|
* the matcher uses for Tier 1/Tier 2 equality + substring checks. */
|
|
function normaliseSku(sku: string): string {
|
|
return sku.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
}
|
|
|
|
// JEISA-WESTSCOTT — the dominant supplier in the catalogue. Using it
|
|
// keeps the test data consistent with what the picker shows on existing rows.
|
|
const SUPPLIER_JEISA = "q6hpwbxsicyoiuu";
|
|
|
|
// 5 dummy grinders. Designed to exercise three match tiers:
|
|
// - Exact SKU match: MAK-AG-125-9558
|
|
// - Tier 2 substring: MAK (matches MAK-*)
|
|
// - Tier 3 fuzzy: "angle grinder 125mm" hits multiple rows by brand/category
|
|
// - No-match sanity: a circular saw item won't match
|
|
const GRINDERS = [
|
|
{
|
|
sku: "MAK-AG-125-9558",
|
|
description: "MAKITA 9558NB ANGLE GRINDER 125MM 840W",
|
|
brand: "TEST_GRINDER",
|
|
category: "angle_grinder",
|
|
subcategory: "small",
|
|
unit: "each",
|
|
cost_price: 1450.00,
|
|
selling_price: 1850.00,
|
|
markup: 27.6,
|
|
specifications: {
|
|
diameter_mm: 125,
|
|
power_w: 840,
|
|
synonyms: [
|
|
"makita grinder",
|
|
"9558nb",
|
|
"angle grinder 125mm",
|
|
"small angle grinder",
|
|
"MAKITA 9558NB ANGLE GRINDER 125MM 840W",
|
|
],
|
|
},
|
|
},
|
|
{
|
|
sku: "BOS-AG-125-GWS850",
|
|
description: "BOSCH GWS 850C ANGLE GRINDER 125MM 850W",
|
|
brand: "TEST_GRINDER",
|
|
category: "angle_grinder",
|
|
subcategory: "small",
|
|
unit: "each",
|
|
cost_price: 1395.00,
|
|
selling_price: 1795.00,
|
|
markup: 28.7,
|
|
specifications: {
|
|
diameter_mm: 125,
|
|
power_w: 850,
|
|
synonyms: [
|
|
"bosch grinder",
|
|
"gws 850c",
|
|
"angle grinder 125mm",
|
|
"small angle grinder",
|
|
"BOSCH GWS 850C ANGLE GRINDER 125MM 850W",
|
|
],
|
|
},
|
|
},
|
|
{
|
|
sku: "DEW-AG-125-DWE4257",
|
|
description: "DEWALT DWE4257 ANGLE GRINDER 125MM 1500W",
|
|
brand: "TEST_GRINDER",
|
|
category: "angle_grinder",
|
|
subcategory: "medium",
|
|
unit: "each",
|
|
cost_price: 2895.00,
|
|
selling_price: 3690.00,
|
|
markup: 27.5,
|
|
specifications: {
|
|
diameter_mm: 125,
|
|
power_w: 1500,
|
|
synonyms: [
|
|
"dewalt grinder",
|
|
"dwe4257",
|
|
"angle grinder 125mm",
|
|
"medium angle grinder",
|
|
"DEWALT DWE4257 ANGLE GRINDER 125MM 1500W",
|
|
],
|
|
},
|
|
},
|
|
{
|
|
sku: "MAK-AG-125-9558HD",
|
|
description: "MAKITA 9558PB ANGLE GRINDER 125MM HEAVY DUTY",
|
|
brand: "TEST_GRINDER",
|
|
category: "angle_grinder",
|
|
subcategory: "small",
|
|
unit: "each",
|
|
cost_price: 1750.00,
|
|
selling_price: 2250.00,
|
|
markup: 28.6,
|
|
specifications: {
|
|
diameter_mm: 125,
|
|
power_w: 840,
|
|
synonyms: [
|
|
"makita grinder",
|
|
"9558pb",
|
|
"angle grinder 125mm",
|
|
"small angle grinder",
|
|
"MAKITA 9558PB ANGLE GRINDER 125MM HEAVY DUTY",
|
|
],
|
|
},
|
|
},
|
|
{
|
|
sku: "GEN-DISC-125-CUT",
|
|
description: "GENERIC 125MM CUTTING DISC FOR ANGLE GRINDER",
|
|
brand: "TEST_GRINDER",
|
|
category: "angle_grinder",
|
|
subcategory: "consumable",
|
|
unit: "each",
|
|
cost_price: 18.50,
|
|
selling_price: 28.00,
|
|
markup: 51.4,
|
|
specifications: {
|
|
diameter_mm: 125,
|
|
type: "cutting_disc",
|
|
synonyms: [
|
|
"cutting disc",
|
|
"125mm disc",
|
|
"angle grinder disc",
|
|
"GENERIC 125MM CUTTING DISC FOR ANGLE GRINDER",
|
|
],
|
|
},
|
|
},
|
|
];
|
|
|
|
console.log("=== Inserting 5 test grinders ===");
|
|
const grinderIds: string[] = [];
|
|
for (const g of GRINDERS) {
|
|
const payload = {
|
|
sku: g.sku,
|
|
sku_normalised: normaliseSku(g.sku),
|
|
description: g.description,
|
|
brand: g.brand,
|
|
category: g.category,
|
|
subcategory: g.subcategory,
|
|
unit: g.unit,
|
|
cost_price: g.cost_price,
|
|
selling_price: g.selling_price,
|
|
markup: g.markup,
|
|
supplier: SUPPLIER_JEISA,
|
|
specifications: g.specifications,
|
|
search_text: buildSearchText({
|
|
sku: g.sku,
|
|
description: g.description,
|
|
brand: g.brand,
|
|
category: g.category,
|
|
unit: g.unit,
|
|
cost_price: g.cost_price,
|
|
selling_price: g.selling_price,
|
|
markup: g.markup,
|
|
specifications: g.specifications,
|
|
}),
|
|
};
|
|
const created = await pb.collection("price_list").create(payload);
|
|
grinderIds.push(created.id);
|
|
console.log(` ✓ ${g.sku.padEnd(22)} id=${created.id} R${g.cost_price}`);
|
|
}
|
|
|
|
// === Quote + items ===
|
|
// Use David Els as the customer (real id probed earlier).
|
|
const CUSTOMER_ID = "jjjkadudyctuke5";
|
|
const QUOTE_NUMBER = "Q" + Date.now();
|
|
|
|
console.log(`\n=== Creating test quote ${QUOTE_NUMBER} ===`);
|
|
const quote = await pb.collection("quotes").create({
|
|
quote_number: QUOTE_NUMBER,
|
|
customer: CUSTOMER_ID,
|
|
status: "claimed",
|
|
company_letterhead: "CNL",
|
|
description:
|
|
"[TEST-DATA] 3 grinders for matcher testing — delete after use. " +
|
|
"Item 1 = exact SKU match (Tier 1), Item 2 = fuzzy description " +
|
|
"match (Tier 3, opens picker), Item 3 = no-match sanity check.",
|
|
});
|
|
console.log(` ✓ quote id=${quote.id}`);
|
|
|
|
// 3 quote items designed to exercise the matcher
|
|
const ITEMS = [
|
|
{
|
|
description: "Makita angle grinder 125mm 840W",
|
|
sku: "MAK-AG-125-9558",
|
|
note: "Exact SKU match → expect Tier 1 auto-fill + auto-save",
|
|
},
|
|
{
|
|
description: "angle grinder 125mm",
|
|
sku: "",
|
|
note: "Fuzzy Tier 3 — expect picker with 4 candidates (Makita/Bosch/DeWalt/Generic-disc)",
|
|
},
|
|
{
|
|
description: "circular saw blade 184mm",
|
|
sku: "",
|
|
note: "No-match sanity — expect toast 'no catalogue row matched'",
|
|
},
|
|
];
|
|
|
|
console.log(`\n=== Creating 3 quote_items ===`);
|
|
const itemIds: string[] = [];
|
|
for (const it of ITEMS) {
|
|
const created = await pb.collection("quote_items").create({
|
|
quote: quote.id,
|
|
quote_number: QUOTE_NUMBER,
|
|
description: it.description,
|
|
sku: it.sku,
|
|
quantity: 1,
|
|
cost_price: 0,
|
|
selling_price: 0,
|
|
markup: 0,
|
|
pricing_source: "manual",
|
|
is_processed: false,
|
|
user_edited: false,
|
|
});
|
|
itemIds.push(created.id);
|
|
console.log(` ✓ ${it.sku || "(no sku)"} — "${it.description}"`);
|
|
console.log(` id=${created.id}`);
|
|
console.log(` ${it.note}`);
|
|
}
|
|
|
|
console.log("\n=== DONE ===");
|
|
console.log(`Quote URL: http://localhost:4000/quotes/${quote.id}/process`);
|
|
console.log(`Quote #: ${QUOTE_NUMBER}`);
|
|
console.log(`Grinder ids: ${grinderIds.join(", ")}`);
|
|
console.log(`Item ids: ${itemIds.join(", ")}`);
|
|
console.log(`\n=== CLEANUP ===`);
|
|
console.log(`Delete the 5 grinders (filter by brand=TEST_GRINDER):`);
|
|
console.log(` pb.collection("price_list").getFullList({ filter: 'brand="TEST_GRINDER"' })`);
|
|
console.log(` → for each, pb.collection("price_list").delete(id)`);
|
|
console.log(`Delete the test quote (cascades to items):`);
|
|
console.log(` pb.collection("quotes").delete("${quote.id}")`);
|
|
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error(e);
|
|
process.exit(1);
|
|
}); |