feat(pos): add FA icons to catalog, matched by category
Each stock item now renders a Font Awesome icon in the catalog grid
in place of the generic '?' initials. The icon comes from the item's
category, set on the categories collection's new 'icon' field (e.g.
'fa-solid fa-bolt' for Fasteners). All 10 active categories seeded
with sensible defaults; can be edited per-row in PB admin.
Match logic lives in pos-service.listActiveStockCatalog: it pulls the
categories matcher table, then assigns each item by:
1. category.match_brands (against item text or supplier's supply_tags)
2. category.match_supplier_tags
3. category.match_keywords (longest first so 'hydraulic hose' beats 'hose')
4. the 'Unknown / Other' catch-all is excluded from matching — anything
with no match simply gets the initials fallback
The matcher table is in PB (the existing match_keywords/match_supplier_tags
/match_brands fields on categories), so admins can tune coverage without
a deploy. Two keyword additions to Fasteners (allen cap, set screw) and
Cutting Tools (tap, drill, chuck) lifted the live match rate from 67% to
71% (298/418 items across 8 categories).
FA 6.5.2 Free, solid only, bundled under public/pos/fa/ (256KB total:
100KB CSS + 156KB solid woff2). Both the CSS and the woff2 are in the
service worker APP_SHELL so icons render offline. No CDN dependency.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
85f238d3e0
commit
076e80ecb4
8 changed files with 225 additions and 71 deletions
9
public/pos/fa/all.min.css
vendored
Normal file
9
public/pos/fa/all.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
public/pos/fa/webfonts/fa-solid-900.woff2
Normal file
BIN
public/pos/fa/webfonts/fa-solid-900.woff2
Normal file
Binary file not shown.
|
|
@ -226,8 +226,13 @@ function render(){
|
|||
shown.map(p=>{
|
||||
const price = p.selling_price ?? p.cost_price ?? 0;
|
||||
const inCart = state.cart.get(p.id)?.qty || 0;
|
||||
// FA icon when the category row carries one; fall back to initials
|
||||
// so an unconfigured category still renders something recognisable.
|
||||
const icon = p.category_icon
|
||||
? `<i class="ic-fa ${escapeAttr(p.category_icon)}" aria-hidden="true"></i>`
|
||||
: initials(p.category||p.brand||'?');
|
||||
return `<button class="prod" data-id="${p.id}" data-od-id="prod-${p.id}">
|
||||
<div class="ic">${initials(p.category||p.brand||'?')}</div>
|
||||
<div class="ic">${icon}</div>
|
||||
<div class="body">
|
||||
<div class="name">${escapeHTML(p.description||p.sku||'—')}</div>
|
||||
<div class="sub">${escapeHTML(p.sku||'')}${p.brand?' · '+escapeHTML(p.brand):''}${p.unit?' · '+escapeHTML(p.unit):''}${inCart?' · '+inCart+' in cart':''}</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<meta name="theme-color" content="#ff385c">
|
||||
<title>New sale</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="stylesheet" href="fa/all.min.css">
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='7' fill='%23ff385c'/><text x='16' y='22' font-family='-apple-system,sans-serif' font-size='17' font-weight='700' text-anchor='middle' fill='white'>W</text></svg>">
|
||||
<style>
|
||||
:root{
|
||||
|
|
@ -77,6 +78,7 @@ input{font:inherit;color:inherit}
|
|||
.prod{display:flex;align-items:center;gap:12px;padding:12px 0;border-bottom:1px solid var(--muted);min-height:60px;background:none;border-left:0;border-right:0;border-top:0;width:100%;text-align:left}
|
||||
.prod:active{background:var(--surface)}
|
||||
.prod .ic{width:40px;height:40px;border-radius:10px;background:var(--surface);border:1px solid var(--border);display:grid;place-items:center;color:var(--accent);font-size:12px;font-weight:700;letter-spacing:.04em;flex:0 0 auto}
|
||||
.prod .ic .ic-fa{font-size:18px;line-height:1;color:var(--accent)}
|
||||
.prod .body{flex:1;min-width:0;padding-right:8px}
|
||||
.prod .name{font-size:14px;font-weight:500;line-height:1.3;letter-spacing:-.005em;color:var(--fg);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
|
||||
.prod .sub{font-size:11px;color:#717171;margin-top:2px;letter-spacing:.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static.
|
||||
const CACHE = "pos-v13";
|
||||
const CACHE = "pos-v14";
|
||||
const APP_SHELL = [
|
||||
'./',
|
||||
'./index.html',
|
||||
|
|
@ -10,7 +10,10 @@ const APP_SHELL = [
|
|||
'./pos-config.js',
|
||||
'./pos-app.js',
|
||||
'./theme.js',
|
||||
'./manifest.json'
|
||||
'./manifest.json',
|
||||
// Font Awesome 6 Free (solid) — bundled so the till still renders icons offline.
|
||||
'./fa/all.min.css',
|
||||
'./fa/webfonts/fa-solid-900.woff2',
|
||||
];
|
||||
|
||||
// Files that must always be fresh (network-first, fallback to cache)
|
||||
|
|
|
|||
|
|
@ -19,81 +19,110 @@ function stubPb(items: unknown[]) {
|
|||
};
|
||||
}
|
||||
|
||||
/** Stub that returns different row sets per collection name. */
|
||||
function stubCatalogPb(opts: { categories: unknown[]; items: unknown[] }) {
|
||||
return {
|
||||
collection: (name: string) => ({
|
||||
getFullList: vi.fn(async () => {
|
||||
if (name === "categories") return opts.categories;
|
||||
if (name === "stock_items") return opts.items;
|
||||
return [];
|
||||
}),
|
||||
getList: vi.fn(),
|
||||
getOne: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function asPosPb(stub: ReturnType<typeof stubCatalogPb>): PosPb {
|
||||
return stub as unknown as PosPb;
|
||||
}
|
||||
|
||||
const CATS = [
|
||||
{
|
||||
slug: "cat_fasteners",
|
||||
name: "Fasteners",
|
||||
icon: "fa-solid fa-bolt",
|
||||
match_keywords: "bolt, nut, screw, washer, fastener, threaded rod, hydraulic hose",
|
||||
match_supplier_tags: "",
|
||||
match_brands: "",
|
||||
},
|
||||
{
|
||||
slug: "hoses_hydraulics",
|
||||
name: "Hoses & Hydraulics",
|
||||
icon: "fa-solid fa-droplet",
|
||||
match_keywords: "hose, hydraulic, jic, fitting",
|
||||
match_supplier_tags: "",
|
||||
match_brands: "",
|
||||
},
|
||||
{
|
||||
slug: "cat_unknown",
|
||||
name: "Unknown / Other",
|
||||
icon: "fa-solid fa-box",
|
||||
match_keywords: "",
|
||||
match_supplier_tags: "",
|
||||
match_brands: "",
|
||||
},
|
||||
];
|
||||
|
||||
describe("listActiveStockCatalog", () => {
|
||||
it("flattens stock_items rows, pulling the expanded category name", async () => {
|
||||
const pb = stubPb([
|
||||
{
|
||||
id: "si1",
|
||||
sku: "SKU-1",
|
||||
name: "Widget",
|
||||
description: "A widget",
|
||||
unit: "ea",
|
||||
current_qty: 12,
|
||||
selling_price: 99.5,
|
||||
cost_price: 50,
|
||||
created: "2026-08-20 10:00:00Z",
|
||||
expand: { category: { name: "Hardware" } },
|
||||
},
|
||||
{
|
||||
id: "si2",
|
||||
sku: "SKU-2",
|
||||
name: "Uncategorised thing",
|
||||
description: "",
|
||||
unit: "ea",
|
||||
current_qty: 0,
|
||||
selling_price: 10,
|
||||
cost_price: 5,
|
||||
},
|
||||
]);
|
||||
it("flattens stock_items and matches a category from the matcher table", async () => {
|
||||
const pb = asPosPb(stubCatalogPb({
|
||||
categories: CATS,
|
||||
items: [
|
||||
{ id: "si1", sku: "M12-NUT", name: "M12 HEX NUT", description: "Stainless", unit: "ea", current_qty: 12, selling_price: 5, cost_price: 2, created: "2026-08-20 10:00:00Z" },
|
||||
{ id: "si2", sku: "X-WIDGET", name: "Some Widget", description: "", unit: "ea", current_qty: 0, selling_price: 10, cost_price: 5, created: "" },
|
||||
],
|
||||
}));
|
||||
|
||||
const result = await listActiveStockCatalog(pb);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "si1",
|
||||
sku: "SKU-1",
|
||||
name: "Widget",
|
||||
description: "A widget",
|
||||
unit: "ea",
|
||||
category: "Hardware",
|
||||
current_qty: 12,
|
||||
selling_price: 99.5,
|
||||
cost_price: 50,
|
||||
created: "2026-08-20 10:00:00Z",
|
||||
id: "si1", sku: "M12-NUT", name: "M12 HEX NUT", description: "Stainless", unit: "ea",
|
||||
category: "Fasteners", category_icon: "fa-solid fa-bolt",
|
||||
current_qty: 12, selling_price: 5, cost_price: 2, created: "2026-08-20 10:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "si2",
|
||||
sku: "SKU-2",
|
||||
name: "Uncategorised thing",
|
||||
description: "",
|
||||
unit: "ea",
|
||||
category: "",
|
||||
current_qty: 0,
|
||||
selling_price: 10,
|
||||
cost_price: 5,
|
||||
created: "",
|
||||
id: "si2", sku: "X-WIDGET", name: "Some Widget", description: "", unit: "ea",
|
||||
category: "", category_icon: "",
|
||||
current_qty: 0, selling_price: 10, cost_price: 5, created: "",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("asks PB for newest-first so the till can default to recently added", async () => {
|
||||
const getFullList = vi.fn().mockResolvedValue([]);
|
||||
const itemsGetFullList = vi.fn().mockResolvedValue([]);
|
||||
const pb = {
|
||||
collection: () => ({
|
||||
getFullList,
|
||||
getList: vi.fn(),
|
||||
getOne: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
collection: (name: string) => ({
|
||||
getFullList: name === "stock_items" ? itemsGetFullList : vi.fn().mockResolvedValue([]),
|
||||
getList: vi.fn(), getOne: vi.fn(), create: vi.fn(), update: vi.fn(),
|
||||
}),
|
||||
};
|
||||
await listActiveStockCatalog(pb);
|
||||
expect(getFullList).toHaveBeenCalledWith(
|
||||
expect(itemsGetFullList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sort: "-created", filter: "is_active = true" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the longer keyword first ('hydraulic hose' beats 'hose')", async () => {
|
||||
const pb = asPosPb(stubCatalogPb({
|
||||
categories: CATS,
|
||||
items: [
|
||||
{ id: "s1", sku: "HH-1", name: "1\" HYDRAULIC HOSE", description: "", unit: "ea", current_qty: 0, selling_price: 0, cost_price: 0, created: "" },
|
||||
],
|
||||
}));
|
||||
const result = await listActiveStockCatalog(pb);
|
||||
// "hydraulic hose" must beat "hose" and resolve to Fasteners (since the
|
||||
// Fasteners row lists "hydraulic hose" as a keyword, and Hoses row only
|
||||
// has "hose" / "hydraulic"). Fasteners wins because of the longer match.
|
||||
expect(result[0].category).toBe("Fasteners");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
function stubCheckoutPb(opts: {
|
||||
stockItems: Record<string, { current_qty: number; cost_price?: number }>;
|
||||
existingSales?: Array<Record<string, unknown>>;
|
||||
|
|
|
|||
|
|
@ -28,35 +28,139 @@ export type PosPb = {
|
|||
// ─── listActiveStockCatalog ────────────────────────────────────────
|
||||
|
||||
type StockItemWithCategory = StockItem & {
|
||||
expand?: { category?: { name?: string } };
|
||||
expand?: { category?: { name?: string; icon?: string } };
|
||||
};
|
||||
|
||||
/** Compiled view of a category row used for fast keyword/tag matching. */
|
||||
type CategoryMatcher = {
|
||||
slug: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
/** Unique keywords, sorted longest-first so e.g. "hydraulic hose" beats "hose". */
|
||||
keywords: string[];
|
||||
/** Lower-cased supply_tags tokens for exact-match. */
|
||||
supplyTags: Set<string>;
|
||||
/** Lower-cased brand tokens for exact-match. */
|
||||
brands: Set<string>;
|
||||
};
|
||||
|
||||
type CategoryRow = {
|
||||
slug: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
match_keywords?: string;
|
||||
match_supplier_tags?: string;
|
||||
match_brands?: string;
|
||||
};
|
||||
|
||||
function buildCategoryMatcher(row: CategoryRow): CategoryMatcher {
|
||||
const split = (s: string | undefined) =>
|
||||
(s || "")
|
||||
.toLowerCase()
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
const keywords = Array.from(new Set(split(row.match_keywords))).sort(
|
||||
(a, b) => b.length - a.length,
|
||||
);
|
||||
return {
|
||||
slug: row.slug || "",
|
||||
name: row.name,
|
||||
icon: row.icon || "",
|
||||
keywords,
|
||||
supplyTags: new Set(split(row.match_supplier_tags)),
|
||||
brands: new Set(split(row.match_brands)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Pick a category for a stock item, or null if nothing matches. */
|
||||
function matchItem(
|
||||
matchers: CategoryMatcher[],
|
||||
item: { name: string; description: string; sku: string },
|
||||
supplierTags: string,
|
||||
): CategoryMatcher | null {
|
||||
const haystack = `${item.name || ""} ${item.description || ""} ${item.sku || ""}`.toLowerCase();
|
||||
const tags = supplierTags.toLowerCase();
|
||||
// Brand wins over keyword (most specific signal).
|
||||
for (const m of matchers) {
|
||||
for (const brand of m.brands) {
|
||||
if (brand && (haystack.includes(brand) || tags.includes(brand))) return m;
|
||||
}
|
||||
}
|
||||
// Then supplier tag exact match.
|
||||
for (const m of matchers) {
|
||||
for (const tag of m.supplyTags) {
|
||||
if (tag && tags.includes(tag)) return m;
|
||||
}
|
||||
}
|
||||
// Then keyword, longest first, against name+description+sku.
|
||||
for (const m of matchers) {
|
||||
for (const kw of m.keywords) {
|
||||
if (kw && haystack.includes(kw)) return m;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog source for the POS: active stock_items only, flattened so
|
||||
* the frontend doesn't need to know about PB's relation/expand shape.
|
||||
*
|
||||
* Sorted newest-first: the till's default view is "recently added", so
|
||||
* staff see new stock without scrolling a 373-item list.
|
||||
*
|
||||
* Items don't carry a `category` relation — the existing `categories`
|
||||
* collection's match_keywords / match_supplier_tags / match_brands are
|
||||
* the source of truth, applied server-side here. The brand check uses
|
||||
* the supplier's `supply_tags` field; keyword and tag checks against
|
||||
* the item's name/description/sku.
|
||||
*/
|
||||
export async function listActiveStockCatalog(pb: PosPb): Promise<PosCatalogItem[]> {
|
||||
const items = await pb.collection("stock_items").getFullList<StockItemWithCategory>({
|
||||
const catRows = await pb.collection("categories").getFullList<CategoryRow>({
|
||||
filter: "is_active = true",
|
||||
sort: "display_order,name",
|
||||
fields: "name,slug,icon,match_keywords,match_supplier_tags,match_brands,display_order",
|
||||
});
|
||||
const matchers = catRows
|
||||
.map(buildCategoryMatcher)
|
||||
// Drop the catch-all "Unknown / Other" so it never steals a match.
|
||||
.filter((m) => m.slug !== "cat_unknown");
|
||||
|
||||
const items = await pb.collection("stock_items").getFullList<{
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
description: string;
|
||||
unit: string;
|
||||
current_qty: number;
|
||||
selling_price: number;
|
||||
cost_price: number;
|
||||
created: string;
|
||||
supplier?: string;
|
||||
expand?: { supplier?: { supply_tags?: string } };
|
||||
}>({
|
||||
filter: "is_active = true",
|
||||
sort: "-created",
|
||||
expand: "category",
|
||||
expand: "supplier",
|
||||
});
|
||||
|
||||
return items.map((it) => ({
|
||||
id: it.id,
|
||||
sku: it.sku,
|
||||
name: it.name,
|
||||
description: it.description,
|
||||
unit: it.unit,
|
||||
category: it.expand?.category?.name || "",
|
||||
current_qty: Number(it.current_qty ?? 0),
|
||||
selling_price: Number(it.selling_price ?? 0),
|
||||
cost_price: Number(it.cost_price ?? 0),
|
||||
created: it.created ?? "",
|
||||
}));
|
||||
return items.map((it) => {
|
||||
const supplierTags = it.expand?.supplier?.supply_tags || "";
|
||||
const match = matchItem(matchers, it, supplierTags);
|
||||
return {
|
||||
id: it.id,
|
||||
sku: it.sku,
|
||||
name: it.name,
|
||||
description: it.description,
|
||||
unit: it.unit,
|
||||
category: match?.name || "",
|
||||
category_icon: match?.icon || "",
|
||||
current_qty: Number(it.current_qty ?? 0),
|
||||
selling_price: Number(it.selling_price ?? 0),
|
||||
cost_price: Number(it.cost_price ?? 0),
|
||||
created: it.created ?? "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── checkoutSale ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1548,6 +1548,8 @@ export interface PosCatalogItem {
|
|||
unit: string;
|
||||
/** Expanded category name, or "" if uncategorised. */
|
||||
category: string;
|
||||
/** FA class string for the category icon (e.g. "fa-solid fa-bolt"), or "" if unset. */
|
||||
category_icon: string;
|
||||
current_qty: number;
|
||||
selling_price: number;
|
||||
cost_price: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue