@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,53 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { isValidStage, stageById } from "../lib/pipeline";
3
+
4
+ /**
5
+ * Move a deal to another stage, and log the move on its timeline.
6
+ *
7
+ * A mutation rather than a plain `db.update` from the board, because two things
8
+ * have to happen together: the stage changes and the history records who moved
9
+ * it and when. A forecast you can't audit is a forecast nobody trusts.
10
+ *
11
+ * The stage is validated server-side — the board only ever sends known stages,
12
+ * but the endpoint is reachable directly and an unknown stage would strand the
13
+ * deal in a column that doesn't render.
14
+ */
15
+ export default mutation<
16
+ { dealId: string; stage: string },
17
+ { ok: true }
18
+ >({
19
+ auth: "user",
20
+ args: { dealId: v.id("Deal"), stage: v.string() },
21
+ async handler(ctx, args) {
22
+ if (!isValidStage(args.stage)) {
23
+ throw ctx.error("INVALID_ARGS", `Unknown stage "${args.stage}".`);
24
+ }
25
+
26
+ const deal = await ctx.db.get("Deal", args.dealId);
27
+ if (!deal) throw ctx.error("NOT_FOUND", "Deal not found.");
28
+
29
+ const from = String(deal.stage ?? "");
30
+ // Re-dropping a card in its own column reaches here on a fast double-drag.
31
+ // Nothing changed, so don't write a history entry claiming it did.
32
+ if (from === args.stage) return { ok: true } as const;
33
+
34
+ const now = new Date().toISOString();
35
+ await ctx.db.update("Deal", args.dealId, {
36
+ stage: args.stage,
37
+ updatedAt: now,
38
+ });
39
+
40
+ await ctx.db.insert("Activity", {
41
+ kind: "note",
42
+ body: `Moved from ${stageById(from)?.label ?? from} to ${
43
+ stageById(args.stage)?.label ?? args.stage
44
+ }`,
45
+ dealId: args.dealId,
46
+ companyId: deal.companyId ?? null,
47
+ ownerId: ctx.auth.userId,
48
+ createdAt: now,
49
+ });
50
+
51
+ return { ok: true } as const;
52
+ },
53
+ });
@@ -0,0 +1,63 @@
1
+ import { mutation } from "@pylonsync/functions";
2
+ import { shapeSeed } from "../lib/seed";
3
+
4
+ /**
5
+ * Fill a brand-new workspace with a realistic pipeline, once.
6
+ *
7
+ * An empty CRM demonstrates nothing — no board, no forecast, no sense of what
8
+ * the app is for. The client calls this after sign-in; it returns immediately
9
+ * if any company already exists, so it is safe to call on every load and can
10
+ * never duplicate the fixtures or touch real data.
11
+ *
12
+ * Delete this function and lib/seed.ts once the workspace has real customers.
13
+ */
14
+ export default mutation<Record<string, never>, { seeded: boolean }>({
15
+ auth: "user",
16
+ args: {},
17
+ async handler(ctx) {
18
+ // The guard is "has anything at all", not "has the seed", so a workspace
19
+ // whose demo rows were deleted on purpose stays deleted.
20
+ const existing = await ctx.db.query("Company", { $limit: 1 });
21
+ if (existing.length > 0) return { seeded: false };
22
+
23
+ const seed = shapeSeed();
24
+ const owner = ctx.auth.userId;
25
+
26
+ const companyIds = new Map<string, string>();
27
+ for (const company of seed.companies) {
28
+ const id = await ctx.db.insert("Company", { ...company.row, ownerId: owner });
29
+ companyIds.set(company.key, id as string);
30
+ }
31
+
32
+ const contactIds = new Map<string, string>();
33
+ for (const contact of seed.contacts) {
34
+ const id = await ctx.db.insert("Contact", {
35
+ ...contact.row,
36
+ companyId: companyIds.get(contact.company) ?? null,
37
+ ownerId: owner,
38
+ });
39
+ contactIds.set(String(contact.row.name), id as string);
40
+ }
41
+
42
+ const dealIds = new Map<string, string>();
43
+ for (const deal of seed.deals) {
44
+ const id = await ctx.db.insert("Deal", {
45
+ ...deal.row,
46
+ companyId: companyIds.get(deal.company) ?? null,
47
+ contactId: contactIds.get(deal.contact) ?? null,
48
+ ownerId: owner,
49
+ });
50
+ dealIds.set(deal.title, id as string);
51
+ }
52
+
53
+ for (const activity of seed.activities) {
54
+ await ctx.db.insert("Activity", {
55
+ ...activity.row,
56
+ dealId: dealIds.get(activity.deal) ?? null,
57
+ ownerId: owner,
58
+ });
59
+ }
60
+
61
+ return { seeded: true };
62
+ },
63
+ });
@@ -0,0 +1,10 @@
1
+ node_modules/
2
+ .pylon/
3
+ pylon.manifest.json
4
+ pylon.client.ts
5
+ web/dist/
6
+ *.db
7
+ *.db-*
8
+ .env
9
+ .env.local
10
+ .DS_Store
@@ -0,0 +1,221 @@
1
+ // The pipeline model: stages, money, forecasting, and grouping.
2
+ //
3
+ // Everything here is pure — no React, no `db`. That's what makes the numbers on
4
+ // the board testable without standing up the app, and it's the layer to extend
5
+ // first when you change how the business measures itself.
6
+
7
+ export interface Stage {
8
+ id: string;
9
+ label: string;
10
+ /** Terminal stages leave the forecast: a won or lost deal is no longer open. */
11
+ closed?: "won" | "lost";
12
+ /** Rough likelihood a deal at this stage closes, for the weighted forecast. */
13
+ probability: number;
14
+ }
15
+
16
+ export const PIPELINE: Stage[] = [
17
+ { id: "lead", label: "Lead", probability: 0.1 },
18
+ { id: "qualified", label: "Qualified", probability: 0.3 },
19
+ { id: "proposal", label: "Proposal", probability: 0.6 },
20
+ { id: "won", label: "Won", closed: "won", probability: 1 },
21
+ { id: "lost", label: "Lost", closed: "lost", probability: 0 },
22
+ ];
23
+
24
+ /** Stages shown as board columns — closed/lost is history, not pipeline. */
25
+ export const BOARD_STAGES = PIPELINE.filter((s) => s.closed !== "lost");
26
+
27
+ export const ACTIVITY_KINDS = ["note", "call", "email", "meeting"] as const;
28
+ export type ActivityKind = (typeof ACTIVITY_KINDS)[number];
29
+
30
+ export interface Deal {
31
+ id: string;
32
+ title: string;
33
+ value?: number | null;
34
+ stage: string;
35
+ companyId?: string | null;
36
+ contactId?: string | null;
37
+ closeDate?: string | null;
38
+ ownerId?: string | null;
39
+ createdAt?: string | null;
40
+ updatedAt?: string | null;
41
+ }
42
+
43
+ export function stageById(id: string): Stage | undefined {
44
+ return PIPELINE.find((s) => s.id === id);
45
+ }
46
+
47
+ export function isValidStage(id: string): boolean {
48
+ return PIPELINE.some((s) => s.id === id);
49
+ }
50
+
51
+ export function isOpen(deal: { stage: string }): boolean {
52
+ return !stageById(deal.stage)?.closed;
53
+ }
54
+
55
+ /** The stage a deal advances to, or null when it's already terminal. */
56
+ export function nextStage(current: string): string | null {
57
+ const open = PIPELINE.filter((s) => s.closed !== "lost");
58
+ const index = open.findIndex((s) => s.id === current);
59
+ if (index === -1 || index === open.length - 1) return null;
60
+ return open[index + 1].id;
61
+ }
62
+
63
+ export function previousStage(current: string): string | null {
64
+ const open = PIPELINE.filter((s) => s.closed !== "lost");
65
+ const index = open.findIndex((s) => s.id === current);
66
+ if (index <= 0) return null;
67
+ return open[index - 1].id;
68
+ }
69
+
70
+ export interface Column {
71
+ stage: Stage;
72
+ deals: Deal[];
73
+ total: number;
74
+ }
75
+
76
+ /**
77
+ * Group deals into board columns, newest first within a column. Deals with an
78
+ * unrecognised stage are dropped rather than silently piled into "Lead" — a
79
+ * stage typo should be visible in a test, not disguised as a lead.
80
+ */
81
+ export function groupByStage(deals: Deal[], stages: Stage[] = BOARD_STAGES): Column[] {
82
+ return stages.map((stage) => {
83
+ const inStage = deals
84
+ .filter((deal) => deal.stage === stage.id)
85
+ .sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
86
+ return { stage, deals: inStage, total: sumValue(inStage) };
87
+ });
88
+ }
89
+
90
+ export function sumValue(deals: Deal[]): number {
91
+ return deals.reduce((sum, deal) => sum + (Number(deal.value) || 0), 0);
92
+ }
93
+
94
+ export interface Metrics {
95
+ /** Total value of every deal still in play. */
96
+ open: number;
97
+ /** Open value scaled by each stage's probability — the honest number. */
98
+ weighted: number;
99
+ won: number;
100
+ lost: number;
101
+ openCount: number;
102
+ /** Won / (won + lost), or null when nothing has closed yet. */
103
+ winRate: number | null;
104
+ }
105
+
106
+ export function metrics(deals: Deal[]): Metrics {
107
+ let open = 0;
108
+ let weighted = 0;
109
+ let won = 0;
110
+ let lost = 0;
111
+ let openCount = 0;
112
+ let wonCount = 0;
113
+ let lostCount = 0;
114
+
115
+ for (const deal of deals) {
116
+ const value = Number(deal.value) || 0;
117
+ const stage = stageById(deal.stage);
118
+ if (!stage) continue;
119
+ if (stage.closed === "won") {
120
+ won += value;
121
+ wonCount += 1;
122
+ } else if (stage.closed === "lost") {
123
+ lost += value;
124
+ lostCount += 1;
125
+ } else {
126
+ open += value;
127
+ weighted += value * stage.probability;
128
+ openCount += 1;
129
+ }
130
+ }
131
+
132
+ const decided = wonCount + lostCount;
133
+ return {
134
+ open,
135
+ weighted: Math.round(weighted),
136
+ won,
137
+ lost,
138
+ openCount,
139
+ winRate: decided === 0 ? null : wonCount / decided,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Compact currency for dense UI: $1.2M, $840K, $1,200. Board columns and cards
145
+ * have to line up in a narrow space, and "$1,240,000" wrecks that.
146
+ */
147
+ export function money(value: number | null | undefined): string {
148
+ const n = Number(value) || 0;
149
+ const sign = n < 0 ? "-" : "";
150
+ const abs = Math.abs(n);
151
+ if (abs >= 1_000_000) return `${sign}$${trim(abs / 1_000_000)}M`;
152
+ if (abs >= 10_000) return `${sign}$${Math.round(abs / 1_000)}K`;
153
+ return `${sign}$${Math.round(abs).toLocaleString("en-US")}`;
154
+ }
155
+
156
+ function trim(value: number): string {
157
+ return value.toFixed(1).replace(/\.0$/, "");
158
+ }
159
+
160
+ export function percent(value: number | null): string {
161
+ return value === null ? "—" : `${Math.round(value * 100)}%`;
162
+ }
163
+
164
+ /** Initials for an avatar: "Acme Corp" → "AC", "jordan@x.com" → "J". */
165
+ export function initials(nameOrEmail: string | null | undefined): string {
166
+ const raw = (nameOrEmail ?? "").trim();
167
+ if (!raw) return "?";
168
+ const name = raw.includes("@") ? raw.split("@")[0] : raw;
169
+ const parts = name.split(/[\s._-]+/).filter(Boolean);
170
+ if (parts.length === 0) return "?";
171
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
172
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
173
+ }
174
+
175
+ /**
176
+ * A deterministic accent per record, so the same company keeps the same colour
177
+ * across sessions and machines. Hashing beats storing a colour: no migration,
178
+ * no drift between two clients rendering the same row.
179
+ */
180
+ export function accentIndex(seed: string, buckets = 6): number {
181
+ let hash = 0;
182
+ for (let i = 0; i < seed.length; i += 1) {
183
+ hash = (hash * 31 + seed.charCodeAt(i)) | 0;
184
+ }
185
+ return Math.abs(hash) % buckets;
186
+ }
187
+
188
+ /** "just now" / "4h ago" / "Mar 3" — timeline density without a date library. */
189
+ export function relativeTime(
190
+ iso: string | null | undefined,
191
+ now: number = Date.now(),
192
+ ): string {
193
+ if (!iso) return "";
194
+ const then = Date.parse(iso);
195
+ if (!Number.isFinite(then)) return "";
196
+ const seconds = Math.round((now - then) / 1000);
197
+ if (seconds < 45) return "just now";
198
+ const minutes = Math.round(seconds / 60);
199
+ if (minutes < 60) return `${minutes}m ago`;
200
+ const hours = Math.round(minutes / 60);
201
+ if (hours < 24) return `${hours}h ago`;
202
+ const days = Math.round(hours / 24);
203
+ if (days < 7) return `${days}d ago`;
204
+ return new Date(then).toLocaleDateString("en-US", { month: "short", day: "numeric" });
205
+ }
206
+
207
+ /** Days until close: negative is overdue, null when there's no date set. */
208
+ export function daysUntil(
209
+ iso: string | null | undefined,
210
+ now: number = Date.now(),
211
+ ): number | null {
212
+ if (!iso) return null;
213
+ const then = Date.parse(iso);
214
+ if (!Number.isFinite(then)) return null;
215
+ const startOfDay = (ms: number) => {
216
+ const d = new Date(ms);
217
+ d.setHours(0, 0, 0, 0);
218
+ return d.getTime();
219
+ };
220
+ return Math.round((startOfDay(then) - startOfDay(now)) / 86_400_000);
221
+ }
@@ -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,130 @@
1
+ // Demo data for a brand-new workspace.
2
+ //
3
+ // An empty CRM teaches you nothing: no board, no forecast, no sense of what the
4
+ // thing is for. The first sign-in seeds a realistic pipeline so the app opens
5
+ // with something to look at, then never touches the data again.
6
+ //
7
+ // Pure data + pure shaping, so the seeding function stays a thin wrapper and
8
+ // this file can be edited (or emptied) without reading any server code.
9
+
10
+ export interface SeedCompany {
11
+ key: string;
12
+ name: string;
13
+ domain: string;
14
+ industry: string;
15
+ size: string;
16
+ }
17
+
18
+ export interface SeedContact {
19
+ company: string;
20
+ name: string;
21
+ email: string;
22
+ title: string;
23
+ }
24
+
25
+ export interface SeedDeal {
26
+ company: string;
27
+ contact: string;
28
+ title: string;
29
+ value: number;
30
+ stage: string;
31
+ /** Days from today; negative is in the past. */
32
+ closeInDays: number;
33
+ }
34
+
35
+ export interface SeedActivity {
36
+ deal: string;
37
+ kind: string;
38
+ body: string;
39
+ /** Hours ago. */
40
+ age: number;
41
+ }
42
+
43
+ export const SEED_COMPANIES: SeedCompany[] = [
44
+ { key: "northwind", name: "Northwind Logistics", domain: "northwind.co", industry: "Logistics", size: "200-500" },
45
+ { key: "hallmark", name: "Hallmark Dental", domain: "hallmarkdental.com", industry: "Healthcare", size: "10-50" },
46
+ { key: "riverbed", name: "Riverbed Coffee", domain: "riverbed.coffee", industry: "Food & Beverage", size: "50-200" },
47
+ { key: "atlas", name: "Atlas Fitness", domain: "atlasfitness.io", industry: "Fitness", size: "10-50" },
48
+ { key: "quarry", name: "Quarry Design Co", domain: "quarry.design", industry: "Agency", size: "1-10" },
49
+ { key: "beacon", name: "Beacon Property", domain: "beaconproperty.com", industry: "Real Estate", size: "50-200" },
50
+ ];
51
+
52
+ export const SEED_CONTACTS: SeedContact[] = [
53
+ { company: "northwind", name: "Dana Whitfield", email: "dana@northwind.co", title: "VP Operations" },
54
+ { company: "northwind", name: "Marcus Iyer", email: "marcus@northwind.co", title: "Head of IT" },
55
+ { company: "hallmark", name: "Priya Raman", email: "priya@hallmarkdental.com", title: "Practice Manager" },
56
+ { company: "riverbed", name: "Tom Alvarez", email: "tom@riverbed.coffee", title: "Founder" },
57
+ { company: "atlas", name: "Simone Clarke", email: "simone@atlasfitness.io", title: "Owner" },
58
+ { company: "quarry", name: "Ben Osei", email: "ben@quarry.design", title: "Principal" },
59
+ { company: "beacon", name: "Lena Fischer", email: "lena@beaconproperty.com", title: "Director of Sales" },
60
+ ];
61
+
62
+ export const SEED_DEALS: SeedDeal[] = [
63
+ { company: "northwind", contact: "Dana Whitfield", title: "Fleet dispatch rollout", value: 48000, stage: "proposal", closeInDays: 12 },
64
+ { company: "northwind", contact: "Marcus Iyer", title: "Warehouse scanner pilot", value: 9500, stage: "qualified", closeInDays: 26 },
65
+ { company: "hallmark", contact: "Priya Raman", title: "Patient intake portal", value: 15200, stage: "won", closeInDays: -8 },
66
+ { company: "riverbed", contact: "Tom Alvarez", title: "Multi-store inventory", value: 22000, stage: "proposal", closeInDays: 5 },
67
+ { company: "atlas", contact: "Simone Clarke", title: "Class booking system", value: 7400, stage: "lead", closeInDays: 40 },
68
+ { company: "quarry", contact: "Ben Osei", title: "Client portal build", value: 31000, stage: "qualified", closeInDays: 18 },
69
+ { company: "beacon", contact: "Lena Fischer", title: "Listing sync integration", value: 64000, stage: "lead", closeInDays: 55 },
70
+ { company: "beacon", contact: "Lena Fischer", title: "Agent CRM migration", value: 18500, stage: "lost", closeInDays: -21 },
71
+ ];
72
+
73
+ export const SEED_ACTIVITIES: SeedActivity[] = [
74
+ { deal: "Fleet dispatch rollout", kind: "call", body: "Walked Dana through the dispatch flow. Wants a security review before signing — sending the SOC 2 summary.", age: 5 },
75
+ { deal: "Fleet dispatch rollout", kind: "email", body: "Sent revised proposal with the 3-year term and onboarding included.", age: 30 },
76
+ { deal: "Patient intake portal", kind: "note", body: "Signed. Kickoff scheduled for the 14th — Priya is the day-to-day contact.", age: 190 },
77
+ { deal: "Multi-store inventory", kind: "meeting", body: "Demo with Tom and the two store leads. Main question is offline mode at the market stall.", age: 48 },
78
+ { deal: "Client portal build", kind: "note", body: "Ben wants SSO with their Google Workspace. Confirmed that's in scope.", age: 72 },
79
+ { deal: "Listing sync integration", kind: "call", body: "Intro call. They're evaluating two other vendors; decision in Q3.", age: 96 },
80
+ ];
81
+
82
+ export interface ShapedSeed {
83
+ companies: Array<{ key: string; row: Record<string, unknown> }>;
84
+ contacts: Array<{ company: string; row: Record<string, unknown> }>;
85
+ deals: Array<{ company: string; contact: string; title: string; row: Record<string, unknown> }>;
86
+ activities: Array<{ deal: string; row: Record<string, unknown> }>;
87
+ }
88
+
89
+ /**
90
+ * Turn the fixtures into insertable rows with dates relative to `now`, so a
91
+ * workspace seeded today shows deals closing next week rather than in 2024.
92
+ */
93
+ export function shapeSeed(now: number = Date.now()): ShapedSeed {
94
+ const at = (days: number) => new Date(now + days * 86_400_000).toISOString();
95
+ const hoursAgo = (hours: number) => new Date(now - hours * 3_600_000).toISOString();
96
+
97
+ return {
98
+ companies: SEED_COMPANIES.map((c) => ({
99
+ key: c.key,
100
+ row: {
101
+ name: c.name,
102
+ domain: c.domain,
103
+ industry: c.industry,
104
+ size: c.size,
105
+ createdAt: hoursAgo(240),
106
+ },
107
+ })),
108
+ contacts: SEED_CONTACTS.map((c) => ({
109
+ company: c.company,
110
+ row: { name: c.name, email: c.email, title: c.title, createdAt: hoursAgo(200) },
111
+ })),
112
+ deals: SEED_DEALS.map((d) => ({
113
+ company: d.company,
114
+ contact: d.contact,
115
+ title: d.title,
116
+ row: {
117
+ title: d.title,
118
+ value: d.value,
119
+ stage: d.stage,
120
+ closeDate: at(d.closeInDays),
121
+ createdAt: hoursAgo(160),
122
+ updatedAt: hoursAgo(4),
123
+ },
124
+ })),
125
+ activities: SEED_ACTIVITIES.map((a) => ({
126
+ deal: a.deal,
127
+ row: { kind: a.kind, body: a.body, createdAt: hoursAgo(a.age) },
128
+ })),
129
+ };
130
+ }
@@ -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
+ }