@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.
Files changed (295) hide show
  1. package/bin/create-pylon.js +35 -0
  2. package/package.json +1 -1
  3. package/templates/crm/AGENTS.md +116 -0
  4. package/templates/crm/CLAUDE.md +7 -0
  5. package/templates/crm/README.md +96 -0
  6. package/templates/crm/app/companies/companies-view.tsx +129 -0
  7. package/templates/crm/app/companies/page.tsx +17 -0
  8. package/templates/crm/app/contacts/contacts-view.tsx +139 -0
  9. package/templates/crm/app/contacts/page.tsx +17 -0
  10. package/templates/crm/app/deals/[id]/deal-view.tsx +189 -0
  11. package/templates/crm/app/deals/[id]/page.tsx +23 -0
  12. package/templates/crm/app/error.tsx +43 -0
  13. package/templates/crm/app/globals.css +201 -0
  14. package/templates/crm/app/layout.tsx +31 -0
  15. package/templates/crm/app/login/auth-form.tsx +122 -0
  16. package/templates/crm/app/login/page.tsx +38 -0
  17. package/templates/crm/app/not-found.tsx +29 -0
  18. package/templates/crm/app/page.tsx +38 -0
  19. package/templates/crm/app/pipeline-view.tsx +99 -0
  20. package/templates/crm/app/robots.ts +12 -0
  21. package/templates/crm/app/sitemap.ts +27 -0
  22. package/templates/crm/app/workspace.tsx +231 -0
  23. package/templates/crm/app.ts +171 -0
  24. package/templates/crm/bunfig +4 -0
  25. package/templates/crm/components/activity-timeline.tsx +133 -0
  26. package/templates/crm/components/avatar.tsx +40 -0
  27. package/templates/crm/components/board-skeleton.tsx +44 -0
  28. package/templates/crm/components/command-palette.tsx +162 -0
  29. package/templates/crm/components/data-table.tsx +90 -0
  30. package/templates/crm/components/deal-card.tsx +91 -0
  31. package/templates/crm/components/deal-dialog.tsx +169 -0
  32. package/templates/crm/components/empty-state.tsx +43 -0
  33. package/templates/crm/components/kbd.tsx +23 -0
  34. package/templates/crm/components/metrics-bar.tsx +46 -0
  35. package/templates/crm/components/page-header.tsx +36 -0
  36. package/templates/crm/components/pipeline-board.tsx +105 -0
  37. package/templates/crm/components/record-dialog.tsx +126 -0
  38. package/templates/crm/components/sidebar.tsx +112 -0
  39. package/templates/crm/components/stage-badge.tsx +33 -0
  40. package/templates/crm/components/ui/badge.tsx +37 -0
  41. package/templates/crm/components/ui/button.tsx +56 -0
  42. package/templates/crm/components/ui/card.tsx +90 -0
  43. package/templates/crm/components/ui/dialog.tsx +110 -0
  44. package/templates/crm/components/ui/input.tsx +30 -0
  45. package/templates/crm/components/ui/label.tsx +21 -0
  46. package/templates/crm/components/ui/select.tsx +39 -0
  47. package/templates/crm/components/ui/table.tsx +124 -0
  48. package/templates/crm/components/ui/textarea.tsx +24 -0
  49. package/templates/crm/components.json +20 -0
  50. package/templates/crm/functions/createDeal.ts +59 -0
  51. package/templates/crm/functions/logActivity.ts +49 -0
  52. package/templates/crm/functions/moveDeal.ts +53 -0
  53. package/templates/crm/functions/seedWorkspace.ts +63 -0
  54. package/templates/crm/gitignore +10 -0
  55. package/templates/crm/lib/pipeline.ts +221 -0
  56. package/templates/crm/lib/search.ts +78 -0
  57. package/templates/crm/lib/seed.ts +130 -0
  58. package/templates/crm/lib/utils.ts +10 -0
  59. package/templates/crm/package.json +39 -0
  60. package/templates/crm/tests/components.test.tsx +245 -0
  61. package/templates/crm/tests/pipeline.test.ts +219 -0
  62. package/templates/crm/tests/search.test.ts +83 -0
  63. package/templates/crm/tests/setup.ts +5 -0
  64. package/templates/crm/tsconfig.json +33 -0
  65. package/templates/helpdesk/AGENTS.md +116 -0
  66. package/templates/helpdesk/CLAUDE.md +7 -0
  67. package/templates/helpdesk/README.md +90 -0
  68. package/templates/helpdesk/app/customers/customers-view.tsx +140 -0
  69. package/templates/helpdesk/app/customers/page.tsx +17 -0
  70. package/templates/helpdesk/app/error.tsx +43 -0
  71. package/templates/helpdesk/app/globals.css +201 -0
  72. package/templates/helpdesk/app/inbox-view.tsx +147 -0
  73. package/templates/helpdesk/app/layout.tsx +31 -0
  74. package/templates/helpdesk/app/login/auth-form.tsx +122 -0
  75. package/templates/helpdesk/app/login/page.tsx +38 -0
  76. package/templates/helpdesk/app/not-found.tsx +29 -0
  77. package/templates/helpdesk/app/page.tsx +38 -0
  78. package/templates/helpdesk/app/robots.ts +12 -0
  79. package/templates/helpdesk/app/sitemap.ts +27 -0
  80. package/templates/helpdesk/app/tickets/[id]/page.tsx +23 -0
  81. package/templates/helpdesk/app/tickets/[id]/ticket-view.tsx +164 -0
  82. package/templates/helpdesk/app/workspace.tsx +210 -0
  83. package/templates/helpdesk/app.ts +142 -0
  84. package/templates/helpdesk/bunfig +4 -0
  85. package/templates/helpdesk/components/avatar.tsx +40 -0
  86. package/templates/helpdesk/components/command-palette.tsx +162 -0
  87. package/templates/helpdesk/components/data-table.tsx +90 -0
  88. package/templates/helpdesk/components/empty-state.tsx +43 -0
  89. package/templates/helpdesk/components/kbd.tsx +23 -0
  90. package/templates/helpdesk/components/new-ticket-dialog.tsx +147 -0
  91. package/templates/helpdesk/components/page-header.tsx +36 -0
  92. package/templates/helpdesk/components/priority-badge.tsx +61 -0
  93. package/templates/helpdesk/components/record-dialog.tsx +126 -0
  94. package/templates/helpdesk/components/sidebar.tsx +111 -0
  95. package/templates/helpdesk/components/sla-indicator.tsx +47 -0
  96. package/templates/helpdesk/components/thread.tsx +143 -0
  97. package/templates/helpdesk/components/ticket-list.tsx +78 -0
  98. package/templates/helpdesk/components/ui/badge.tsx +37 -0
  99. package/templates/helpdesk/components/ui/button.tsx +56 -0
  100. package/templates/helpdesk/components/ui/card.tsx +90 -0
  101. package/templates/helpdesk/components/ui/dialog.tsx +110 -0
  102. package/templates/helpdesk/components/ui/input.tsx +30 -0
  103. package/templates/helpdesk/components/ui/label.tsx +21 -0
  104. package/templates/helpdesk/components/ui/select.tsx +39 -0
  105. package/templates/helpdesk/components/ui/table.tsx +124 -0
  106. package/templates/helpdesk/components/ui/textarea.tsx +24 -0
  107. package/templates/helpdesk/components.json +20 -0
  108. package/templates/helpdesk/functions/createTicket.ts +62 -0
  109. package/templates/helpdesk/functions/replyToTicket.ts +55 -0
  110. package/templates/helpdesk/functions/seedWorkspace.ts +52 -0
  111. package/templates/helpdesk/functions/setTicketState.ts +58 -0
  112. package/templates/helpdesk/gitignore +10 -0
  113. package/templates/helpdesk/lib/format.ts +53 -0
  114. package/templates/helpdesk/lib/search.ts +78 -0
  115. package/templates/helpdesk/lib/seed.ts +185 -0
  116. package/templates/helpdesk/lib/tickets.ts +184 -0
  117. package/templates/helpdesk/lib/utils.ts +10 -0
  118. package/templates/helpdesk/package.json +39 -0
  119. package/templates/helpdesk/tests/components.test.tsx +202 -0
  120. package/templates/helpdesk/tests/search.test.ts +83 -0
  121. package/templates/helpdesk/tests/setup.ts +5 -0
  122. package/templates/helpdesk/tests/tickets.test.ts +215 -0
  123. package/templates/helpdesk/tsconfig.json +33 -0
  124. package/templates/inventory/AGENTS.md +116 -0
  125. package/templates/inventory/CLAUDE.md +7 -0
  126. package/templates/inventory/README.md +103 -0
  127. package/templates/inventory/app/error.tsx +43 -0
  128. package/templates/inventory/app/globals.css +201 -0
  129. package/templates/inventory/app/layout.tsx +31 -0
  130. package/templates/inventory/app/login/auth-form.tsx +122 -0
  131. package/templates/inventory/app/login/page.tsx +38 -0
  132. package/templates/inventory/app/movements/movements-view.tsx +103 -0
  133. package/templates/inventory/app/movements/page.tsx +17 -0
  134. package/templates/inventory/app/not-found.tsx +29 -0
  135. package/templates/inventory/app/page.tsx +34 -0
  136. package/templates/inventory/app/products/[id]/page.tsx +23 -0
  137. package/templates/inventory/app/products/[id]/product-view.tsx +178 -0
  138. package/templates/inventory/app/products-view.tsx +213 -0
  139. package/templates/inventory/app/robots.ts +12 -0
  140. package/templates/inventory/app/sitemap.ts +27 -0
  141. package/templates/inventory/app/workspace.tsx +172 -0
  142. package/templates/inventory/app.ts +154 -0
  143. package/templates/inventory/bunfig +4 -0
  144. package/templates/inventory/components/avatar.tsx +40 -0
  145. package/templates/inventory/components/command-palette.tsx +162 -0
  146. package/templates/inventory/components/data-table.tsx +90 -0
  147. package/templates/inventory/components/empty-state.tsx +43 -0
  148. package/templates/inventory/components/kbd.tsx +23 -0
  149. package/templates/inventory/components/movement-dialog.tsx +182 -0
  150. package/templates/inventory/components/page-header.tsx +36 -0
  151. package/templates/inventory/components/record-dialog.tsx +126 -0
  152. package/templates/inventory/components/sidebar.tsx +111 -0
  153. package/templates/inventory/components/stock-level.tsx +47 -0
  154. package/templates/inventory/components/summary-bar.tsx +56 -0
  155. package/templates/inventory/components/ui/badge.tsx +37 -0
  156. package/templates/inventory/components/ui/button.tsx +56 -0
  157. package/templates/inventory/components/ui/card.tsx +90 -0
  158. package/templates/inventory/components/ui/dialog.tsx +110 -0
  159. package/templates/inventory/components/ui/input.tsx +30 -0
  160. package/templates/inventory/components/ui/label.tsx +21 -0
  161. package/templates/inventory/components/ui/select.tsx +39 -0
  162. package/templates/inventory/components/ui/table.tsx +124 -0
  163. package/templates/inventory/components/ui/textarea.tsx +24 -0
  164. package/templates/inventory/components.json +20 -0
  165. package/templates/inventory/functions/recordMovement.ts +72 -0
  166. package/templates/inventory/functions/seedWorkspace.ts +40 -0
  167. package/templates/inventory/gitignore +10 -0
  168. package/templates/inventory/lib/format.ts +53 -0
  169. package/templates/inventory/lib/search.ts +78 -0
  170. package/templates/inventory/lib/seed.ts +102 -0
  171. package/templates/inventory/lib/stock.ts +207 -0
  172. package/templates/inventory/lib/utils.ts +10 -0
  173. package/templates/inventory/package.json +39 -0
  174. package/templates/inventory/tests/components.test.tsx +155 -0
  175. package/templates/inventory/tests/search.test.ts +83 -0
  176. package/templates/inventory/tests/setup.ts +5 -0
  177. package/templates/inventory/tests/stock.test.ts +255 -0
  178. package/templates/inventory/tsconfig.json +33 -0
  179. package/templates/invoices/AGENTS.md +116 -0
  180. package/templates/invoices/CLAUDE.md +7 -0
  181. package/templates/invoices/README.md +99 -0
  182. package/templates/invoices/app/clients/clients-view.tsx +130 -0
  183. package/templates/invoices/app/clients/page.tsx +17 -0
  184. package/templates/invoices/app/error.tsx +43 -0
  185. package/templates/invoices/app/globals.css +201 -0
  186. package/templates/invoices/app/invoices/[id]/invoice-view.tsx +253 -0
  187. package/templates/invoices/app/invoices/[id]/page.tsx +23 -0
  188. package/templates/invoices/app/invoices-view.tsx +183 -0
  189. package/templates/invoices/app/layout.tsx +31 -0
  190. package/templates/invoices/app/login/auth-form.tsx +122 -0
  191. package/templates/invoices/app/login/page.tsx +38 -0
  192. package/templates/invoices/app/not-found.tsx +29 -0
  193. package/templates/invoices/app/page.tsx +36 -0
  194. package/templates/invoices/app/robots.ts +12 -0
  195. package/templates/invoices/app/sitemap.ts +27 -0
  196. package/templates/invoices/app/workspace.tsx +174 -0
  197. package/templates/invoices/app.ts +169 -0
  198. package/templates/invoices/bunfig +4 -0
  199. package/templates/invoices/components/avatar.tsx +40 -0
  200. package/templates/invoices/components/command-palette.tsx +162 -0
  201. package/templates/invoices/components/data-table.tsx +90 -0
  202. package/templates/invoices/components/empty-state.tsx +43 -0
  203. package/templates/invoices/components/kbd.tsx +23 -0
  204. package/templates/invoices/components/line-items.tsx +172 -0
  205. package/templates/invoices/components/page-header.tsx +36 -0
  206. package/templates/invoices/components/payment-dialog.tsx +132 -0
  207. package/templates/invoices/components/record-dialog.tsx +126 -0
  208. package/templates/invoices/components/sidebar.tsx +111 -0
  209. package/templates/invoices/components/status-badge.tsx +35 -0
  210. package/templates/invoices/components/summary-bar.tsx +65 -0
  211. package/templates/invoices/components/totals-panel.tsx +70 -0
  212. package/templates/invoices/components/ui/badge.tsx +37 -0
  213. package/templates/invoices/components/ui/button.tsx +56 -0
  214. package/templates/invoices/components/ui/card.tsx +90 -0
  215. package/templates/invoices/components/ui/dialog.tsx +110 -0
  216. package/templates/invoices/components/ui/input.tsx +30 -0
  217. package/templates/invoices/components/ui/label.tsx +21 -0
  218. package/templates/invoices/components/ui/select.tsx +39 -0
  219. package/templates/invoices/components/ui/table.tsx +124 -0
  220. package/templates/invoices/components/ui/textarea.tsx +24 -0
  221. package/templates/invoices/components.json +20 -0
  222. package/templates/invoices/functions/createInvoice.ts +49 -0
  223. package/templates/invoices/functions/recordPayment.ts +72 -0
  224. package/templates/invoices/functions/seedWorkspace.ts +57 -0
  225. package/templates/invoices/functions/setInvoiceStatus.ts +35 -0
  226. package/templates/invoices/gitignore +10 -0
  227. package/templates/invoices/lib/billing.ts +259 -0
  228. package/templates/invoices/lib/format.ts +53 -0
  229. package/templates/invoices/lib/search.ts +78 -0
  230. package/templates/invoices/lib/seed.ts +190 -0
  231. package/templates/invoices/lib/utils.ts +10 -0
  232. package/templates/invoices/package.json +39 -0
  233. package/templates/invoices/tests/billing.test.ts +283 -0
  234. package/templates/invoices/tests/components.test.tsx +172 -0
  235. package/templates/invoices/tests/search.test.ts +83 -0
  236. package/templates/invoices/tests/setup.ts +5 -0
  237. package/templates/invoices/tsconfig.json +33 -0
  238. package/templates/projects/AGENTS.md +116 -0
  239. package/templates/projects/CLAUDE.md +7 -0
  240. package/templates/projects/README.md +96 -0
  241. package/templates/projects/app/clients/clients-view.tsx +126 -0
  242. package/templates/projects/app/clients/page.tsx +17 -0
  243. package/templates/projects/app/error.tsx +43 -0
  244. package/templates/projects/app/globals.css +201 -0
  245. package/templates/projects/app/layout.tsx +31 -0
  246. package/templates/projects/app/login/auth-form.tsx +122 -0
  247. package/templates/projects/app/login/page.tsx +38 -0
  248. package/templates/projects/app/not-found.tsx +29 -0
  249. package/templates/projects/app/page.tsx +30 -0
  250. package/templates/projects/app/projects/[id]/page.tsx +23 -0
  251. package/templates/projects/app/projects/[id]/project-view.tsx +179 -0
  252. package/templates/projects/app/projects-view.tsx +181 -0
  253. package/templates/projects/app/robots.ts +12 -0
  254. package/templates/projects/app/sitemap.ts +27 -0
  255. package/templates/projects/app/workspace.tsx +190 -0
  256. package/templates/projects/app.ts +186 -0
  257. package/templates/projects/bunfig +4 -0
  258. package/templates/projects/components/avatar.tsx +40 -0
  259. package/templates/projects/components/board-skeleton.tsx +37 -0
  260. package/templates/projects/components/budget-bar.tsx +61 -0
  261. package/templates/projects/components/command-palette.tsx +162 -0
  262. package/templates/projects/components/data-table.tsx +90 -0
  263. package/templates/projects/components/empty-state.tsx +43 -0
  264. package/templates/projects/components/kbd.tsx +23 -0
  265. package/templates/projects/components/page-header.tsx +36 -0
  266. package/templates/projects/components/record-dialog.tsx +126 -0
  267. package/templates/projects/components/sidebar.tsx +111 -0
  268. package/templates/projects/components/task-board.tsx +109 -0
  269. package/templates/projects/components/task-card.tsx +77 -0
  270. package/templates/projects/components/time-dialog.tsx +114 -0
  271. package/templates/projects/components/ui/badge.tsx +37 -0
  272. package/templates/projects/components/ui/button.tsx +56 -0
  273. package/templates/projects/components/ui/card.tsx +90 -0
  274. package/templates/projects/components/ui/dialog.tsx +110 -0
  275. package/templates/projects/components/ui/input.tsx +30 -0
  276. package/templates/projects/components/ui/label.tsx +21 -0
  277. package/templates/projects/components/ui/select.tsx +39 -0
  278. package/templates/projects/components/ui/table.tsx +124 -0
  279. package/templates/projects/components/ui/textarea.tsx +24 -0
  280. package/templates/projects/components.json +20 -0
  281. package/templates/projects/functions/logTime.ts +49 -0
  282. package/templates/projects/functions/moveTask.ts +40 -0
  283. package/templates/projects/functions/seedWorkspace.ts +59 -0
  284. package/templates/projects/gitignore +10 -0
  285. package/templates/projects/lib/format.ts +53 -0
  286. package/templates/projects/lib/search.ts +78 -0
  287. package/templates/projects/lib/seed.ts +140 -0
  288. package/templates/projects/lib/utils.ts +10 -0
  289. package/templates/projects/lib/work.ts +233 -0
  290. package/templates/projects/package.json +39 -0
  291. package/templates/projects/tests/components.test.tsx +200 -0
  292. package/templates/projects/tests/search.test.ts +83 -0
  293. package/templates/projects/tests/setup.ts +5 -0
  294. package/templates/projects/tests/work.test.ts +238 -0
  295. package/templates/projects/tsconfig.json +33 -0
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { ArrowLeftRight } from "lucide-react";
5
+ import { PageHeader } from "@/components/page-header";
6
+ import { DataTable, type ColumnDef } from "@/components/data-table";
7
+ import { EmptyState } from "@/components/empty-state";
8
+ import { relativeTime } from "@/lib/format";
9
+ import { reasonById, signedQuantity } from "@/lib/stock";
10
+ import { Workspace, type MovementRow } from "../workspace";
11
+
12
+ /**
13
+ * The ledger, newest first. Read-only by design: a movement is never edited or
14
+ * deleted — the policy forbids it — because rewriting history would change a
15
+ * past valuation and make the current level unexplainable. Corrections are new
16
+ * rows in the opposite direction.
17
+ */
18
+ export function MovementsView({ email }: { email: string }) {
19
+ return (
20
+ <Workspace email={email} pathname="/movements">
21
+ {(data) => {
22
+ const columns: ColumnDef<MovementRow>[] = [
23
+ {
24
+ key: "when",
25
+ header: "When",
26
+ cell: (row) => (
27
+ <span className="text-muted-foreground">{relativeTime(row.createdAt)}</span>
28
+ ),
29
+ },
30
+ {
31
+ key: "product",
32
+ header: "Product",
33
+ cell: (row) => (
34
+ <span className="truncate font-medium">
35
+ {data.productName(row.productId) ?? "—"}
36
+ </span>
37
+ ),
38
+ },
39
+ {
40
+ key: "reason",
41
+ header: "Reason",
42
+ cell: (row) => (
43
+ <span className="text-muted-foreground">
44
+ {reasonById(row.reason)?.label ?? row.reason}
45
+ </span>
46
+ ),
47
+ },
48
+ {
49
+ key: "delta",
50
+ header: "Change",
51
+ numeric: true,
52
+ cell: (row) => (
53
+ <span
54
+ className={
55
+ row.delta > 0 ? "text-stage-won" : row.delta < 0 ? "text-destructive" : ""
56
+ }
57
+ >
58
+ {signedQuantity(row.delta)}
59
+ </span>
60
+ ),
61
+ },
62
+ {
63
+ key: "note",
64
+ header: "Note",
65
+ cell: (row) => (
66
+ <span className="text-muted-foreground">{row.note ?? "—"}</span>
67
+ ),
68
+ },
69
+ {
70
+ key: "who",
71
+ header: "By",
72
+ cell: (row) => (
73
+ <span className="text-muted-foreground">
74
+ {data.actorName(row.actorId) ?? "—"}
75
+ </span>
76
+ ),
77
+ },
78
+ ];
79
+
80
+ const rows = [...data.movements].sort((a, b) =>
81
+ (b.createdAt ?? "").localeCompare(a.createdAt ?? ""),
82
+ );
83
+
84
+ return (
85
+ <>
86
+ <PageHeader title="Movements" count={rows.length} />
87
+ <DataTable
88
+ rows={rows}
89
+ columns={columns}
90
+ empty={
91
+ <EmptyState
92
+ icon={<ArrowLeftRight />}
93
+ title={data.loading ? "Loading…" : "No movements yet"}
94
+ description="Every change to stock is recorded here, permanently. On-hand is the sum of these rows."
95
+ />
96
+ }
97
+ />
98
+ </>
99
+ );
100
+ }}
101
+ </Workspace>
102
+ );
103
+ }
@@ -0,0 +1,17 @@
1
+ import React, { use } from "react";
2
+ import type { Metadata, PageProps } from "@pylonsync/react";
3
+ import { MovementsView } from "./movements-view";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "Movements",
7
+ robots: "noindex",
8
+ };
9
+
10
+ export default function MovementsPage({ auth, response, serverData }: PageProps) {
11
+ if (!auth.user_id || auth.user_id.startsWith("guest_")) {
12
+ response.redirect("/login");
13
+ return null;
14
+ }
15
+ const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
16
+ return <MovementsView email={me?.email ?? ""} />;
17
+ }
@@ -0,0 +1,29 @@
1
+ import React from "react";
2
+ import { Link, useRouter, type NotFoundProps } from "@pylonsync/react";
3
+ import { Button } from "@/components/ui/button";
4
+
5
+ // `app/not-found.tsx` → rendered at HTTP 404 for any unmatched URL (and when
6
+ // a page calls `response.notFound()`). It's HYDRATED, so it's interactive:
7
+ // the buttons below use the client router. Not-found boundaries receive the
8
+ // standard page props (and, matching Next, no `reset`).
9
+ export default function NotFound(_props: NotFoundProps) {
10
+ const router = useRouter();
11
+ return (
12
+ <div className="space-y-6">
13
+ <section>
14
+ <h1 className="text-2xl font-semibold tracking-tight">404</h1>
15
+ <p className="mt-2 text-muted-foreground">
16
+ We couldn&apos;t find that page.
17
+ </p>
18
+ </section>
19
+ <div className="flex items-center gap-3">
20
+ <Button onClick={() => router.back()} variant="outline">
21
+ ← Go back
22
+ </Button>
23
+ <Button asChild>
24
+ <Link href="/">Home</Link>
25
+ </Button>
26
+ </div>
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,34 @@
1
+ import React, { use } from "react";
2
+ import type { Metadata, PageProps } from "@pylonsync/react";
3
+ import { ProductsView } from "./products-view";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "Products",
7
+ robots: "noindex",
8
+ };
9
+
10
+ /**
11
+ * `/` — the stock list.
12
+ *
13
+ * Server-side auth gate only. Reading `auth` opts this render out of caching,
14
+ * which is correct: every screen here is private.
15
+ */
16
+ export default function ProductsPage({
17
+ auth,
18
+ response,
19
+ searchParams,
20
+ serverData,
21
+ }: PageProps) {
22
+ if (!auth.user_id || auth.user_id.startsWith("guest_")) {
23
+ response.redirect("/login");
24
+ return null;
25
+ }
26
+ const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
27
+ return (
28
+ <ProductsView
29
+ email={me?.email ?? ""}
30
+ openNew={searchParams?.new === "product"}
31
+ initialFilter={searchParams?.filter}
32
+ />
33
+ );
34
+ }
@@ -0,0 +1,23 @@
1
+ import React, { use } from "react";
2
+ import type { Metadata, PageProps } from "@pylonsync/react";
3
+ import { ProductView } from "./product-view";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "Product",
7
+ robots: "noindex",
8
+ };
9
+
10
+ /** `app/products/[id]/page.tsx` -> `/products/:id`. */
11
+ export default function ProductPage({
12
+ auth,
13
+ response,
14
+ params,
15
+ serverData,
16
+ }: PageProps<{ id: string }>) {
17
+ if (!auth.user_id || auth.user_id.startsWith("guest_")) {
18
+ response.redirect("/login");
19
+ return null;
20
+ }
21
+ const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
22
+ return <ProductView email={me?.email ?? ""} productId={params.id} />;
23
+ }
@@ -0,0 +1,178 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import { Link, callFn, useRouter } from "@pylonsync/react";
5
+ import { ArrowLeft, ArrowLeftRight } from "lucide-react";
6
+ import { Button } from "@/components/ui/button";
7
+ import { PageHeader } from "@/components/page-header";
8
+ import { EmptyState } from "@/components/empty-state";
9
+ import { StockLevel } from "@/components/stock-level";
10
+ import { MovementDialog } from "@/components/movement-dialog";
11
+ import { relativeTime } from "@/lib/format";
12
+ import { money, reasonById, signedQuantity } from "@/lib/stock";
13
+ import { Workspace } from "../../workspace";
14
+
15
+ export function ProductView({
16
+ email,
17
+ productId,
18
+ }: {
19
+ email: string;
20
+ productId: string;
21
+ }) {
22
+ const [moveOpen, setMoveOpen] = useState(false);
23
+
24
+ return (
25
+ <Workspace email={email} pathname="/">
26
+ {(data) => {
27
+ const product = data.products.find((p) => p.id === productId);
28
+
29
+ if (!product) {
30
+ return (
31
+ <>
32
+ <PageHeader title="Product" />
33
+ <EmptyState
34
+ title={data.loading ? "Loading…" : "Product not found"}
35
+ description={
36
+ data.loading ? undefined : "It may have been deleted, or the link is wrong."
37
+ }
38
+ action={
39
+ data.loading ? undefined : (
40
+ <Button size="sm" variant="secondary" asChild>
41
+ <Link href="/">Back to products</Link>
42
+ </Button>
43
+ )
44
+ }
45
+ />
46
+ </>
47
+ );
48
+ }
49
+
50
+ const quantity = data.levels.get(product.id) ?? 0;
51
+ // Newest first, and running backwards from the current level so each
52
+ // row shows what the shelf held right after it — the question you
53
+ // actually ask when auditing a count.
54
+ const history = data.movements
55
+ .filter((movement) => movement.productId === product.id)
56
+ .sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
57
+ let running = quantity;
58
+ const withBalance = history.map((movement) => {
59
+ const after = running;
60
+ running -= Math.trunc(Number(movement.delta) || 0);
61
+ return { movement, after };
62
+ });
63
+
64
+ return (
65
+ <>
66
+ <PageHeader title={product.name}>
67
+ <Button size="sm" onClick={() => setMoveOpen(true)}>
68
+ <ArrowLeftRight />
69
+ Record movement
70
+ </Button>
71
+ </PageHeader>
72
+
73
+ <div className="min-h-0 flex-1 overflow-y-auto">
74
+ <div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-6">
75
+ <Link
76
+ href="/"
77
+ className="inline-flex w-fit items-center gap-1.5 text-[12px] text-muted-foreground transition-colors hover:text-foreground"
78
+ >
79
+ <ArrowLeft className="size-3.5" />
80
+ Products
81
+ </Link>
82
+
83
+ <dl className="grid grid-cols-2 gap-x-6 gap-y-4 rounded-lg border border-border bg-card p-4 sm:grid-cols-4">
84
+ <Field label="SKU">
85
+ <span className="tabular">{product.sku}</span>
86
+ </Field>
87
+ <Field label="On hand">
88
+ <StockLevel
89
+ quantity={quantity}
90
+ reorderPoint={product.reorderPoint}
91
+ />
92
+ </Field>
93
+ <Field label="Unit cost">{money(product.unitCostCents)}</Field>
94
+ <Field label="Stock value">
95
+ {money(Math.max(0, quantity) * (Number(product.unitCostCents) || 0))}
96
+ </Field>
97
+ </dl>
98
+
99
+ <section>
100
+ <h2 className="mb-2 text-[13px] font-semibold">History</h2>
101
+ <p className="mb-3 text-[12px] text-muted-foreground">
102
+ On hand is the sum of these movements. Nothing here is ever
103
+ edited — a correction is another movement.
104
+ </p>
105
+ {withBalance.length === 0 ? (
106
+ <p className="py-6 text-center text-[12px] text-muted-foreground">
107
+ No movements yet.
108
+ </p>
109
+ ) : (
110
+ <ol className="space-y-1.5">
111
+ {withBalance.map(({ movement, after }) => (
112
+ <li
113
+ key={movement.id}
114
+ className="flex items-center gap-3 rounded-md border border-border bg-card px-3 py-2 text-[12px]"
115
+ >
116
+ <span
117
+ className={
118
+ "tabular w-12 shrink-0 font-medium " +
119
+ (movement.delta > 0 ? "text-stage-won" : "text-destructive")
120
+ }
121
+ >
122
+ {signedQuantity(movement.delta)}
123
+ </span>
124
+ <span className="shrink-0">
125
+ {reasonById(movement.reason)?.label ?? movement.reason}
126
+ </span>
127
+ {movement.note ? (
128
+ <span className="truncate text-muted-foreground">
129
+ {movement.note}
130
+ </span>
131
+ ) : null}
132
+ <span className="tabular ml-auto shrink-0 text-muted-foreground">
133
+ → {after}
134
+ </span>
135
+ <time
136
+ className="w-20 shrink-0 text-right text-muted-foreground"
137
+ dateTime={movement.createdAt ?? undefined}
138
+ >
139
+ {relativeTime(movement.createdAt)}
140
+ </time>
141
+ </li>
142
+ ))}
143
+ </ol>
144
+ )}
145
+ </section>
146
+ </div>
147
+ </div>
148
+
149
+ <MovementDialog
150
+ open={moveOpen}
151
+ products={data.products.filter((p) => !p.archived)}
152
+ currentLevel={(id) => data.levels.get(id) ?? 0}
153
+ defaultProductId={product.id}
154
+ onOpenChange={setMoveOpen}
155
+ onRecord={(pid, delta, reason, note) =>
156
+ callFn("recordMovement", {
157
+ productId: pid,
158
+ delta,
159
+ reason,
160
+ ...(note ? { note } : {}),
161
+ })
162
+ }
163
+ />
164
+ </>
165
+ );
166
+ }}
167
+ </Workspace>
168
+ );
169
+ }
170
+
171
+ function Field({ label, children }: { label: string; children: React.ReactNode }) {
172
+ return (
173
+ <div className="min-w-0">
174
+ <dt className="text-[11px] text-muted-foreground">{label}</dt>
175
+ <dd className="mt-1 truncate text-[13px]">{children}</dd>
176
+ </div>
177
+ );
178
+ }
@@ -0,0 +1,213 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useState } from "react";
4
+ import { callFn, db, useRouter } from "@pylonsync/react";
5
+ import { Boxes, Plus, ArrowLeftRight } from "lucide-react";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Kbd } from "@/components/kbd";
8
+ import { PageHeader } from "@/components/page-header";
9
+ import { DataTable, type ColumnDef } from "@/components/data-table";
10
+ import { EmptyState } from "@/components/empty-state";
11
+ import { RecordDialog } from "@/components/record-dialog";
12
+ import { SummaryBar } from "@/components/summary-bar";
13
+ import { StockLevel } from "@/components/stock-level";
14
+ import { MovementDialog } from "@/components/movement-dialog";
15
+ import { money, parseAmount, parseCount, stockState } from "@/lib/stock";
16
+ import { Workspace, type ProductRow } from "./workspace";
17
+
18
+ export function ProductsView({
19
+ email,
20
+ openNew,
21
+ initialFilter,
22
+ }: {
23
+ email: string;
24
+ openNew?: boolean;
25
+ initialFilter?: string;
26
+ }) {
27
+ const router = useRouter();
28
+ const [newOpen, setNewOpen] = useState(Boolean(openNew));
29
+ const [moveOpen, setMoveOpen] = useState(false);
30
+ const [reorderOnly, setReorderOnly] = useState(initialFilter === "reorder");
31
+
32
+ // "m" records a movement — the thing you do twenty times a day. Ignored
33
+ // while typing.
34
+ useEffect(() => {
35
+ function onKey(event: KeyboardEvent) {
36
+ const target = event.target as HTMLElement | null;
37
+ const typing =
38
+ !!target &&
39
+ (target.tagName === "INPUT" ||
40
+ target.tagName === "TEXTAREA" ||
41
+ target.isContentEditable);
42
+ if (typing || event.metaKey || event.ctrlKey) return;
43
+ if (event.key === "m") {
44
+ event.preventDefault();
45
+ setMoveOpen(true);
46
+ } else if (event.key === "c") {
47
+ event.preventDefault();
48
+ setNewOpen(true);
49
+ }
50
+ }
51
+ window.addEventListener("keydown", onKey);
52
+ return () => window.removeEventListener("keydown", onKey);
53
+ }, []);
54
+
55
+ return (
56
+ <Workspace email={email} pathname="/">
57
+ {(data) => {
58
+ const level = (id: string) => data.levels.get(id) ?? 0;
59
+
60
+ const columns: ColumnDef<ProductRow>[] = [
61
+ {
62
+ key: "sku",
63
+ header: "SKU",
64
+ cell: (row) => <span className="tabular text-muted-foreground">{row.sku}</span>,
65
+ },
66
+ {
67
+ key: "name",
68
+ header: "Product",
69
+ cell: (row) => <span className="truncate font-medium">{row.name}</span>,
70
+ },
71
+ {
72
+ key: "category",
73
+ header: "Category",
74
+ cell: (row) => (
75
+ <span className="text-muted-foreground">{row.category ?? "—"}</span>
76
+ ),
77
+ },
78
+ {
79
+ key: "onhand",
80
+ header: "On hand",
81
+ numeric: true,
82
+ cell: (row) => (
83
+ <StockLevel quantity={level(row.id)} reorderPoint={row.reorderPoint} />
84
+ ),
85
+ },
86
+ {
87
+ key: "reorder",
88
+ header: "Reorder at",
89
+ numeric: true,
90
+ cell: (row) => (
91
+ <span className="text-muted-foreground">{row.reorderPoint || "—"}</span>
92
+ ),
93
+ },
94
+ {
95
+ key: "value",
96
+ header: "Value",
97
+ numeric: true,
98
+ cell: (row) =>
99
+ money(Math.max(0, level(row.id)) * (Number(row.unitCostCents) || 0)),
100
+ },
101
+ ];
102
+
103
+ const rows = data.products
104
+ .filter((product) => !product.archived)
105
+ .filter((product) =>
106
+ reorderOnly
107
+ ? stockState(level(product.id), product.reorderPoint) !== "ok"
108
+ : true,
109
+ )
110
+ .sort((a, b) => a.sku.localeCompare(b.sku));
111
+
112
+ return (
113
+ <>
114
+ <PageHeader title="Products" count={rows.length}>
115
+ <Button
116
+ size="sm"
117
+ variant={reorderOnly ? "default" : "secondary"}
118
+ onClick={() => setReorderOnly((on) => !on)}
119
+ >
120
+ Needs reorder
121
+ </Button>
122
+ <Button size="sm" variant="secondary" onClick={() => setMoveOpen(true)}>
123
+ <ArrowLeftRight />
124
+ Movement
125
+ <Kbd className="ml-1">m</Kbd>
126
+ </Button>
127
+ <Button size="sm" onClick={() => setNewOpen(true)}>
128
+ <Plus />
129
+ New product
130
+ </Button>
131
+ </PageHeader>
132
+
133
+ <SummaryBar products={data.products} movements={data.movements} />
134
+
135
+ <DataTable
136
+ rows={rows}
137
+ columns={columns}
138
+ onRowClick={(row) => router.push(`/products/${row.id}`)}
139
+ empty={
140
+ <EmptyState
141
+ icon={<Boxes />}
142
+ title={
143
+ data.loading
144
+ ? "Loading…"
145
+ : reorderOnly
146
+ ? "Nothing needs reordering"
147
+ : "No products yet"
148
+ }
149
+ description={
150
+ reorderOnly
151
+ ? "Every line is above its reorder point."
152
+ : "Add a product, then record what you receive. On-hand is the sum of those movements — there is no quantity to keep in sync."
153
+ }
154
+ action={
155
+ reorderOnly ? undefined : (
156
+ <Button size="sm" onClick={() => setNewOpen(true)}>
157
+ <Plus />
158
+ New product
159
+ </Button>
160
+ )
161
+ }
162
+ />
163
+ }
164
+ />
165
+
166
+ <RecordDialog
167
+ open={newOpen}
168
+ title="New product"
169
+ submitLabel="Create product"
170
+ onOpenChange={setNewOpen}
171
+ fields={[
172
+ { name: "sku", label: "SKU", required: true, placeholder: "CFE-001" },
173
+ { name: "name", label: "Name", required: true, placeholder: "House Blend, 1kg" },
174
+ { name: "category", label: "Category", placeholder: "Coffee" },
175
+ { name: "unitCost", label: "Unit cost", placeholder: "9.40" },
176
+ { name: "unitPrice", label: "Unit price", placeholder: "18.00" },
177
+ { name: "reorderPoint", label: "Reorder at", placeholder: "12" },
178
+ ]}
179
+ onCreate={async (values) => {
180
+ // Money and counts are parsed here, at the edge — everything
181
+ // below this line is integers.
182
+ await db.insert("Product", {
183
+ sku: values.sku,
184
+ name: values.name,
185
+ category: values.category ?? null,
186
+ unitCostCents: parseAmount(values.unitCost ?? "") ?? 0,
187
+ unitPriceCents: parseAmount(values.unitPrice ?? "") ?? 0,
188
+ reorderPoint: parseCount(values.reorderPoint ?? "") ?? 0,
189
+ archived: false,
190
+ });
191
+ }}
192
+ />
193
+
194
+ <MovementDialog
195
+ open={moveOpen}
196
+ products={data.products.filter((p) => !p.archived)}
197
+ currentLevel={level}
198
+ onOpenChange={setMoveOpen}
199
+ onRecord={(productId, delta, reason, note) =>
200
+ callFn("recordMovement", {
201
+ productId,
202
+ delta,
203
+ reason,
204
+ ...(note ? { note } : {}),
205
+ })
206
+ }
207
+ />
208
+ </>
209
+ );
210
+ }}
211
+ </Workspace>
212
+ );
213
+ }
@@ -0,0 +1,12 @@
1
+ import type { Robots } from "@pylonsync/react";
2
+
3
+ // app/robots.ts → served at /robots.txt. The default export may also be async.
4
+ const SITE = process.env.SITE_URL ?? "http://localhost:4321";
5
+
6
+ export default function robots(): Robots {
7
+ return {
8
+ // Keep the authenticated app and the API out of the index.
9
+ rules: { userAgent: "*", allow: "/", disallow: ["/dashboard", "/api/"] },
10
+ sitemap: `${SITE}/sitemap.xml`,
11
+ };
12
+ }
@@ -0,0 +1,27 @@
1
+ import type { Sitemap } from "@pylonsync/react";
2
+
3
+ // app/sitemap.ts → served at /sitemap.xml. The default export can be async, so
4
+ // it can enumerate dynamic pages from your database. Point SITE_URL at your
5
+ // domain in production.
6
+ const SITE = process.env.SITE_URL ?? "http://localhost:4321";
7
+
8
+ export default async function sitemap(): Promise<Sitemap> {
9
+ // Only public pages belong here — /dashboard is private (and noindex), so
10
+ // it's intentionally left out.
11
+ const staticRoutes: Sitemap = [
12
+ { url: `${SITE}/`, changeFrequency: "weekly", priority: 1 },
13
+ { url: `${SITE}/login`, changeFrequency: "yearly", priority: 0.3 },
14
+ { url: `${SITE}/signup`, changeFrequency: "yearly", priority: 0.5 },
15
+ ];
16
+
17
+ // The export is async, so you can enumerate dynamic pages from a DB read:
18
+ //
19
+ // const posts = await fetchPublishedPosts();
20
+ // const postRoutes: Sitemap = posts.map((p) => ({
21
+ // url: `${SITE}/blog/${p.slug}`,
22
+ // lastModified: p.updatedAt,
23
+ // }));
24
+ // return [...staticRoutes, ...postRoutes];
25
+
26
+ return staticRoutes;
27
+ }