@pylonsync/create-pylon 0.3.344 → 0.3.346
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-pylon.js +35 -0
- package/package.json +1 -1
- package/templates/crm/AGENTS.md +116 -0
- package/templates/crm/CLAUDE.md +7 -0
- package/templates/crm/README.md +96 -0
- package/templates/crm/app/companies/companies-view.tsx +129 -0
- package/templates/crm/app/companies/page.tsx +17 -0
- package/templates/crm/app/contacts/contacts-view.tsx +139 -0
- package/templates/crm/app/contacts/page.tsx +17 -0
- package/templates/crm/app/deals/[id]/deal-view.tsx +189 -0
- package/templates/crm/app/deals/[id]/page.tsx +23 -0
- package/templates/crm/app/error.tsx +43 -0
- package/templates/crm/app/globals.css +201 -0
- package/templates/crm/app/layout.tsx +31 -0
- package/templates/crm/app/login/auth-form.tsx +122 -0
- package/templates/crm/app/login/page.tsx +38 -0
- package/templates/crm/app/not-found.tsx +29 -0
- package/templates/crm/app/page.tsx +38 -0
- package/templates/crm/app/pipeline-view.tsx +99 -0
- package/templates/crm/app/robots.ts +12 -0
- package/templates/crm/app/sitemap.ts +27 -0
- package/templates/crm/app/workspace.tsx +231 -0
- package/templates/crm/app.ts +171 -0
- package/templates/crm/bunfig +4 -0
- package/templates/crm/components/activity-timeline.tsx +133 -0
- package/templates/crm/components/avatar.tsx +40 -0
- package/templates/crm/components/board-skeleton.tsx +44 -0
- package/templates/crm/components/command-palette.tsx +162 -0
- package/templates/crm/components/data-table.tsx +90 -0
- package/templates/crm/components/deal-card.tsx +91 -0
- package/templates/crm/components/deal-dialog.tsx +169 -0
- package/templates/crm/components/empty-state.tsx +43 -0
- package/templates/crm/components/kbd.tsx +23 -0
- package/templates/crm/components/metrics-bar.tsx +46 -0
- package/templates/crm/components/page-header.tsx +36 -0
- package/templates/crm/components/pipeline-board.tsx +105 -0
- package/templates/crm/components/record-dialog.tsx +126 -0
- package/templates/crm/components/sidebar.tsx +112 -0
- package/templates/crm/components/stage-badge.tsx +33 -0
- package/templates/crm/components/ui/badge.tsx +37 -0
- package/templates/crm/components/ui/button.tsx +56 -0
- package/templates/crm/components/ui/card.tsx +90 -0
- package/templates/crm/components/ui/dialog.tsx +110 -0
- package/templates/crm/components/ui/input.tsx +30 -0
- package/templates/crm/components/ui/label.tsx +21 -0
- package/templates/crm/components/ui/select.tsx +39 -0
- package/templates/crm/components/ui/table.tsx +124 -0
- package/templates/crm/components/ui/textarea.tsx +24 -0
- package/templates/crm/components.json +20 -0
- package/templates/crm/functions/createDeal.ts +59 -0
- package/templates/crm/functions/logActivity.ts +49 -0
- package/templates/crm/functions/moveDeal.ts +53 -0
- package/templates/crm/functions/seedWorkspace.ts +63 -0
- package/templates/crm/gitignore +10 -0
- package/templates/crm/lib/pipeline.ts +221 -0
- package/templates/crm/lib/search.ts +78 -0
- package/templates/crm/lib/seed.ts +130 -0
- package/templates/crm/lib/utils.ts +10 -0
- package/templates/crm/package.json +39 -0
- package/templates/crm/tests/components.test.tsx +245 -0
- package/templates/crm/tests/pipeline.test.ts +219 -0
- package/templates/crm/tests/search.test.ts +83 -0
- package/templates/crm/tests/setup.ts +5 -0
- package/templates/crm/tsconfig.json +33 -0
- package/templates/helpdesk/AGENTS.md +116 -0
- package/templates/helpdesk/CLAUDE.md +7 -0
- package/templates/helpdesk/README.md +90 -0
- package/templates/helpdesk/app/customers/customers-view.tsx +140 -0
- package/templates/helpdesk/app/customers/page.tsx +17 -0
- package/templates/helpdesk/app/error.tsx +43 -0
- package/templates/helpdesk/app/globals.css +201 -0
- package/templates/helpdesk/app/inbox-view.tsx +147 -0
- package/templates/helpdesk/app/layout.tsx +31 -0
- package/templates/helpdesk/app/login/auth-form.tsx +122 -0
- package/templates/helpdesk/app/login/page.tsx +38 -0
- package/templates/helpdesk/app/not-found.tsx +29 -0
- package/templates/helpdesk/app/page.tsx +38 -0
- package/templates/helpdesk/app/robots.ts +12 -0
- package/templates/helpdesk/app/sitemap.ts +27 -0
- package/templates/helpdesk/app/tickets/[id]/page.tsx +23 -0
- package/templates/helpdesk/app/tickets/[id]/ticket-view.tsx +164 -0
- package/templates/helpdesk/app/workspace.tsx +210 -0
- package/templates/helpdesk/app.ts +142 -0
- package/templates/helpdesk/bunfig +4 -0
- package/templates/helpdesk/components/avatar.tsx +40 -0
- package/templates/helpdesk/components/command-palette.tsx +162 -0
- package/templates/helpdesk/components/data-table.tsx +90 -0
- package/templates/helpdesk/components/empty-state.tsx +43 -0
- package/templates/helpdesk/components/kbd.tsx +23 -0
- package/templates/helpdesk/components/new-ticket-dialog.tsx +147 -0
- package/templates/helpdesk/components/page-header.tsx +36 -0
- package/templates/helpdesk/components/priority-badge.tsx +61 -0
- package/templates/helpdesk/components/record-dialog.tsx +126 -0
- package/templates/helpdesk/components/sidebar.tsx +111 -0
- package/templates/helpdesk/components/sla-indicator.tsx +47 -0
- package/templates/helpdesk/components/thread.tsx +143 -0
- package/templates/helpdesk/components/ticket-list.tsx +78 -0
- package/templates/helpdesk/components/ui/badge.tsx +37 -0
- package/templates/helpdesk/components/ui/button.tsx +56 -0
- package/templates/helpdesk/components/ui/card.tsx +90 -0
- package/templates/helpdesk/components/ui/dialog.tsx +110 -0
- package/templates/helpdesk/components/ui/input.tsx +30 -0
- package/templates/helpdesk/components/ui/label.tsx +21 -0
- package/templates/helpdesk/components/ui/select.tsx +39 -0
- package/templates/helpdesk/components/ui/table.tsx +124 -0
- package/templates/helpdesk/components/ui/textarea.tsx +24 -0
- package/templates/helpdesk/components.json +20 -0
- package/templates/helpdesk/functions/createTicket.ts +62 -0
- package/templates/helpdesk/functions/replyToTicket.ts +55 -0
- package/templates/helpdesk/functions/seedWorkspace.ts +52 -0
- package/templates/helpdesk/functions/setTicketState.ts +58 -0
- package/templates/helpdesk/gitignore +10 -0
- package/templates/helpdesk/lib/format.ts +53 -0
- package/templates/helpdesk/lib/search.ts +78 -0
- package/templates/helpdesk/lib/seed.ts +185 -0
- package/templates/helpdesk/lib/tickets.ts +184 -0
- package/templates/helpdesk/lib/utils.ts +10 -0
- package/templates/helpdesk/package.json +39 -0
- package/templates/helpdesk/tests/components.test.tsx +202 -0
- package/templates/helpdesk/tests/search.test.ts +83 -0
- package/templates/helpdesk/tests/setup.ts +5 -0
- package/templates/helpdesk/tests/tickets.test.ts +215 -0
- package/templates/helpdesk/tsconfig.json +33 -0
- package/templates/inventory/AGENTS.md +116 -0
- package/templates/inventory/CLAUDE.md +7 -0
- package/templates/inventory/README.md +103 -0
- package/templates/inventory/app/error.tsx +43 -0
- package/templates/inventory/app/globals.css +201 -0
- package/templates/inventory/app/layout.tsx +31 -0
- package/templates/inventory/app/login/auth-form.tsx +122 -0
- package/templates/inventory/app/login/page.tsx +38 -0
- package/templates/inventory/app/movements/movements-view.tsx +103 -0
- package/templates/inventory/app/movements/page.tsx +17 -0
- package/templates/inventory/app/not-found.tsx +29 -0
- package/templates/inventory/app/page.tsx +34 -0
- package/templates/inventory/app/products/[id]/page.tsx +23 -0
- package/templates/inventory/app/products/[id]/product-view.tsx +178 -0
- package/templates/inventory/app/products-view.tsx +213 -0
- package/templates/inventory/app/robots.ts +12 -0
- package/templates/inventory/app/sitemap.ts +27 -0
- package/templates/inventory/app/workspace.tsx +172 -0
- package/templates/inventory/app.ts +154 -0
- package/templates/inventory/bunfig +4 -0
- package/templates/inventory/components/avatar.tsx +40 -0
- package/templates/inventory/components/command-palette.tsx +162 -0
- package/templates/inventory/components/data-table.tsx +90 -0
- package/templates/inventory/components/empty-state.tsx +43 -0
- package/templates/inventory/components/kbd.tsx +23 -0
- package/templates/inventory/components/movement-dialog.tsx +182 -0
- package/templates/inventory/components/page-header.tsx +36 -0
- package/templates/inventory/components/record-dialog.tsx +126 -0
- package/templates/inventory/components/sidebar.tsx +111 -0
- package/templates/inventory/components/stock-level.tsx +47 -0
- package/templates/inventory/components/summary-bar.tsx +56 -0
- package/templates/inventory/components/ui/badge.tsx +37 -0
- package/templates/inventory/components/ui/button.tsx +56 -0
- package/templates/inventory/components/ui/card.tsx +90 -0
- package/templates/inventory/components/ui/dialog.tsx +110 -0
- package/templates/inventory/components/ui/input.tsx +30 -0
- package/templates/inventory/components/ui/label.tsx +21 -0
- package/templates/inventory/components/ui/select.tsx +39 -0
- package/templates/inventory/components/ui/table.tsx +124 -0
- package/templates/inventory/components/ui/textarea.tsx +24 -0
- package/templates/inventory/components.json +20 -0
- package/templates/inventory/functions/recordMovement.ts +72 -0
- package/templates/inventory/functions/seedWorkspace.ts +40 -0
- package/templates/inventory/gitignore +10 -0
- package/templates/inventory/lib/format.ts +53 -0
- package/templates/inventory/lib/search.ts +78 -0
- package/templates/inventory/lib/seed.ts +102 -0
- package/templates/inventory/lib/stock.ts +207 -0
- package/templates/inventory/lib/utils.ts +10 -0
- package/templates/inventory/package.json +39 -0
- package/templates/inventory/tests/components.test.tsx +155 -0
- package/templates/inventory/tests/search.test.ts +83 -0
- package/templates/inventory/tests/setup.ts +5 -0
- package/templates/inventory/tests/stock.test.ts +255 -0
- package/templates/inventory/tsconfig.json +33 -0
- package/templates/invoices/AGENTS.md +116 -0
- package/templates/invoices/CLAUDE.md +7 -0
- package/templates/invoices/README.md +99 -0
- package/templates/invoices/app/clients/clients-view.tsx +130 -0
- package/templates/invoices/app/clients/page.tsx +17 -0
- package/templates/invoices/app/error.tsx +43 -0
- package/templates/invoices/app/globals.css +201 -0
- package/templates/invoices/app/invoices/[id]/invoice-view.tsx +253 -0
- package/templates/invoices/app/invoices/[id]/page.tsx +23 -0
- package/templates/invoices/app/invoices-view.tsx +183 -0
- package/templates/invoices/app/layout.tsx +31 -0
- package/templates/invoices/app/login/auth-form.tsx +122 -0
- package/templates/invoices/app/login/page.tsx +38 -0
- package/templates/invoices/app/not-found.tsx +29 -0
- package/templates/invoices/app/page.tsx +36 -0
- package/templates/invoices/app/robots.ts +12 -0
- package/templates/invoices/app/sitemap.ts +27 -0
- package/templates/invoices/app/workspace.tsx +174 -0
- package/templates/invoices/app.ts +169 -0
- package/templates/invoices/bunfig +4 -0
- package/templates/invoices/components/avatar.tsx +40 -0
- package/templates/invoices/components/command-palette.tsx +162 -0
- package/templates/invoices/components/data-table.tsx +90 -0
- package/templates/invoices/components/empty-state.tsx +43 -0
- package/templates/invoices/components/kbd.tsx +23 -0
- package/templates/invoices/components/line-items.tsx +172 -0
- package/templates/invoices/components/page-header.tsx +36 -0
- package/templates/invoices/components/payment-dialog.tsx +132 -0
- package/templates/invoices/components/record-dialog.tsx +126 -0
- package/templates/invoices/components/sidebar.tsx +111 -0
- package/templates/invoices/components/status-badge.tsx +35 -0
- package/templates/invoices/components/summary-bar.tsx +65 -0
- package/templates/invoices/components/totals-panel.tsx +70 -0
- package/templates/invoices/components/ui/badge.tsx +37 -0
- package/templates/invoices/components/ui/button.tsx +56 -0
- package/templates/invoices/components/ui/card.tsx +90 -0
- package/templates/invoices/components/ui/dialog.tsx +110 -0
- package/templates/invoices/components/ui/input.tsx +30 -0
- package/templates/invoices/components/ui/label.tsx +21 -0
- package/templates/invoices/components/ui/select.tsx +39 -0
- package/templates/invoices/components/ui/table.tsx +124 -0
- package/templates/invoices/components/ui/textarea.tsx +24 -0
- package/templates/invoices/components.json +20 -0
- package/templates/invoices/functions/createInvoice.ts +49 -0
- package/templates/invoices/functions/recordPayment.ts +72 -0
- package/templates/invoices/functions/seedWorkspace.ts +57 -0
- package/templates/invoices/functions/setInvoiceStatus.ts +35 -0
- package/templates/invoices/gitignore +10 -0
- package/templates/invoices/lib/billing.ts +259 -0
- package/templates/invoices/lib/format.ts +53 -0
- package/templates/invoices/lib/search.ts +78 -0
- package/templates/invoices/lib/seed.ts +190 -0
- package/templates/invoices/lib/utils.ts +10 -0
- package/templates/invoices/package.json +39 -0
- package/templates/invoices/tests/billing.test.ts +283 -0
- package/templates/invoices/tests/components.test.tsx +172 -0
- package/templates/invoices/tests/search.test.ts +83 -0
- package/templates/invoices/tests/setup.ts +5 -0
- package/templates/invoices/tsconfig.json +33 -0
- package/templates/projects/AGENTS.md +116 -0
- package/templates/projects/CLAUDE.md +7 -0
- package/templates/projects/README.md +96 -0
- package/templates/projects/app/clients/clients-view.tsx +126 -0
- package/templates/projects/app/clients/page.tsx +17 -0
- package/templates/projects/app/error.tsx +43 -0
- package/templates/projects/app/globals.css +201 -0
- package/templates/projects/app/layout.tsx +31 -0
- package/templates/projects/app/login/auth-form.tsx +122 -0
- package/templates/projects/app/login/page.tsx +38 -0
- package/templates/projects/app/not-found.tsx +29 -0
- package/templates/projects/app/page.tsx +30 -0
- package/templates/projects/app/projects/[id]/page.tsx +23 -0
- package/templates/projects/app/projects/[id]/project-view.tsx +179 -0
- package/templates/projects/app/projects-view.tsx +181 -0
- package/templates/projects/app/robots.ts +12 -0
- package/templates/projects/app/sitemap.ts +27 -0
- package/templates/projects/app/workspace.tsx +190 -0
- package/templates/projects/app.ts +186 -0
- package/templates/projects/bunfig +4 -0
- package/templates/projects/components/avatar.tsx +40 -0
- package/templates/projects/components/board-skeleton.tsx +37 -0
- package/templates/projects/components/budget-bar.tsx +61 -0
- package/templates/projects/components/command-palette.tsx +162 -0
- package/templates/projects/components/data-table.tsx +90 -0
- package/templates/projects/components/empty-state.tsx +43 -0
- package/templates/projects/components/kbd.tsx +23 -0
- package/templates/projects/components/page-header.tsx +36 -0
- package/templates/projects/components/record-dialog.tsx +126 -0
- package/templates/projects/components/sidebar.tsx +111 -0
- package/templates/projects/components/task-board.tsx +109 -0
- package/templates/projects/components/task-card.tsx +77 -0
- package/templates/projects/components/time-dialog.tsx +114 -0
- package/templates/projects/components/ui/badge.tsx +37 -0
- package/templates/projects/components/ui/button.tsx +56 -0
- package/templates/projects/components/ui/card.tsx +90 -0
- package/templates/projects/components/ui/dialog.tsx +110 -0
- package/templates/projects/components/ui/input.tsx +30 -0
- package/templates/projects/components/ui/label.tsx +21 -0
- package/templates/projects/components/ui/select.tsx +39 -0
- package/templates/projects/components/ui/table.tsx +124 -0
- package/templates/projects/components/ui/textarea.tsx +24 -0
- package/templates/projects/components.json +20 -0
- package/templates/projects/functions/logTime.ts +49 -0
- package/templates/projects/functions/moveTask.ts +40 -0
- package/templates/projects/functions/seedWorkspace.ts +59 -0
- package/templates/projects/gitignore +10 -0
- package/templates/projects/lib/format.ts +53 -0
- package/templates/projects/lib/search.ts +78 -0
- package/templates/projects/lib/seed.ts +140 -0
- package/templates/projects/lib/utils.ts +10 -0
- package/templates/projects/lib/work.ts +233 -0
- package/templates/projects/package.json +39 -0
- package/templates/projects/tests/components.test.tsx +200 -0
- package/templates/projects/tests/search.test.ts +83 -0
- package/templates/projects/tests/setup.ts +5 -0
- package/templates/projects/tests/work.test.ts +238 -0
- package/templates/projects/tsconfig.json +33 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Client-side search powering the ⌘K palette.
|
|
2
|
+
//
|
|
3
|
+
// A small team's CRM fits in the synced replica, so search runs locally against
|
|
4
|
+
// data already in memory: results appear as you type, with no request per
|
|
5
|
+
// keystroke. Swap in `db.useSearch` (Pylon's FTS) when a workspace outgrows
|
|
6
|
+
// that — the component boundary doesn't change, only this file.
|
|
7
|
+
|
|
8
|
+
export interface SearchItem {
|
|
9
|
+
id: string;
|
|
10
|
+
type: "deal" | "company" | "contact";
|
|
11
|
+
title: string;
|
|
12
|
+
/** Second line: the company for a contact, the domain for a company. */
|
|
13
|
+
subtitle?: string;
|
|
14
|
+
href: string;
|
|
15
|
+
/** Extra text matched but not displayed — an email, say. */
|
|
16
|
+
keywords?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Scored extends SearchItem {
|
|
20
|
+
score: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Rank items against a query. Prefix matches beat word-boundary matches, which
|
|
25
|
+
* beat substring matches; ties break on the shorter title, so "Acme" ranks
|
|
26
|
+
* above "Acme Corporation Holdings" for the query "acme".
|
|
27
|
+
*/
|
|
28
|
+
export function searchItems(
|
|
29
|
+
items: SearchItem[],
|
|
30
|
+
query: string,
|
|
31
|
+
limit = 8,
|
|
32
|
+
): SearchItem[] {
|
|
33
|
+
const q = query.trim().toLowerCase();
|
|
34
|
+
if (!q) return items.slice(0, limit);
|
|
35
|
+
|
|
36
|
+
const scored: Scored[] = [];
|
|
37
|
+
for (const item of items) {
|
|
38
|
+
const score = scoreItem(item, q);
|
|
39
|
+
if (score > 0) scored.push({ ...item, score });
|
|
40
|
+
}
|
|
41
|
+
scored.sort(
|
|
42
|
+
(a, b) => b.score - a.score || a.title.length - b.title.length ||
|
|
43
|
+
a.title.localeCompare(b.title),
|
|
44
|
+
);
|
|
45
|
+
return scored.slice(0, limit);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function scoreItem(item: SearchItem, q: string): number {
|
|
49
|
+
const title = item.title.toLowerCase();
|
|
50
|
+
if (title.startsWith(q)) return 100;
|
|
51
|
+
if (wordStart(title, q)) return 80;
|
|
52
|
+
if (title.includes(q)) return 60;
|
|
53
|
+
|
|
54
|
+
const haystack = `${item.subtitle ?? ""} ${item.keywords ?? ""}`.toLowerCase();
|
|
55
|
+
if (wordStart(haystack, q)) return 40;
|
|
56
|
+
if (haystack.includes(q)) return 20;
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function wordStart(haystack: string, needle: string): boolean {
|
|
61
|
+
let from = 0;
|
|
62
|
+
for (;;) {
|
|
63
|
+
const at = haystack.indexOf(needle, from);
|
|
64
|
+
if (at === -1) return false;
|
|
65
|
+
if (at === 0 || /[\s._@/-]/.test(haystack[at - 1])) return true;
|
|
66
|
+
from = at + 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Move through a list with arrow keys, wrapping at both ends. */
|
|
71
|
+
export function moveSelection(
|
|
72
|
+
current: number,
|
|
73
|
+
delta: number,
|
|
74
|
+
length: number,
|
|
75
|
+
): number {
|
|
76
|
+
if (length === 0) return 0;
|
|
77
|
+
return (current + delta + length) % length;
|
|
78
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Demo data for a brand-new stock workspace.
|
|
2
|
+
//
|
|
3
|
+
// An empty product list shows nothing about levels, reorder flags, or the
|
|
4
|
+
// ledger. The first sign-in seeds a realistic shelf — including one line out of
|
|
5
|
+
// stock and one below its reorder point — so those states are visible rather
|
|
6
|
+
// than theoretical.
|
|
7
|
+
//
|
|
8
|
+
// The movements are the interesting part: each product\'s level is the sum of
|
|
9
|
+
// its history, so the seed writes a plausible sequence of receipts and sales
|
|
10
|
+
// rather than a starting quantity.
|
|
11
|
+
|
|
12
|
+
export interface SeedProduct {
|
|
13
|
+
key: string;
|
|
14
|
+
sku: string;
|
|
15
|
+
name: string;
|
|
16
|
+
category: string;
|
|
17
|
+
cost: number;
|
|
18
|
+
price: number;
|
|
19
|
+
reorderPoint: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SeedMovement {
|
|
23
|
+
product: string;
|
|
24
|
+
delta: number;
|
|
25
|
+
reason: string;
|
|
26
|
+
note?: string;
|
|
27
|
+
/** Days ago. */
|
|
28
|
+
age: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const SEED_PRODUCTS: SeedProduct[] = [
|
|
32
|
+
{ key: "beans", sku: "CFE-001", name: "House Blend, 1kg", category: "Coffee", cost: 9.4, price: 18, reorderPoint: 12 },
|
|
33
|
+
{ key: "decaf", sku: "CFE-002", name: "Decaf Single Origin, 1kg", category: "Coffee", cost: 11.2, price: 22, reorderPoint: 6 },
|
|
34
|
+
{ key: "cups", sku: "PKG-010", name: "12oz Cups (sleeve of 50)", category: "Packaging", cost: 4.15, price: 0, reorderPoint: 20 },
|
|
35
|
+
{ key: "lids", sku: "PKG-011", name: "12oz Lids (sleeve of 50)", category: "Packaging", cost: 2.8, price: 0, reorderPoint: 20 },
|
|
36
|
+
{ key: "grinder", sku: "EQP-100", name: "Burr Grinder", category: "Equipment", cost: 148, price: 249, reorderPoint: 2 },
|
|
37
|
+
{ key: "filters", sku: "PKG-020", name: "Paper Filters (box of 100)", category: "Packaging", cost: 3.5, price: 7, reorderPoint: 10 },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export const SEED_MOVEMENTS: SeedMovement[] = [
|
|
41
|
+
// Healthy stock.
|
|
42
|
+
{ product: "beans", delta: 60, reason: "received", note: "PO-4471", age: 21 },
|
|
43
|
+
{ product: "beans", delta: -8, reason: "sold", age: 14 },
|
|
44
|
+
{ product: "beans", delta: -11, reason: "sold", age: 7 },
|
|
45
|
+
{ product: "beans", delta: -6, reason: "sold", age: 2 },
|
|
46
|
+
|
|
47
|
+
// Below its reorder point of 6 — ends at 4.
|
|
48
|
+
{ product: "decaf", delta: 24, reason: "received", note: "PO-4471", age: 21 },
|
|
49
|
+
{ product: "decaf", delta: -9, reason: "sold", age: 12 },
|
|
50
|
+
{ product: "decaf", delta: -8, reason: "sold", age: 5 },
|
|
51
|
+
{ product: "decaf", delta: -3, reason: "sold", age: 1 },
|
|
52
|
+
|
|
53
|
+
{ product: "cups", delta: 100, reason: "received", age: 30 },
|
|
54
|
+
{ product: "cups", delta: -34, reason: "sold", age: 10 },
|
|
55
|
+
|
|
56
|
+
// Out of stock — ends at 0.
|
|
57
|
+
{ product: "lids", delta: 40, reason: "received", age: 30 },
|
|
58
|
+
{ product: "lids", delta: -38, reason: "sold", age: 9 },
|
|
59
|
+
{ product: "lids", delta: -2, reason: "damaged", note: "Crushed in transit", age: 4 },
|
|
60
|
+
|
|
61
|
+
{ product: "grinder", delta: 6, reason: "received", note: "PO-4502", age: 40 },
|
|
62
|
+
{ product: "grinder", delta: -2, reason: "sold", age: 18 },
|
|
63
|
+
{ product: "grinder", delta: 1, reason: "returned", note: "Customer changed mind", age: 6 },
|
|
64
|
+
|
|
65
|
+
{ product: "filters", delta: 48, reason: "received", age: 25 },
|
|
66
|
+
{ product: "filters", delta: -12, reason: "sold", age: 8 },
|
|
67
|
+
{ product: "filters", delta: -2, reason: "count", note: "Cycle count adjustment", age: 3 },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
export interface ShapedSeed {
|
|
71
|
+
products: Array<{ key: string; row: Record<string, unknown> }>;
|
|
72
|
+
movements: Array<{ product: string; row: Record<string, unknown> }>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function shapeSeed(now: number = Date.now()): ShapedSeed {
|
|
76
|
+
const daysAgo = (days: number) => new Date(now - days * 86_400_000).toISOString();
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
products: SEED_PRODUCTS.map((p) => ({
|
|
80
|
+
key: p.key,
|
|
81
|
+
row: {
|
|
82
|
+
sku: p.sku,
|
|
83
|
+
name: p.name,
|
|
84
|
+
category: p.category,
|
|
85
|
+
unitCostCents: Math.round(p.cost * 100),
|
|
86
|
+
unitPriceCents: Math.round(p.price * 100),
|
|
87
|
+
reorderPoint: p.reorderPoint,
|
|
88
|
+
archived: false,
|
|
89
|
+
createdAt: daysAgo(60),
|
|
90
|
+
},
|
|
91
|
+
})),
|
|
92
|
+
movements: SEED_MOVEMENTS.map((m) => ({
|
|
93
|
+
product: m.product,
|
|
94
|
+
row: {
|
|
95
|
+
delta: m.delta,
|
|
96
|
+
reason: m.reason,
|
|
97
|
+
note: m.note ?? null,
|
|
98
|
+
createdAt: daysAgo(m.age),
|
|
99
|
+
},
|
|
100
|
+
})),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Stock levels, valuation, and reorder state.
|
|
2
|
+
//
|
|
3
|
+
// THE CENTRAL DECISION: on-hand is never stored. It is the sum of an append-only
|
|
4
|
+
// ledger of movements. A mutable `quantity` column is the classic inventory bug
|
|
5
|
+
// — two people receive the same delivery, both read 10, both write 15, and you
|
|
6
|
+
// have lost five units with nothing to audit. Appending "+5" twice gives 20 and
|
|
7
|
+
// shows you exactly who did it.
|
|
8
|
+
//
|
|
9
|
+
// It also means every count can be explained. "Why do we have 3?" is answerable
|
|
10
|
+
// from the same rows that produced the number.
|
|
11
|
+
//
|
|
12
|
+
// Money is INTEGER CENTS. Quantities are whole units — you cannot hold half a
|
|
13
|
+
// physical thing, and allowing it hides unit-of-measure mistakes.
|
|
14
|
+
//
|
|
15
|
+
// Pure: no React, no `db`.
|
|
16
|
+
|
|
17
|
+
export interface Product {
|
|
18
|
+
id: string;
|
|
19
|
+
sku: string;
|
|
20
|
+
name: string;
|
|
21
|
+
category?: string | null;
|
|
22
|
+
unitCostCents?: number | null;
|
|
23
|
+
unitPriceCents?: number | null;
|
|
24
|
+
/** Order more at or below this level. Null means never flag it. */
|
|
25
|
+
reorderPoint?: number | null;
|
|
26
|
+
archived?: boolean | null;
|
|
27
|
+
createdAt?: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Movement {
|
|
31
|
+
id: string;
|
|
32
|
+
productId: string;
|
|
33
|
+
/** Signed: positive receives, negative issues. Never zero. */
|
|
34
|
+
delta: number;
|
|
35
|
+
reason: string;
|
|
36
|
+
note?: string | null;
|
|
37
|
+
createdAt?: string | null;
|
|
38
|
+
actorId?: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Reason {
|
|
42
|
+
id: string;
|
|
43
|
+
label: string;
|
|
44
|
+
/** Which direction this reason is allowed to move stock. */
|
|
45
|
+
direction: "in" | "out" | "either";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const REASONS: Reason[] = [
|
|
49
|
+
{ id: "received", label: "Received", direction: "in" },
|
|
50
|
+
{ id: "sold", label: "Sold", direction: "out" },
|
|
51
|
+
{ id: "returned", label: "Customer return", direction: "in" },
|
|
52
|
+
{ id: "damaged", label: "Damaged", direction: "out" },
|
|
53
|
+
{ id: "count", label: "Stock count", direction: "either" },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export function reasonById(id: string): Reason | undefined {
|
|
57
|
+
return REASONS.find((r) => r.id === id);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isValidReason(id: string): boolean {
|
|
61
|
+
return REASONS.some((r) => r.id === id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Does this reason permit this direction? A "Sold" that adds stock is a
|
|
66
|
+
* mis-click, and catching it here keeps the ledger meaningful — the whole point
|
|
67
|
+
* of storing a reason is being able to trust it later.
|
|
68
|
+
*/
|
|
69
|
+
export function reasonAllows(reasonId: string, delta: number): boolean {
|
|
70
|
+
const reason = reasonById(reasonId);
|
|
71
|
+
if (!reason || delta === 0) return false;
|
|
72
|
+
if (reason.direction === "either") return true;
|
|
73
|
+
return reason.direction === "in" ? delta > 0 : delta < 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** On-hand for one product: the sum of its movements. */
|
|
77
|
+
export function onHand(productId: string, movements: Movement[]): number {
|
|
78
|
+
let total = 0;
|
|
79
|
+
for (const movement of movements) {
|
|
80
|
+
if (movement.productId !== productId) continue;
|
|
81
|
+
const delta = Number(movement.delta);
|
|
82
|
+
if (Number.isFinite(delta)) total += Math.trunc(delta);
|
|
83
|
+
}
|
|
84
|
+
return total;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* On-hand for every product in one pass.
|
|
89
|
+
*
|
|
90
|
+
* The per-product version is O(movements) each, so a list view calling it per
|
|
91
|
+
* row is O(products × movements) — fine at ten products, not at a thousand.
|
|
92
|
+
*/
|
|
93
|
+
export function onHandByProduct(movements: Movement[]): Map<string, number> {
|
|
94
|
+
const totals = new Map<string, number>();
|
|
95
|
+
for (const movement of movements) {
|
|
96
|
+
const delta = Number(movement.delta);
|
|
97
|
+
if (!Number.isFinite(delta)) continue;
|
|
98
|
+
totals.set(
|
|
99
|
+
movement.productId,
|
|
100
|
+
(totals.get(movement.productId) ?? 0) + Math.trunc(delta),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return totals;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type StockState = "out" | "low" | "ok";
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Out of stock, at/below the reorder point, or fine.
|
|
110
|
+
*
|
|
111
|
+
* Zero is `out` even when there's no reorder point set — you can't sell what
|
|
112
|
+
* you don't have, and that's worth surfacing whether or not someone configured
|
|
113
|
+
* a threshold.
|
|
114
|
+
*/
|
|
115
|
+
export function stockState(quantity: number, reorderPoint?: number | null): StockState {
|
|
116
|
+
if (quantity <= 0) return "out";
|
|
117
|
+
const point = Number(reorderPoint);
|
|
118
|
+
if (Number.isFinite(point) && point > 0 && quantity <= point) return "low";
|
|
119
|
+
return "ok";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface Summary {
|
|
123
|
+
skuCount: number;
|
|
124
|
+
unitCount: number;
|
|
125
|
+
/** Stock at COST — what the shelves are worth to you, not to a customer. */
|
|
126
|
+
valuationCents: number;
|
|
127
|
+
lowCount: number;
|
|
128
|
+
outCount: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function summarize(products: Product[], movements: Movement[]): Summary {
|
|
132
|
+
const byProduct = onHandByProduct(movements);
|
|
133
|
+
const summary: Summary = {
|
|
134
|
+
skuCount: 0,
|
|
135
|
+
unitCount: 0,
|
|
136
|
+
valuationCents: 0,
|
|
137
|
+
lowCount: 0,
|
|
138
|
+
outCount: 0,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
for (const product of products) {
|
|
142
|
+
if (product.archived) continue;
|
|
143
|
+
summary.skuCount += 1;
|
|
144
|
+
const quantity = byProduct.get(product.id) ?? 0;
|
|
145
|
+
// Negative stock is a data error, not negative value — don't let it credit
|
|
146
|
+
// the valuation and hide itself.
|
|
147
|
+
summary.unitCount += Math.max(0, quantity);
|
|
148
|
+
summary.valuationCents +=
|
|
149
|
+
Math.max(0, quantity) * (Number(product.unitCostCents) || 0);
|
|
150
|
+
|
|
151
|
+
const state = stockState(quantity, product.reorderPoint);
|
|
152
|
+
if (state === "out") summary.outCount += 1;
|
|
153
|
+
else if (state === "low") summary.lowCount += 1;
|
|
154
|
+
}
|
|
155
|
+
return summary;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Products at or below their reorder point, most urgent first. */
|
|
159
|
+
export function needsReorder(
|
|
160
|
+
products: Product[],
|
|
161
|
+
movements: Movement[],
|
|
162
|
+
): Array<{ product: Product; quantity: number; state: StockState }> {
|
|
163
|
+
const byProduct = onHandByProduct(movements);
|
|
164
|
+
return products
|
|
165
|
+
.filter((product) => !product.archived)
|
|
166
|
+
.map((product) => {
|
|
167
|
+
const quantity = byProduct.get(product.id) ?? 0;
|
|
168
|
+
return { product, quantity, state: stockState(quantity, product.reorderPoint) };
|
|
169
|
+
})
|
|
170
|
+
.filter((row) => row.state !== "ok")
|
|
171
|
+
.sort((a, b) => a.quantity - b.quantity);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** "$12.50" — exact, like every money figure in this app. */
|
|
175
|
+
export function money(cents: number | null | undefined): string {
|
|
176
|
+
const value = Number(cents) || 0;
|
|
177
|
+
const sign = value < 0 ? "-" : "";
|
|
178
|
+
const abs = Math.abs(value);
|
|
179
|
+
const dollars = Math.floor(abs / 100).toLocaleString("en-US");
|
|
180
|
+
const rest = String(abs % 100).padStart(2, "0");
|
|
181
|
+
return `${sign}$${dollars}.${rest}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** "+5" / "−3" — the sign is the point, so it's always shown. */
|
|
185
|
+
export function signedQuantity(delta: number): string {
|
|
186
|
+
const value = Math.trunc(Number(delta) || 0);
|
|
187
|
+
return value > 0 ? `+${value}` : value < 0 ? `−${Math.abs(value)}` : "0";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Parse a typed amount into cents. Returns null on anything unreadable. */
|
|
191
|
+
export function parseAmount(input: string): number | null {
|
|
192
|
+
const cleaned = input.replace(/[$,\s]/g, "");
|
|
193
|
+
if (!cleaned || !/^-?\d*\.?\d*$/.test(cleaned) || cleaned === "." || cleaned === "-") {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const value = Number(cleaned);
|
|
197
|
+
if (!Number.isFinite(value)) return null;
|
|
198
|
+
return Math.round(value * 100);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Parse a typed whole-unit count. Rejects fractions and nonsense. */
|
|
202
|
+
export function parseCount(input: string): number | null {
|
|
203
|
+
const cleaned = input.replace(/[,\s]/g, "");
|
|
204
|
+
if (!cleaned || !/^-?\d+$/.test(cleaned)) return null;
|
|
205
|
+
const value = Number(cleaned);
|
|
206
|
+
return Number.isFinite(value) ? value : null;
|
|
207
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ClassValue, clsx } from "clsx";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
|
|
4
|
+
// `cn` — the shadcn class merger. clsx resolves conditional/array class
|
|
5
|
+
// inputs; tailwind-merge then dedupes conflicting Tailwind utilities so
|
|
6
|
+
// the last one wins (e.g. `cn("px-2", "px-4")` → "px-4"). Every shadcn
|
|
7
|
+
// component routes its className through this.
|
|
8
|
+
export function cn(...inputs: ClassValue[]) {
|
|
9
|
+
return twMerge(clsx(inputs));
|
|
10
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_NAME_KEBAB__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "pylon dev",
|
|
8
|
+
"deploy": "pylon deploy",
|
|
9
|
+
"check": "tsc --noEmit",
|
|
10
|
+
"test": "pylon test"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@pylonsync/react": "^__PYLON_VERSION__",
|
|
14
|
+
"@pylonsync/sdk": "^__PYLON_VERSION__",
|
|
15
|
+
"@pylonsync/functions": "^__PYLON_VERSION__",
|
|
16
|
+
"@pylonsync/client": "^__PYLON_VERSION__",
|
|
17
|
+
"react": "^19.0.0",
|
|
18
|
+
"react-dom": "^19.0.0",
|
|
19
|
+
"tailwindcss": "^4.3.0",
|
|
20
|
+
"@tailwindcss/cli": "^4.3.0",
|
|
21
|
+
"tw-animate-css": "^1.2.0",
|
|
22
|
+
"class-variance-authority": "^0.7.1",
|
|
23
|
+
"clsx": "^2.1.1",
|
|
24
|
+
"tailwind-merge": "^2.5.0",
|
|
25
|
+
"lucide-react": "^0.460.0",
|
|
26
|
+
"@radix-ui/react-dialog": "^1.1.4",
|
|
27
|
+
"@radix-ui/react-slot": "^1.1.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@happy-dom/global-registrator": "^20.10.0",
|
|
31
|
+
"@pylonsync/cli": "^__PYLON_VERSION__",
|
|
32
|
+
"@testing-library/dom": "^10.4.0",
|
|
33
|
+
"@testing-library/react": "^16.3.0",
|
|
34
|
+
"@types/node": "^22.0.0",
|
|
35
|
+
"@types/react": "^19.0.0",
|
|
36
|
+
"@types/react-dom": "^19.0.0",
|
|
37
|
+
"typescript": "^5.6.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
// This project uses the classic JSX transform, so .tsx tests import React.
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
5
|
+
import { StockLevel } from "../components/stock-level";
|
|
6
|
+
import { MovementDialog } from "../components/movement-dialog";
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
|
|
10
|
+
// Tier 2: components. No mocking needed — they take data as props and report
|
|
11
|
+
// changes through callbacks, because the container owns `db`.
|
|
12
|
+
|
|
13
|
+
const products = [
|
|
14
|
+
{ id: "p1", sku: "CFE-001", name: "House Blend" },
|
|
15
|
+
{ id: "p2", sku: "PKG-011", name: "Lids" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
describe("StockLevel", () => {
|
|
19
|
+
test("marks an empty shelf", () => {
|
|
20
|
+
render(<StockLevel quantity={0} reorderPoint={5} />);
|
|
21
|
+
expect(screen.getByText("out")).toBeDefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("marks a level at the reorder point", () => {
|
|
25
|
+
render(<StockLevel quantity={5} reorderPoint={5} />);
|
|
26
|
+
expect(screen.getByText("low")).toBeDefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("a healthy level is just the number", () => {
|
|
30
|
+
const { container } = render(<StockLevel quantity={41} reorderPoint={5} />);
|
|
31
|
+
expect(container.textContent).toBe("41");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("MovementDialog", () => {
|
|
36
|
+
const level = (id: string) => (id === "p1" ? 12 : 0);
|
|
37
|
+
|
|
38
|
+
test("a sale is recorded as a negative delta from a positive input", () => {
|
|
39
|
+
// Asking someone to type "-3" is how you get a "+3" sale.
|
|
40
|
+
const recorded: unknown[] = [];
|
|
41
|
+
render(
|
|
42
|
+
<MovementDialog
|
|
43
|
+
open
|
|
44
|
+
products={products}
|
|
45
|
+
currentLevel={level}
|
|
46
|
+
onOpenChange={() => {}}
|
|
47
|
+
onRecord={(id, delta, reason) => recorded.push([id, delta, reason])}
|
|
48
|
+
/>,
|
|
49
|
+
);
|
|
50
|
+
fireEvent.change(screen.getByLabelText("Product"), { target: { value: "p1" } });
|
|
51
|
+
fireEvent.change(screen.getByLabelText("Reason"), { target: { value: "sold" } });
|
|
52
|
+
fireEvent.change(screen.getByLabelText("Quantity"), { target: { value: "3" } });
|
|
53
|
+
fireEvent.click(screen.getByRole("button", { name: /^record$/i }));
|
|
54
|
+
expect(recorded).toEqual([["p1", -3, "sold"]]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a receipt is positive", () => {
|
|
58
|
+
const recorded: unknown[] = [];
|
|
59
|
+
render(
|
|
60
|
+
<MovementDialog
|
|
61
|
+
open
|
|
62
|
+
products={products}
|
|
63
|
+
currentLevel={level}
|
|
64
|
+
onOpenChange={() => {}}
|
|
65
|
+
onRecord={(id, delta) => recorded.push(delta)}
|
|
66
|
+
/>,
|
|
67
|
+
);
|
|
68
|
+
fireEvent.change(screen.getByLabelText("Product"), { target: { value: "p1" } });
|
|
69
|
+
fireEvent.change(screen.getByLabelText("Quantity"), { target: { value: "10" } });
|
|
70
|
+
fireEvent.click(screen.getByRole("button", { name: /^record$/i }));
|
|
71
|
+
expect(recorded).toEqual([10]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a stock count sets the level, so it sends the difference", () => {
|
|
75
|
+
// Counting a shelf produces "there are 9", not "adjust by -3".
|
|
76
|
+
const recorded: unknown[] = [];
|
|
77
|
+
render(
|
|
78
|
+
<MovementDialog
|
|
79
|
+
open
|
|
80
|
+
products={products}
|
|
81
|
+
currentLevel={level}
|
|
82
|
+
onOpenChange={() => {}}
|
|
83
|
+
onRecord={(id, delta) => recorded.push(delta)}
|
|
84
|
+
/>,
|
|
85
|
+
);
|
|
86
|
+
fireEvent.change(screen.getByLabelText("Product"), { target: { value: "p1" } });
|
|
87
|
+
fireEvent.change(screen.getByLabelText("Reason"), { target: { value: "count" } });
|
|
88
|
+
fireEvent.change(screen.getByLabelText("Counted"), { target: { value: "9" } });
|
|
89
|
+
fireEvent.click(screen.getByRole("button", { name: /^record$/i }));
|
|
90
|
+
expect(recorded).toEqual([-3]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("refuses to take stock below zero", () => {
|
|
94
|
+
const recorded: unknown[] = [];
|
|
95
|
+
render(
|
|
96
|
+
<MovementDialog
|
|
97
|
+
open
|
|
98
|
+
products={products}
|
|
99
|
+
currentLevel={level}
|
|
100
|
+
onOpenChange={() => {}}
|
|
101
|
+
onRecord={(id, delta) => recorded.push(delta)}
|
|
102
|
+
/>,
|
|
103
|
+
);
|
|
104
|
+
fireEvent.change(screen.getByLabelText("Product"), { target: { value: "p2" } });
|
|
105
|
+
fireEvent.change(screen.getByLabelText("Reason"), { target: { value: "sold" } });
|
|
106
|
+
fireEvent.change(screen.getByLabelText("Quantity"), { target: { value: "1" } });
|
|
107
|
+
fireEvent.click(screen.getByRole("button", { name: /^record$/i }));
|
|
108
|
+
expect(recorded).toEqual([]);
|
|
109
|
+
expect(screen.getByText(/below zero/i)).toBeDefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("shows what the level will become", () => {
|
|
113
|
+
render(
|
|
114
|
+
<MovementDialog
|
|
115
|
+
open
|
|
116
|
+
products={products}
|
|
117
|
+
currentLevel={level}
|
|
118
|
+
onOpenChange={() => {}}
|
|
119
|
+
onRecord={() => {}}
|
|
120
|
+
/>,
|
|
121
|
+
);
|
|
122
|
+
fireEvent.change(screen.getByLabelText("Product"), { target: { value: "p1" } });
|
|
123
|
+
fireEvent.change(screen.getByLabelText("Quantity"), { target: { value: "5" } });
|
|
124
|
+
expect(screen.getByText(/On hand 12 → 17/)).toBeDefined();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("nothing is recorded without a product", () => {
|
|
128
|
+
const recorded: unknown[] = [];
|
|
129
|
+
render(
|
|
130
|
+
<MovementDialog
|
|
131
|
+
open
|
|
132
|
+
products={products}
|
|
133
|
+
currentLevel={level}
|
|
134
|
+
onOpenChange={() => {}}
|
|
135
|
+
onRecord={() => recorded.push(1)}
|
|
136
|
+
/>,
|
|
137
|
+
);
|
|
138
|
+
fireEvent.change(screen.getByLabelText("Quantity"), { target: { value: "5" } });
|
|
139
|
+
fireEvent.click(screen.getByRole("button", { name: /^record$/i }));
|
|
140
|
+
expect(recorded).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("renders nothing when closed", () => {
|
|
144
|
+
const { container } = render(
|
|
145
|
+
<MovementDialog
|
|
146
|
+
open={false}
|
|
147
|
+
products={products}
|
|
148
|
+
currentLevel={level}
|
|
149
|
+
onOpenChange={() => {}}
|
|
150
|
+
onRecord={() => {}}
|
|
151
|
+
/>,
|
|
152
|
+
);
|
|
153
|
+
expect(container.firstChild).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { moveSelection, searchItems, type SearchItem } from "../lib/search";
|
|
3
|
+
|
|
4
|
+
const items: SearchItem[] = [
|
|
5
|
+
{ id: "1", type: "company", title: "Acme Inc", subtitle: "acme.com", href: "/c" },
|
|
6
|
+
{
|
|
7
|
+
id: "2",
|
|
8
|
+
type: "company",
|
|
9
|
+
title: "Acme Corporation Holdings",
|
|
10
|
+
subtitle: "acmeholdings.com",
|
|
11
|
+
href: "/c",
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: "3",
|
|
15
|
+
type: "contact",
|
|
16
|
+
title: "Dana Whitfield",
|
|
17
|
+
subtitle: "Northwind Logistics",
|
|
18
|
+
href: "/p",
|
|
19
|
+
keywords: "dana@northwind.co VP Operations",
|
|
20
|
+
},
|
|
21
|
+
{ id: "4", type: "deal", title: "Fleet dispatch rollout", href: "/d" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const titles = (q: string) => searchItems(items, q).map((i) => i.title);
|
|
25
|
+
|
|
26
|
+
describe("searchItems", () => {
|
|
27
|
+
test("an empty query lists everything", () => {
|
|
28
|
+
expect(searchItems(items, "").length).toBe(items.length);
|
|
29
|
+
expect(searchItems(items, " ").length).toBe(items.length);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("prefix beats substring", () => {
|
|
33
|
+
expect(titles("acme")[0]).toBe("Acme Inc");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("the shorter title wins a tie", () => {
|
|
37
|
+
// Both start with "Acme"; the more specific record should come first.
|
|
38
|
+
expect(titles("acme")).toEqual(["Acme Inc", "Acme Corporation Holdings"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("matches a word inside the title", () => {
|
|
42
|
+
expect(titles("dispatch")).toContain("Fleet dispatch rollout");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("matches the subtitle and hidden keywords", () => {
|
|
46
|
+
expect(titles("northwind")).toContain("Dana Whitfield");
|
|
47
|
+
expect(titles("dana@northwind.co")).toContain("Dana Whitfield");
|
|
48
|
+
expect(titles("VP")).toContain("Dana Whitfield");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("is case-insensitive", () => {
|
|
52
|
+
expect(titles("ACME").length).toBe(2);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("no match is empty, not everything", () => {
|
|
56
|
+
expect(titles("zzzz")).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("respects the limit", () => {
|
|
60
|
+
expect(searchItems(items, "", 2)).toHaveLength(2);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a word-boundary hit beats a mid-word one", () => {
|
|
64
|
+
const rows: SearchItem[] = [
|
|
65
|
+
{ id: "a", type: "deal", title: "Broadcasting", href: "/" },
|
|
66
|
+
{ id: "b", type: "deal", title: "New cast list", href: "/" },
|
|
67
|
+
];
|
|
68
|
+
expect(searchItems(rows, "cast").map((r) => r.title)[0]).toBe("New cast list");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("moveSelection", () => {
|
|
73
|
+
test("wraps at both ends", () => {
|
|
74
|
+
expect(moveSelection(0, -1, 3)).toBe(2);
|
|
75
|
+
expect(moveSelection(2, 1, 3)).toBe(0);
|
|
76
|
+
expect(moveSelection(0, 1, 3)).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("an empty list stays at zero rather than going negative", () => {
|
|
80
|
+
expect(moveSelection(0, 1, 0)).toBe(0);
|
|
81
|
+
expect(moveSelection(0, -1, 0)).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
});
|