@pylonsync/create-pylon 0.3.344 → 0.3.345
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,172 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { callFn, db, useRouter } from "@pylonsync/react";
|
|
5
|
+
import { useAuth } from "@pylonsync/client";
|
|
6
|
+
import { Sidebar } from "@/components/sidebar";
|
|
7
|
+
import { CommandPalette } from "@/components/command-palette";
|
|
8
|
+
import type { SearchItem } from "@/lib/search";
|
|
9
|
+
import { onHandByProduct, summarize, type Movement, type Product } from "@/lib/stock";
|
|
10
|
+
|
|
11
|
+
export type ProductRow = Product;
|
|
12
|
+
export type MovementRow = Movement;
|
|
13
|
+
export interface UserRow {
|
|
14
|
+
id: string;
|
|
15
|
+
email: string;
|
|
16
|
+
displayName?: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WorkspaceData {
|
|
20
|
+
products: ProductRow[];
|
|
21
|
+
movements: MovementRow[];
|
|
22
|
+
/** On-hand for every product, computed once per render rather than per row. */
|
|
23
|
+
levels: Map<string, number>;
|
|
24
|
+
productName: (id: string | null | undefined) => string | null;
|
|
25
|
+
actorName: (id: string | null | undefined) => string | null;
|
|
26
|
+
loading: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The app shell, and the ONLY place that touches `db`.
|
|
31
|
+
*
|
|
32
|
+
* Every view below receives plain data through the render prop and reports
|
|
33
|
+
* changes through callbacks, which is what lets the components in `components/`
|
|
34
|
+
* render from fixtures in a test with nothing mocked.
|
|
35
|
+
*
|
|
36
|
+
* The queries are live: a movement recorded at the back door updates the level
|
|
37
|
+
* on the shop floor\'s screen immediately, so nobody sells what was just
|
|
38
|
+
* damaged.
|
|
39
|
+
*/
|
|
40
|
+
export function Workspace({
|
|
41
|
+
email,
|
|
42
|
+
pathname,
|
|
43
|
+
children,
|
|
44
|
+
}: {
|
|
45
|
+
email: string;
|
|
46
|
+
pathname: string;
|
|
47
|
+
children: (data: WorkspaceData) => React.ReactNode;
|
|
48
|
+
}) {
|
|
49
|
+
const router = useRouter();
|
|
50
|
+
const { signOut } = useAuth();
|
|
51
|
+
const { data: products, loading } = db.useQuery<ProductRow>("Product");
|
|
52
|
+
const { data: movements } = db.useQuery<MovementRow>("Movement");
|
|
53
|
+
const { data: users } = db.useQuery<UserRow>("User");
|
|
54
|
+
|
|
55
|
+
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
56
|
+
const seeded = useRef(false);
|
|
57
|
+
|
|
58
|
+
const productList = products ?? [];
|
|
59
|
+
const movementList = movements ?? [];
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (loading || seeded.current) return;
|
|
63
|
+
if (productList.length > 0) {
|
|
64
|
+
seeded.current = true;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
seeded.current = true;
|
|
68
|
+
callFn("seedWorkspace", {}).catch(() => {
|
|
69
|
+
seeded.current = false;
|
|
70
|
+
});
|
|
71
|
+
}, [loading, productList.length]);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
function onKey(event: KeyboardEvent) {
|
|
75
|
+
const target = event.target as HTMLElement | null;
|
|
76
|
+
const typing =
|
|
77
|
+
!!target &&
|
|
78
|
+
(target.tagName === "INPUT" ||
|
|
79
|
+
target.tagName === "TEXTAREA" ||
|
|
80
|
+
target.isContentEditable);
|
|
81
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
setPaletteOpen((open) => !open);
|
|
84
|
+
} else if (event.key === "/" && !typing) {
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
setPaletteOpen(true);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
window.addEventListener("keydown", onKey);
|
|
90
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
// One pass over the ledger for the whole render — calling onHand() per row
|
|
94
|
+
// would be O(products x movements).
|
|
95
|
+
const levels = useMemo(() => onHandByProduct(movementList), [movementList]);
|
|
96
|
+
const productById = useMemo(
|
|
97
|
+
() => new Map(productList.map((p) => [p.id, p] as const)),
|
|
98
|
+
[productList],
|
|
99
|
+
);
|
|
100
|
+
const userById = useMemo(
|
|
101
|
+
() => new Map((users ?? []).map((u) => [u.id, u] as const)),
|
|
102
|
+
[users],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const searchIndex = useMemo<SearchItem[]>(
|
|
106
|
+
() =>
|
|
107
|
+
productList.map((product) => ({
|
|
108
|
+
id: `product:${product.id}`,
|
|
109
|
+
type: "company" as const,
|
|
110
|
+
title: product.name,
|
|
111
|
+
subtitle: product.sku,
|
|
112
|
+
href: `/products/${product.id}`,
|
|
113
|
+
keywords: product.category ?? undefined,
|
|
114
|
+
})),
|
|
115
|
+
[productList],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const summary = summarize(productList, movementList);
|
|
119
|
+
|
|
120
|
+
const data: WorkspaceData = {
|
|
121
|
+
products: productList,
|
|
122
|
+
movements: movementList,
|
|
123
|
+
levels,
|
|
124
|
+
productName: (id) => productById.get(id ?? "")?.name ?? null,
|
|
125
|
+
actorName: (id) => {
|
|
126
|
+
const user = userById.get(id ?? "");
|
|
127
|
+
return user?.displayName || user?.email || null;
|
|
128
|
+
},
|
|
129
|
+
loading,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="flex h-screen overflow-hidden">
|
|
134
|
+
<Sidebar
|
|
135
|
+
workspace="Inventory"
|
|
136
|
+
email={email}
|
|
137
|
+
pathname={pathname}
|
|
138
|
+
counts={{ "/": summary.skuCount }}
|
|
139
|
+
onOpenCommand={() => setPaletteOpen(true)}
|
|
140
|
+
onSignOut={async () => {
|
|
141
|
+
await signOut();
|
|
142
|
+
window.location.assign("/login");
|
|
143
|
+
}}
|
|
144
|
+
/>
|
|
145
|
+
<main className="flex min-w-0 flex-1 flex-col">{children(data)}</main>
|
|
146
|
+
|
|
147
|
+
<CommandPalette
|
|
148
|
+
open={paletteOpen}
|
|
149
|
+
items={searchIndex}
|
|
150
|
+
actions={[
|
|
151
|
+
{
|
|
152
|
+
id: "new-product",
|
|
153
|
+
label: "New product",
|
|
154
|
+
run: () => router.push("/?new=product"),
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: "reorder",
|
|
158
|
+
label: `Needs reorder (${summary.lowCount + summary.outCount})`,
|
|
159
|
+
run: () => router.push("/?filter=reorder"),
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: "go-movements",
|
|
163
|
+
label: "Go to Movements",
|
|
164
|
+
run: () => router.push("/movements"),
|
|
165
|
+
},
|
|
166
|
+
]}
|
|
167
|
+
onClose={() => setPaletteOpen(false)}
|
|
168
|
+
onSelect={(item) => router.push(item.href)}
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import {
|
|
2
|
+
entity,
|
|
3
|
+
field,
|
|
4
|
+
policy,
|
|
5
|
+
auth,
|
|
6
|
+
buildManifest,
|
|
7
|
+
discoverAppRoutes,
|
|
8
|
+
font,
|
|
9
|
+
} from "@pylonsync/sdk";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// inventory — stock control for a small business: products, an append-only
|
|
13
|
+
// ledger of movements, and the levels derived from it.
|
|
14
|
+
//
|
|
15
|
+
// THE CENTRAL DECISION: there is no `quantity` column. On-hand is the SUM of a
|
|
16
|
+
// product's movements. A mutable counter is the classic inventory bug — two
|
|
17
|
+
// people receive the same delivery, both read 10, both write 15, and five units
|
|
18
|
+
// vanish with nothing to audit. Appending "+5" twice gives 20 and shows exactly
|
|
19
|
+
// who did it. It also makes every count explainable from the same rows that
|
|
20
|
+
// produced it.
|
|
21
|
+
//
|
|
22
|
+
// • Product — sku, name, cost, price, reorder point.
|
|
23
|
+
// • Movement — a signed delta with a reason. Append-only; never edited.
|
|
24
|
+
// • User — a team member (email/password).
|
|
25
|
+
//
|
|
26
|
+
// Money is INTEGER CENTS; quantities are whole units. You cannot hold half a
|
|
27
|
+
// physical thing, and allowing it hides unit-of-measure mistakes.
|
|
28
|
+
//
|
|
29
|
+
// The realtime hook: a movement recorded at the back door updates the level on
|
|
30
|
+
// the shop floor's screen immediately — nobody sells what was just damaged.
|
|
31
|
+
//
|
|
32
|
+
// TRUST MODEL: an internal tool for ONE business. Every entity is readable and
|
|
33
|
+
// writable by any SIGNED-IN user and by nobody else.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const TEAM = "auth.userId != null";
|
|
37
|
+
|
|
38
|
+
const Product = entity(
|
|
39
|
+
"Product",
|
|
40
|
+
{
|
|
41
|
+
// Unique: two rows sharing a SKU means two different answers to "how many
|
|
42
|
+
// do we have", which is the question this app exists to answer.
|
|
43
|
+
sku: field.string().unique(),
|
|
44
|
+
name: field.string(),
|
|
45
|
+
category: field.string().optional(),
|
|
46
|
+
// What it costs YOU — valuation is at cost, not at retail.
|
|
47
|
+
unitCostCents: field.number().default(0),
|
|
48
|
+
unitPriceCents: field.number().default(0),
|
|
49
|
+
// Order more at or below this. 0 / unset means never flag it.
|
|
50
|
+
reorderPoint: field.number().default(0),
|
|
51
|
+
// Discontinued lines stay for their movement history rather than being
|
|
52
|
+
// deleted — removing the product would orphan the ledger that explains
|
|
53
|
+
// last quarter's numbers.
|
|
54
|
+
archived: field.boolean().default(false),
|
|
55
|
+
createdAt: field.datetime().defaultNow(),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
indexes: [
|
|
59
|
+
{ name: "by_sku", fields: ["sku"], unique: true },
|
|
60
|
+
{ name: "by_category", fields: ["category"], unique: false },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// APPEND-ONLY. Nothing in the app updates or deletes a movement: a correction
|
|
66
|
+
// is another movement in the opposite direction, which is what makes the
|
|
67
|
+
// history trustworthy. The policy enforces that, not just convention.
|
|
68
|
+
const Movement = entity(
|
|
69
|
+
"Movement",
|
|
70
|
+
{
|
|
71
|
+
productId: field.id("Product"),
|
|
72
|
+
// Signed whole units: positive receives, negative issues. Never zero.
|
|
73
|
+
delta: field.number(),
|
|
74
|
+
// "received" | "sold" | "returned" | "damaged" | "count" — see REASONS.
|
|
75
|
+
reason: field.string(),
|
|
76
|
+
note: field.string().optional(),
|
|
77
|
+
actorId: field.id("User").readonly().optional(),
|
|
78
|
+
createdAt: field.datetime().defaultNow(),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
indexes: [
|
|
82
|
+
{ name: "by_product", fields: ["productId"], unique: false },
|
|
83
|
+
{ name: "by_created", fields: ["createdAt"], unique: false },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const User = entity(
|
|
89
|
+
"User",
|
|
90
|
+
{
|
|
91
|
+
email: field.string(),
|
|
92
|
+
displayName: field.string().optional(),
|
|
93
|
+
passwordHash: field.string().serverOnly().optional(),
|
|
94
|
+
avatarColor: field.string().optional(),
|
|
95
|
+
emailVerified: field.datetime().optional(),
|
|
96
|
+
createdAt: field.datetime().defaultNow(),
|
|
97
|
+
},
|
|
98
|
+
{ indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const productPolicy = policy({
|
|
102
|
+
name: "product_team",
|
|
103
|
+
entity: "Product",
|
|
104
|
+
allowRead: TEAM,
|
|
105
|
+
allowInsert: TEAM,
|
|
106
|
+
allowUpdate: TEAM,
|
|
107
|
+
allowDelete: TEAM,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// The ledger is INSERT-ONLY at the policy level. Editing a movement would
|
|
111
|
+
// silently rewrite history and change a past valuation; deleting one would make
|
|
112
|
+
// the current level unexplainable. Corrections are new rows.
|
|
113
|
+
const movementPolicy = policy({
|
|
114
|
+
name: "movement_append_only",
|
|
115
|
+
entity: "Movement",
|
|
116
|
+
allowRead: TEAM,
|
|
117
|
+
allowInsert: TEAM,
|
|
118
|
+
allowUpdate: "false",
|
|
119
|
+
allowDelete: "false",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const userPolicy = policy({
|
|
123
|
+
name: "user_team_read",
|
|
124
|
+
entity: "User",
|
|
125
|
+
allowRead: TEAM,
|
|
126
|
+
allowInsert: "false",
|
|
127
|
+
allowUpdate: "false",
|
|
128
|
+
allowDelete: "false",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const manifest = buildManifest({
|
|
132
|
+
name: "__APP_NAME__",
|
|
133
|
+
version: "0.1.0",
|
|
134
|
+
entities: [Product, Movement, User],
|
|
135
|
+
queries: [],
|
|
136
|
+
actions: [],
|
|
137
|
+
policies: [productPolicy, movementPolicy, userPolicy],
|
|
138
|
+
auth: auth(),
|
|
139
|
+
fonts: [
|
|
140
|
+
font({
|
|
141
|
+
family: "Inter",
|
|
142
|
+
variable: "--font-sans",
|
|
143
|
+
weights: ["400", "500", "600", "700"],
|
|
144
|
+
subsets: ["latin"],
|
|
145
|
+
display: "swap",
|
|
146
|
+
preload: true,
|
|
147
|
+
}),
|
|
148
|
+
],
|
|
149
|
+
routes: await discoverAppRoutes(),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
153
|
+
|
|
154
|
+
export default manifest;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
import { accentIndex, initials } from "@/lib/format";
|
|
4
|
+
|
|
5
|
+
// Deterministic accent per record — the same company keeps its colour across
|
|
6
|
+
// sessions and machines without storing one. Hues are muted so a dense list
|
|
7
|
+
// doesn't turn into confetti.
|
|
8
|
+
const ACCENTS = [
|
|
9
|
+
"bg-indigo-500/15 text-indigo-500 dark:text-indigo-300",
|
|
10
|
+
"bg-emerald-500/15 text-emerald-600 dark:text-emerald-300",
|
|
11
|
+
"bg-amber-500/15 text-amber-600 dark:text-amber-300",
|
|
12
|
+
"bg-rose-500/15 text-rose-500 dark:text-rose-300",
|
|
13
|
+
"bg-sky-500/15 text-sky-600 dark:text-sky-300",
|
|
14
|
+
"bg-violet-500/15 text-violet-500 dark:text-violet-300",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function Avatar({
|
|
18
|
+
name,
|
|
19
|
+
size = "md",
|
|
20
|
+
className,
|
|
21
|
+
}: {
|
|
22
|
+
name: string | null | undefined;
|
|
23
|
+
size?: "sm" | "md";
|
|
24
|
+
className?: string;
|
|
25
|
+
}) {
|
|
26
|
+
const label = initials(name);
|
|
27
|
+
return (
|
|
28
|
+
<span
|
|
29
|
+
aria-hidden="true"
|
|
30
|
+
className={cn(
|
|
31
|
+
"inline-flex shrink-0 select-none items-center justify-center rounded-full font-medium",
|
|
32
|
+
size === "sm" ? "size-5 text-[9px]" : "size-6 text-[10px]",
|
|
33
|
+
ACCENTS[accentIndex(name ?? "?", ACCENTS.length)],
|
|
34
|
+
className,
|
|
35
|
+
)}
|
|
36
|
+
>
|
|
37
|
+
{label}
|
|
38
|
+
</span>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Building2, Plus, Search, User } from "lucide-react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
import { Kbd } from "@/components/kbd";
|
|
5
|
+
import { moveSelection, searchItems, type SearchItem } from "@/lib/search";
|
|
6
|
+
|
|
7
|
+
const TYPE_ICON: Record<SearchItem["type"], React.ReactNode> = {
|
|
8
|
+
deal: <Search />,
|
|
9
|
+
company: <Building2 />,
|
|
10
|
+
contact: <User />,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export interface CommandAction {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
run: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* ⌘K. Searches the synced replica in memory, so results land as you type with
|
|
21
|
+
* no request per keystroke — see lib/search.ts for the ranking.
|
|
22
|
+
*
|
|
23
|
+
* Presentational: items and actions come in as props, selection is delegated
|
|
24
|
+
* through onSelect. Keyboard handling lives here because it belongs to the
|
|
25
|
+
* widget, not to the page that opened it.
|
|
26
|
+
*/
|
|
27
|
+
export function CommandPalette({
|
|
28
|
+
open,
|
|
29
|
+
items,
|
|
30
|
+
actions = [],
|
|
31
|
+
onClose,
|
|
32
|
+
onSelect,
|
|
33
|
+
}: {
|
|
34
|
+
open: boolean;
|
|
35
|
+
items: SearchItem[];
|
|
36
|
+
actions?: CommandAction[];
|
|
37
|
+
onClose: () => void;
|
|
38
|
+
onSelect: (item: SearchItem) => void;
|
|
39
|
+
}) {
|
|
40
|
+
const [query, setQuery] = useState("");
|
|
41
|
+
const [index, setIndex] = useState(0);
|
|
42
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
43
|
+
|
|
44
|
+
const results = useMemo(() => searchItems(items, query), [items, query]);
|
|
45
|
+
const shownActions = query.trim() ? [] : actions;
|
|
46
|
+
const rows = useMemo(
|
|
47
|
+
() => [
|
|
48
|
+
...shownActions.map((a) => ({ kind: "action" as const, action: a })),
|
|
49
|
+
...results.map((item) => ({ kind: "item" as const, item })),
|
|
50
|
+
],
|
|
51
|
+
[shownActions, results],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!open) return;
|
|
56
|
+
setQuery("");
|
|
57
|
+
setIndex(0);
|
|
58
|
+
// Focus after paint so the dialog is mounted and the caret lands.
|
|
59
|
+
const id = requestAnimationFrame(() => inputRef.current?.focus());
|
|
60
|
+
return () => cancelAnimationFrame(id);
|
|
61
|
+
}, [open]);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
setIndex(0);
|
|
65
|
+
}, [query]);
|
|
66
|
+
|
|
67
|
+
if (!open) return null;
|
|
68
|
+
|
|
69
|
+
function commit(at: number) {
|
|
70
|
+
const row = rows[at];
|
|
71
|
+
if (!row) return;
|
|
72
|
+
if (row.kind === "action") row.action.run();
|
|
73
|
+
else onSelect(row.item);
|
|
74
|
+
onClose();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[12vh]"
|
|
80
|
+
// Clicking the backdrop dismisses; clicks inside the panel must not.
|
|
81
|
+
onMouseDown={(event) => {
|
|
82
|
+
if (event.target === event.currentTarget) onClose();
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
<div
|
|
86
|
+
role="dialog"
|
|
87
|
+
aria-modal="true"
|
|
88
|
+
aria-label="Search"
|
|
89
|
+
className="w-full max-w-lg overflow-hidden rounded-xl border border-border bg-popover shadow-2xl"
|
|
90
|
+
onKeyDown={(event) => {
|
|
91
|
+
if (event.key === "Escape") {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
onClose();
|
|
94
|
+
} else if (event.key === "ArrowDown") {
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
setIndex((i) => moveSelection(i, 1, rows.length));
|
|
97
|
+
} else if (event.key === "ArrowUp") {
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
setIndex((i) => moveSelection(i, -1, rows.length));
|
|
100
|
+
} else if (event.key === "Enter") {
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
commit(index);
|
|
103
|
+
}
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<div className="flex items-center gap-2 border-b border-border px-3">
|
|
107
|
+
<Search className="size-4 shrink-0 text-muted-foreground" />
|
|
108
|
+
<input
|
|
109
|
+
ref={inputRef}
|
|
110
|
+
value={query}
|
|
111
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
112
|
+
placeholder="Search deals, companies, contacts…"
|
|
113
|
+
aria-label="Search deals, companies and contacts"
|
|
114
|
+
className="h-11 flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground"
|
|
115
|
+
/>
|
|
116
|
+
<Kbd>esc</Kbd>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div className="max-h-80 overflow-y-auto p-1.5">
|
|
120
|
+
{rows.length === 0 ? (
|
|
121
|
+
<p className="px-3 py-6 text-center text-[12px] text-muted-foreground">
|
|
122
|
+
No matches for “{query}”
|
|
123
|
+
</p>
|
|
124
|
+
) : (
|
|
125
|
+
rows.map((row, at) => (
|
|
126
|
+
<button
|
|
127
|
+
key={row.kind === "action" ? row.action.id : row.item.id}
|
|
128
|
+
type="button"
|
|
129
|
+
// Pointer hover moves selection so mouse and keyboard agree.
|
|
130
|
+
onMouseMove={() => setIndex(at)}
|
|
131
|
+
onClick={() => commit(at)}
|
|
132
|
+
aria-selected={at === index}
|
|
133
|
+
className={cn(
|
|
134
|
+
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-[13px] transition-colors",
|
|
135
|
+
"[&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:text-muted-foreground",
|
|
136
|
+
at === index ? "bg-surface-2" : "hover:bg-surface-2/60",
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
{row.kind === "action" ? (
|
|
140
|
+
<>
|
|
141
|
+
<Plus />
|
|
142
|
+
<span className="flex-1 truncate">{row.action.label}</span>
|
|
143
|
+
</>
|
|
144
|
+
) : (
|
|
145
|
+
<>
|
|
146
|
+
{TYPE_ICON[row.item.type]}
|
|
147
|
+
<span className="flex-1 truncate">{row.item.title}</span>
|
|
148
|
+
{row.item.subtitle ? (
|
|
149
|
+
<span className="truncate text-[12px] text-muted-foreground">
|
|
150
|
+
{row.item.subtitle}
|
|
151
|
+
</span>
|
|
152
|
+
) : null}
|
|
153
|
+
</>
|
|
154
|
+
)}
|
|
155
|
+
</button>
|
|
156
|
+
))
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
export interface ColumnDef<Row> {
|
|
5
|
+
key: string;
|
|
6
|
+
header: string;
|
|
7
|
+
/** Cell content. Return a string and it's rendered as-is. */
|
|
8
|
+
cell: (row: Row) => React.ReactNode;
|
|
9
|
+
/** Right-align and tabular-figure a numeric column. */
|
|
10
|
+
numeric?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A dense list view. Rows are 36px so a screenful is a screenful of data, and
|
|
16
|
+
* the header sticks while the body scrolls.
|
|
17
|
+
*
|
|
18
|
+
* Generic and presentational — every view here uses the same one, which is why
|
|
19
|
+
* companies and contacts look like the same product.
|
|
20
|
+
*/
|
|
21
|
+
export function DataTable<Row extends { id: string }>({
|
|
22
|
+
rows,
|
|
23
|
+
columns,
|
|
24
|
+
onRowClick,
|
|
25
|
+
empty,
|
|
26
|
+
}: {
|
|
27
|
+
rows: Row[];
|
|
28
|
+
columns: ColumnDef<Row>[];
|
|
29
|
+
onRowClick?: (row: Row) => void;
|
|
30
|
+
empty?: React.ReactNode;
|
|
31
|
+
}) {
|
|
32
|
+
if (rows.length === 0 && empty) return <>{empty}</>;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
36
|
+
<table className="w-full border-collapse text-[13px]">
|
|
37
|
+
<thead className="sticky top-0 z-10 bg-background">
|
|
38
|
+
<tr className="hairline">
|
|
39
|
+
{columns.map((column) => (
|
|
40
|
+
<th
|
|
41
|
+
key={column.key}
|
|
42
|
+
scope="col"
|
|
43
|
+
className={cn(
|
|
44
|
+
"h-8 px-4 text-left text-[11px] font-medium text-muted-foreground",
|
|
45
|
+
column.numeric && "text-right",
|
|
46
|
+
column.className,
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
{column.header}
|
|
50
|
+
</th>
|
|
51
|
+
))}
|
|
52
|
+
</tr>
|
|
53
|
+
</thead>
|
|
54
|
+
<tbody>
|
|
55
|
+
{rows.map((row) => (
|
|
56
|
+
<tr
|
|
57
|
+
key={row.id}
|
|
58
|
+
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
59
|
+
tabIndex={onRowClick ? 0 : undefined}
|
|
60
|
+
onKeyDown={
|
|
61
|
+
onRowClick
|
|
62
|
+
? (event) => {
|
|
63
|
+
if (event.key === "Enter") onRowClick(row);
|
|
64
|
+
}
|
|
65
|
+
: undefined
|
|
66
|
+
}
|
|
67
|
+
className={cn(
|
|
68
|
+
"border-b border-border/60 transition-colors",
|
|
69
|
+
onRowClick && "cursor-pointer hover:bg-surface-1",
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{columns.map((column) => (
|
|
73
|
+
<td
|
|
74
|
+
key={column.key}
|
|
75
|
+
className={cn(
|
|
76
|
+
"h-9 max-w-0 truncate px-4",
|
|
77
|
+
column.numeric && "tabular text-right",
|
|
78
|
+
column.className,
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
{column.cell(row)}
|
|
82
|
+
</td>
|
|
83
|
+
))}
|
|
84
|
+
</tr>
|
|
85
|
+
))}
|
|
86
|
+
</tbody>
|
|
87
|
+
</table>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An empty state that says what this screen is for and offers the one action
|
|
6
|
+
* that fills it. "No records found" tells a new user nothing and gives them
|
|
7
|
+
* nowhere to go.
|
|
8
|
+
*/
|
|
9
|
+
export function EmptyState({
|
|
10
|
+
icon,
|
|
11
|
+
title,
|
|
12
|
+
description,
|
|
13
|
+
action,
|
|
14
|
+
className,
|
|
15
|
+
}: {
|
|
16
|
+
icon?: React.ReactNode;
|
|
17
|
+
title: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
action?: React.ReactNode;
|
|
20
|
+
className?: string;
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
className={cn(
|
|
25
|
+
"flex flex-col items-center justify-center px-6 py-16 text-center",
|
|
26
|
+
className,
|
|
27
|
+
)}
|
|
28
|
+
>
|
|
29
|
+
{icon ? (
|
|
30
|
+
<div className="mb-3 flex size-9 items-center justify-center rounded-lg bg-surface-2 text-muted-foreground [&_svg]:size-4">
|
|
31
|
+
{icon}
|
|
32
|
+
</div>
|
|
33
|
+
) : null}
|
|
34
|
+
<p className="text-[13px] font-medium">{title}</p>
|
|
35
|
+
{description ? (
|
|
36
|
+
<p className="mt-1 max-w-sm text-[12px] leading-5 text-muted-foreground">
|
|
37
|
+
{description}
|
|
38
|
+
</p>
|
|
39
|
+
) : null}
|
|
40
|
+
{action ? <div className="mt-4">{action}</div> : null}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
/** A keyboard hint. Present in the UI because the shortcuts are real. */
|
|
5
|
+
export function Kbd({
|
|
6
|
+
children,
|
|
7
|
+
className,
|
|
8
|
+
}: {
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
className?: string;
|
|
11
|
+
}) {
|
|
12
|
+
return (
|
|
13
|
+
<kbd
|
|
14
|
+
className={cn(
|
|
15
|
+
"inline-flex h-5 min-w-5 items-center justify-center rounded border border-border bg-surface-2 px-1.5",
|
|
16
|
+
"font-sans text-[10px] font-medium text-muted-foreground",
|
|
17
|
+
className,
|
|
18
|
+
)}
|
|
19
|
+
>
|
|
20
|
+
{children}
|
|
21
|
+
</kbd>
|
|
22
|
+
);
|
|
23
|
+
}
|