@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,190 @@
1
+ // Demo data for a brand-new billing workspace.
2
+ //
3
+ // An empty invoice list shows you nothing about totals, ageing, or partial
4
+ // payment. The first sign-in seeds a realistic book — including one invoice
5
+ // already overdue and one part-paid — so those states are visible rather than
6
+ // theoretical.
7
+ //
8
+ // Pure data + pure shaping; `functions/seedWorkspace.ts` stays a thin wrapper.
9
+
10
+ export interface SeedClient {
11
+ key: string;
12
+ name: string;
13
+ email: string;
14
+ address: string;
15
+ }
16
+
17
+ export interface SeedLine {
18
+ description: string;
19
+ /** Whole units; shaped into thousandths. */
20
+ quantity: number;
21
+ /** Whole dollars; shaped into cents. */
22
+ unitPrice: number;
23
+ }
24
+
25
+ export interface SeedInvoice {
26
+ key: string;
27
+ client: string;
28
+ number: string;
29
+ status: string;
30
+ taxRateBps: number;
31
+ /** Days from today; negative is in the past. */
32
+ issuedInDays: number;
33
+ dueInDays: number;
34
+ lines: SeedLine[];
35
+ /** Dollars already received; 0 means nothing paid. */
36
+ paid?: number;
37
+ paidInDays?: number;
38
+ }
39
+
40
+ export const SEED_CLIENTS: SeedClient[] = [
41
+ {
42
+ key: "northwind",
43
+ name: "Northwind Logistics",
44
+ email: "ap@northwind.co",
45
+ address: "412 Dock Road\nRotterdam, NL",
46
+ },
47
+ {
48
+ key: "hallmark",
49
+ name: "Hallmark Dental",
50
+ email: "billing@hallmarkdental.com",
51
+ address: "88 Mercer Street\nAustin, TX 78701",
52
+ },
53
+ {
54
+ key: "riverbed",
55
+ name: "Riverbed Coffee",
56
+ email: "tom@riverbed.coffee",
57
+ address: "5 Mill Lane\nPortland, OR 97209",
58
+ },
59
+ {
60
+ key: "quarry",
61
+ name: "Quarry Design Co",
62
+ email: "ben@quarry.design",
63
+ address: "19 Foundry Street\nManchester, UK",
64
+ },
65
+ ];
66
+
67
+ export const SEED_INVOICES: SeedInvoice[] = [
68
+ // Overdue: sent, past due, nothing paid.
69
+ {
70
+ key: "overdue",
71
+ client: "northwind",
72
+ number: "INV-2026-0001",
73
+ status: "sent",
74
+ taxRateBps: 0,
75
+ issuedInDays: -52,
76
+ dueInDays: -22,
77
+ lines: [
78
+ { description: "Dispatch integration — discovery", quantity: 1, unitPrice: 4800 },
79
+ { description: "Senior engineering", quantity: 32, unitPrice: 165 },
80
+ ],
81
+ },
82
+ // Part-paid: a payment against a larger balance.
83
+ {
84
+ key: "partial",
85
+ client: "hallmark",
86
+ number: "INV-2026-0002",
87
+ status: "sent",
88
+ taxRateBps: 875,
89
+ issuedInDays: -18,
90
+ dueInDays: 12,
91
+ lines: [
92
+ { description: "Patient intake portal — build", quantity: 1, unitPrice: 12_400 },
93
+ { description: "Training session", quantity: 2, unitPrice: 450 },
94
+ ],
95
+ paid: 5000,
96
+ paidInDays: -6,
97
+ },
98
+ // Settled.
99
+ {
100
+ key: "paid",
101
+ client: "riverbed",
102
+ number: "INV-2026-0003",
103
+ status: "paid",
104
+ taxRateBps: 0,
105
+ issuedInDays: -40,
106
+ dueInDays: -10,
107
+ lines: [{ description: "Inventory sync — monthly", quantity: 1, unitPrice: 1800 }],
108
+ paid: 1800,
109
+ paidInDays: -12,
110
+ },
111
+ // Still being written.
112
+ {
113
+ key: "draft",
114
+ client: "quarry",
115
+ number: "INV-2026-0004",
116
+ status: "draft",
117
+ taxRateBps: 2000,
118
+ issuedInDays: 0,
119
+ dueInDays: 30,
120
+ lines: [
121
+ { description: "Client portal — phase 1", quantity: 1, unitPrice: 9500 },
122
+ { description: "SSO configuration", quantity: 4, unitPrice: 180 },
123
+ ],
124
+ },
125
+ ];
126
+
127
+ export interface ShapedSeed {
128
+ clients: Array<{ key: string; row: Record<string, unknown> }>;
129
+ invoices: Array<{ key: string; client: string; row: Record<string, unknown> }>;
130
+ lines: Array<{ invoice: string; row: Record<string, unknown> }>;
131
+ payments: Array<{ invoice: string; row: Record<string, unknown> }>;
132
+ }
133
+
134
+ export function shapeSeed(now: number = Date.now()): ShapedSeed {
135
+ const at = (days: number) => new Date(now + days * 86_400_000).toISOString();
136
+
137
+ const lines: ShapedSeed["lines"] = [];
138
+ const payments: ShapedSeed["payments"] = [];
139
+
140
+ for (const invoice of SEED_INVOICES) {
141
+ invoice.lines.forEach((line, index) => {
142
+ lines.push({
143
+ invoice: invoice.key,
144
+ row: {
145
+ description: line.description,
146
+ quantityMilli: Math.round(line.quantity * 1000),
147
+ unitPriceCents: Math.round(line.unitPrice * 100),
148
+ position: index,
149
+ },
150
+ });
151
+ });
152
+ if (invoice.paid) {
153
+ payments.push({
154
+ invoice: invoice.key,
155
+ row: {
156
+ amountCents: Math.round(invoice.paid * 100),
157
+ method: "bank",
158
+ paidAt: at(invoice.paidInDays ?? 0),
159
+ },
160
+ });
161
+ }
162
+ }
163
+
164
+ return {
165
+ clients: SEED_CLIENTS.map((c) => ({
166
+ key: c.key,
167
+ row: {
168
+ name: c.name,
169
+ email: c.email,
170
+ address: c.address,
171
+ createdAt: at(-90),
172
+ },
173
+ })),
174
+ invoices: SEED_INVOICES.map((i) => ({
175
+ key: i.key,
176
+ client: i.client,
177
+ row: {
178
+ number: i.number,
179
+ status: i.status,
180
+ taxRateBps: i.taxRateBps,
181
+ issueDate: at(i.issuedInDays),
182
+ dueDate: at(i.dueInDays),
183
+ createdAt: at(i.issuedInDays),
184
+ updatedAt: at(i.paidInDays ?? i.issuedInDays),
185
+ },
186
+ })),
187
+ lines,
188
+ payments,
189
+ };
190
+ }
@@ -0,0 +1,10 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ // `cn` — the shadcn class merger. clsx resolves conditional/array class
5
+ // inputs; tailwind-merge then dedupes conflicting Tailwind utilities so
6
+ // the last one wins (e.g. `cn("px-2", "px-4")` → "px-4"). Every shadcn
7
+ // component routes its className through this.
8
+ export function cn(...inputs: ClassValue[]) {
9
+ return twMerge(clsx(inputs));
10
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "__APP_NAME_KEBAB__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "pylon dev",
8
+ "deploy": "pylon deploy",
9
+ "check": "tsc --noEmit",
10
+ "test": "pylon test"
11
+ },
12
+ "dependencies": {
13
+ "@pylonsync/react": "^__PYLON_VERSION__",
14
+ "@pylonsync/sdk": "^__PYLON_VERSION__",
15
+ "@pylonsync/functions": "^__PYLON_VERSION__",
16
+ "@pylonsync/client": "^__PYLON_VERSION__",
17
+ "react": "^19.0.0",
18
+ "react-dom": "^19.0.0",
19
+ "tailwindcss": "^4.3.0",
20
+ "@tailwindcss/cli": "^4.3.0",
21
+ "tw-animate-css": "^1.2.0",
22
+ "class-variance-authority": "^0.7.1",
23
+ "clsx": "^2.1.1",
24
+ "tailwind-merge": "^2.5.0",
25
+ "lucide-react": "^0.460.0",
26
+ "@radix-ui/react-dialog": "^1.1.4",
27
+ "@radix-ui/react-slot": "^1.1.0"
28
+ },
29
+ "devDependencies": {
30
+ "@happy-dom/global-registrator": "^20.10.0",
31
+ "@pylonsync/cli": "^__PYLON_VERSION__",
32
+ "@testing-library/dom": "^10.4.0",
33
+ "@testing-library/react": "^16.3.0",
34
+ "@types/node": "^22.0.0",
35
+ "@types/react": "^19.0.0",
36
+ "@types/react-dom": "^19.0.0",
37
+ "typescript": "^5.6.0"
38
+ }
39
+ }
@@ -0,0 +1,283 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ daysOverdue,
4
+ displayStatus,
5
+ isOverdue,
6
+ isValidStatus,
7
+ lineTotalCents,
8
+ money,
9
+ nextNumber,
10
+ parseAmount,
11
+ parseQuantity,
12
+ quantity,
13
+ summarize,
14
+ totals,
15
+ type Invoice,
16
+ type LineItem,
17
+ type Payment,
18
+ } from "../lib/billing";
19
+
20
+ const NOW = Date.parse("2026-07-27T12:00:00Z");
21
+ const at = (days: number) => new Date(NOW + days * 86_400_000).toISOString();
22
+
23
+ const invoice = (over: Partial<Invoice> = {}): Invoice => ({
24
+ id: "i1",
25
+ number: "INV-2026-0001",
26
+ status: "sent",
27
+ taxRateBps: 0,
28
+ dueDate: at(10),
29
+ ...over,
30
+ });
31
+
32
+ const line = (over: Partial<LineItem> = {}): LineItem => ({
33
+ id: "l1",
34
+ invoiceId: "i1",
35
+ description: "Work",
36
+ quantityMilli: 1000,
37
+ unitPriceCents: 10_000,
38
+ ...over,
39
+ });
40
+
41
+ describe("lineTotalCents", () => {
42
+ test("multiplies quantity by unit price", () => {
43
+ expect(lineTotalCents({ quantityMilli: 32_000, unitPriceCents: 16_500 })).toBe(528_000);
44
+ });
45
+
46
+ test("handles fractional quantities exactly", () => {
47
+ // 1.5 hours at $165.00 — the case a float would get wrong.
48
+ expect(lineTotalCents({ quantityMilli: 1500, unitPriceCents: 16_500 })).toBe(24_750);
49
+ });
50
+
51
+ test("rounds to a whole cent", () => {
52
+ // 0.333 × $10.00 = $3.33
53
+ expect(lineTotalCents({ quantityMilli: 333, unitPriceCents: 1000 })).toBe(333);
54
+ });
55
+
56
+ test("garbage is zero, not NaN", () => {
57
+ expect(
58
+ lineTotalCents({ quantityMilli: NaN as unknown as number, unitPriceCents: 100 }),
59
+ ).toBe(0);
60
+ });
61
+ });
62
+
63
+ describe("totals", () => {
64
+ test("sums lines belonging to THIS invoice only", () => {
65
+ const t = totals(
66
+ invoice(),
67
+ [line({ id: "a" }), line({ id: "b", invoiceId: "other", unitPriceCents: 999_999 })],
68
+ [],
69
+ );
70
+ expect(t.subtotalCents).toBe(10_000);
71
+ });
72
+
73
+ test("applies tax to the rounded subtotal", () => {
74
+ // 8.75% of $124.00 = $10.85
75
+ const t = totals(
76
+ invoice({ taxRateBps: 875 }),
77
+ [line({ unitPriceCents: 12_400 })],
78
+ [],
79
+ );
80
+ expect(t.subtotalCents).toBe(12_400);
81
+ expect(t.taxCents).toBe(1085);
82
+ expect(t.totalCents).toBe(13_485);
83
+ });
84
+
85
+ test("balance is total minus payments", () => {
86
+ const payments: Payment[] = [{ id: "p1", invoiceId: "i1", amountCents: 4000 }];
87
+ const t = totals(invoice(), [line()], payments);
88
+ expect(t.paidCents).toBe(4000);
89
+ expect(t.balanceCents).toBe(6000);
90
+ });
91
+
92
+ test("an overpayment floors the balance at zero", () => {
93
+ // A credit is a decision to handle deliberately, not a negative balance
94
+ // that quietly offsets the next invoice.
95
+ const payments: Payment[] = [{ id: "p1", invoiceId: "i1", amountCents: 99_999 }];
96
+ expect(totals(invoice(), [line()], payments).balanceCents).toBe(0);
97
+ });
98
+
99
+ test("no lines is zero across the board", () => {
100
+ expect(totals(invoice(), [], [])).toEqual({
101
+ subtotalCents: 0,
102
+ taxCents: 0,
103
+ totalCents: 0,
104
+ paidCents: 0,
105
+ balanceCents: 0,
106
+ });
107
+ });
108
+ });
109
+
110
+ describe("isOverdue / displayStatus", () => {
111
+ test("sent, past due, and unpaid is overdue", () => {
112
+ expect(isOverdue(invoice({ dueDate: at(-1) }), 5000, NOW)).toBe(true);
113
+ });
114
+
115
+ test("due today is not yet overdue", () => {
116
+ // The customer has until the end of the day they were given.
117
+ expect(isOverdue(invoice({ dueDate: at(0) }), 5000, NOW)).toBe(false);
118
+ });
119
+
120
+ test("a paid balance is never overdue", () => {
121
+ expect(isOverdue(invoice({ dueDate: at(-30) }), 0, NOW)).toBe(false);
122
+ });
123
+
124
+ test("drafts and voids are never overdue", () => {
125
+ expect(isOverdue(invoice({ status: "draft", dueDate: at(-30) }), 5000, NOW)).toBe(false);
126
+ expect(isOverdue(invoice({ status: "void", dueDate: at(-30) }), 5000, NOW)).toBe(false);
127
+ });
128
+
129
+ test("no due date means nothing to be late for", () => {
130
+ expect(isOverdue(invoice({ dueDate: null }), 5000, NOW)).toBe(false);
131
+ });
132
+
133
+ test("displayStatus surfaces the derived states", () => {
134
+ expect(displayStatus(invoice({ dueDate: at(-3) }), 5000, NOW)).toBe("overdue");
135
+ // Fully paid reads as paid even before someone changes the field.
136
+ expect(displayStatus(invoice(), 0, NOW)).toBe("paid");
137
+ expect(displayStatus(invoice({ status: "draft" }), 100, NOW)).toBe("draft");
138
+ expect(displayStatus(invoice({ status: "void" }), 100, NOW)).toBe("void");
139
+ });
140
+
141
+ test("daysOverdue counts whole days", () => {
142
+ expect(daysOverdue(invoice({ dueDate: at(-7) }), NOW)).toBe(7);
143
+ expect(daysOverdue(invoice({ dueDate: null }), NOW)).toBeNull();
144
+ });
145
+ });
146
+
147
+ describe("summarize", () => {
148
+ const items: LineItem[] = [
149
+ line({ id: "a", invoiceId: "late", unitPriceCents: 100_000 }),
150
+ line({ id: "b", invoiceId: "open", unitPriceCents: 50_000 }),
151
+ line({ id: "c", invoiceId: "done", unitPriceCents: 20_000 }),
152
+ line({ id: "d", invoiceId: "draft", unitPriceCents: 30_000 }),
153
+ ];
154
+ const payments: Payment[] = [{ id: "p", invoiceId: "done", amountCents: 20_000 }];
155
+ const invoices: Invoice[] = [
156
+ invoice({ id: "late", dueDate: at(-5) }),
157
+ invoice({ id: "open", dueDate: at(5) }),
158
+ invoice({ id: "done", status: "paid" }),
159
+ invoice({ id: "draft", status: "draft" }),
160
+ ];
161
+
162
+ test("outstanding covers every sent invoice, overdue only the late ones", () => {
163
+ const s = summarize(invoices, items, payments, NOW);
164
+ expect(s.outstandingCents).toBe(150_000);
165
+ expect(s.overdueCents).toBe(100_000);
166
+ expect(s.overdueCount).toBe(1);
167
+ });
168
+
169
+ test("collected counts payments regardless of status", () => {
170
+ expect(summarize(invoices, items, payments, NOW).paidCents).toBe(20_000);
171
+ });
172
+
173
+ test("drafts are counted but never billed", () => {
174
+ // A draft has real line items ($300) but isn't owed until it's sent —
175
+ // including it would overstate what the business is waiting on.
176
+ const s = summarize(invoices, items, payments, NOW);
177
+ expect(s.draftCount).toBe(1);
178
+ expect(s.outstandingCents).toBe(150_000);
179
+
180
+ const withoutDraft = summarize(
181
+ invoices.filter((i) => i.status !== "draft"),
182
+ items,
183
+ payments,
184
+ NOW,
185
+ );
186
+ expect(withoutDraft.outstandingCents).toBe(s.outstandingCents);
187
+ });
188
+
189
+ test("an empty book is zeroes", () => {
190
+ expect(summarize([], [], [], NOW)).toEqual({
191
+ outstandingCents: 0,
192
+ overdueCents: 0,
193
+ paidCents: 0,
194
+ draftCount: 0,
195
+ overdueCount: 0,
196
+ });
197
+ });
198
+ });
199
+
200
+ describe("money", () => {
201
+ test("shows exact amounts — an invoice never compacts", () => {
202
+ expect(money(124_000)).toBe("$1,240.00");
203
+ expect(money(5)).toBe("$0.05");
204
+ expect(money(0)).toBe("$0.00");
205
+ expect(money(1_234_567)).toBe("$12,345.67");
206
+ });
207
+
208
+ test("handles nothing and negatives", () => {
209
+ expect(money(null)).toBe("$0.00");
210
+ expect(money(-2500)).toBe("-$25.00");
211
+ });
212
+ });
213
+
214
+ describe("quantity", () => {
215
+ test("drops trailing zeroes", () => {
216
+ expect(quantity(1000)).toBe("1");
217
+ expect(quantity(1500)).toBe("1.5");
218
+ expect(quantity(333)).toBe("0.333");
219
+ expect(quantity(null)).toBe("0");
220
+ });
221
+ });
222
+
223
+ describe("parseAmount", () => {
224
+ test("reads what a person actually types", () => {
225
+ expect(parseAmount("1,240.50")).toBe(124_050);
226
+ expect(parseAmount("$1240.5")).toBe(124_050);
227
+ expect(parseAmount("1240")).toBe(124_000);
228
+ expect(parseAmount(" 12.34 ")).toBe(1234);
229
+ });
230
+
231
+ test("rejects nonsense rather than silently billing $0.00", () => {
232
+ expect(parseAmount("")).toBeNull();
233
+ expect(parseAmount("abc")).toBeNull();
234
+ expect(parseAmount("1.2.3")).toBeNull();
235
+ expect(parseAmount(".")).toBeNull();
236
+ });
237
+ });
238
+
239
+ describe("parseQuantity", () => {
240
+ test("reads decimals into thousandths", () => {
241
+ expect(parseQuantity("1.5")).toBe(1500);
242
+ expect(parseQuantity("32")).toBe(32_000);
243
+ });
244
+
245
+ test("rejects nonsense and negatives", () => {
246
+ expect(parseQuantity("abc")).toBeNull();
247
+ expect(parseQuantity("-1")).toBeNull();
248
+ expect(parseQuantity("")).toBeNull();
249
+ });
250
+ });
251
+
252
+ describe("nextNumber", () => {
253
+ test("continues the year's series", () => {
254
+ expect(nextNumber(["INV-2026-0001", "INV-2026-0007"], 2026)).toBe("INV-2026-0008");
255
+ });
256
+
257
+ test("starts a new year at one", () => {
258
+ expect(nextNumber(["INV-2025-0042"], 2026)).toBe("INV-2026-0001");
259
+ });
260
+
261
+ test("ignores numbers it can't read", () => {
262
+ expect(nextNumber(["INV-2026-abc", "", "INV-2026-0003"], 2026)).toBe("INV-2026-0004");
263
+ });
264
+
265
+ test("an empty book starts at one", () => {
266
+ expect(nextNumber([], 2026)).toBe("INV-2026-0001");
267
+ });
268
+
269
+ test("pads so the series sorts lexically", () => {
270
+ // The list view sorts on the string; unpadded numbers would put 10 before 9.
271
+ expect(nextNumber(["INV-2026-0009"], 2026)).toBe("INV-2026-0010");
272
+ });
273
+ });
274
+
275
+ describe("statuses", () => {
276
+ test("overdue is derived, never a stored status", () => {
277
+ expect(isValidStatus("overdue")).toBe(false);
278
+ expect(isValidStatus("draft")).toBe(true);
279
+ expect(isValidStatus("sent")).toBe(true);
280
+ expect(isValidStatus("paid")).toBe(true);
281
+ expect(isValidStatus("void")).toBe(true);
282
+ });
283
+ });
@@ -0,0 +1,172 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ // This project uses the classic JSX transform, so .tsx tests import React.
3
+ import React from "react";
4
+ import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
5
+ import { LineItems } from "../components/line-items";
6
+ import { TotalsPanel } from "../components/totals-panel";
7
+ import { PaymentDialog } from "../components/payment-dialog";
8
+ import { StatusBadge } from "../components/status-badge";
9
+ import { totals, type LineItem } from "../lib/billing";
10
+
11
+ afterEach(cleanup);
12
+
13
+ // Tier 2: components. No mocking needed — they take data as props and report
14
+ // changes through callbacks, because the container owns `db`.
15
+
16
+ const items: LineItem[] = [
17
+ {
18
+ id: "l1",
19
+ invoiceId: "i1",
20
+ description: "Senior engineering",
21
+ quantityMilli: 32_000,
22
+ unitPriceCents: 16_500,
23
+ },
24
+ ];
25
+
26
+ describe("LineItems", () => {
27
+ test("renders the line with its computed amount", () => {
28
+ render(<LineItems items={items} editable onAdd={() => {}} onRemove={() => {}} />);
29
+ expect(screen.getByText("Senior engineering")).toBeDefined();
30
+ expect(screen.getByText("32")).toBeDefined();
31
+ expect(screen.getByText("$5,280.00")).toBeDefined();
32
+ });
33
+
34
+ test("adding parses the typed quantity and price into integers", () => {
35
+ const added: unknown[] = [];
36
+ render(<LineItems items={[]} editable onAdd={(d) => added.push(d)} onRemove={() => {}} />);
37
+ fireEvent.change(screen.getByLabelText("Description"), {
38
+ target: { value: "Training" },
39
+ });
40
+ fireEvent.change(screen.getByLabelText("Quantity"), { target: { value: "1.5" } });
41
+ fireEvent.change(screen.getByLabelText("Unit price"), { target: { value: "450.50" } });
42
+ fireEvent.click(screen.getByRole("button", { name: /add/i }));
43
+ expect(added).toEqual([
44
+ { description: "Training", quantityMilli: 1500, unitPriceCents: 45_050 },
45
+ ]);
46
+ });
47
+
48
+ test("an unreadable price can't be added", () => {
49
+ // Silently billing $0.00 for a typo is the failure this prevents.
50
+ const added: unknown[] = [];
51
+ render(<LineItems items={[]} editable onAdd={(d) => added.push(d)} onRemove={() => {}} />);
52
+ fireEvent.change(screen.getByLabelText("Description"), { target: { value: "X" } });
53
+ fireEvent.change(screen.getByLabelText("Unit price"), { target: { value: "abc" } });
54
+ fireEvent.click(screen.getByRole("button", { name: /add/i }));
55
+ expect(added).toEqual([]);
56
+ });
57
+
58
+ test("a sent invoice hides the editing controls entirely", () => {
59
+ // It's a document someone is paying against; editing desyncs it from their copy.
60
+ render(
61
+ <LineItems items={items} editable={false} onAdd={() => {}} onRemove={() => {}} />,
62
+ );
63
+ expect(screen.queryByLabelText("Description")).toBeNull();
64
+ expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
65
+ });
66
+
67
+ test("removing reports the line id", () => {
68
+ const removed: string[] = [];
69
+ render(
70
+ <LineItems items={items} editable onAdd={() => {}} onRemove={(id) => removed.push(id)} />,
71
+ );
72
+ fireEvent.click(screen.getByRole("button", { name: /remove senior engineering/i }));
73
+ expect(removed).toEqual(["l1"]);
74
+ });
75
+
76
+ test("no lines shows a placeholder row, not an empty table", () => {
77
+ render(<LineItems items={[]} editable onAdd={() => {}} onRemove={() => {}} />);
78
+ expect(screen.getByText("No lines yet.")).toBeDefined();
79
+ });
80
+ });
81
+
82
+ describe("TotalsPanel", () => {
83
+ test("shows subtotal, tax, total and balance in reading order", () => {
84
+ const t = totals(
85
+ { id: "i1", number: "INV-1", status: "sent", taxRateBps: 875 },
86
+ items,
87
+ [{ id: "p", invoiceId: "i1", amountCents: 100_000 }],
88
+ );
89
+ const { container } = render(<TotalsPanel totals={t} taxRateBps={875} />);
90
+ const text = container.textContent ?? "";
91
+ expect(text).toContain("$5,280.00");
92
+ expect(text).toContain("Tax (8.75%)");
93
+ expect(text).toContain("Balance due");
94
+ });
95
+
96
+ test("hides the tax row when there's no tax", () => {
97
+ const t = totals({ id: "i1", number: "INV-1", status: "sent" }, items, []);
98
+ const { container } = render(<TotalsPanel totals={t} taxRateBps={0} />);
99
+ expect(container.textContent).not.toContain("Tax");
100
+ });
101
+ });
102
+
103
+ describe("PaymentDialog", () => {
104
+ test("defaults to the outstanding balance", () => {
105
+ // Retyping the figure is how a typo gets in.
106
+ render(
107
+ <PaymentDialog open balanceCents={52_800} onOpenChange={() => {}} onRecord={() => {}} />,
108
+ );
109
+ expect((screen.getByLabelText("Amount") as HTMLInputElement).value).toBe("528.00");
110
+ });
111
+
112
+ test("records the parsed amount in cents", () => {
113
+ const recorded: unknown[] = [];
114
+ render(
115
+ <PaymentDialog
116
+ open
117
+ balanceCents={52_800}
118
+ onOpenChange={() => {}}
119
+ onRecord={(cents, method) => recorded.push([cents, method])}
120
+ />,
121
+ );
122
+ fireEvent.change(screen.getByLabelText("Amount"), { target: { value: "250.25" } });
123
+ fireEvent.click(screen.getByRole("button", { name: /record payment/i }));
124
+ expect(recorded).toEqual([[25_025, "bank"]]);
125
+ });
126
+
127
+ test("refuses more than the balance", () => {
128
+ const recorded: unknown[] = [];
129
+ render(
130
+ <PaymentDialog
131
+ open
132
+ balanceCents={1000}
133
+ onOpenChange={() => {}}
134
+ onRecord={(cents) => recorded.push(cents)}
135
+ />,
136
+ );
137
+ fireEvent.change(screen.getByLabelText("Amount"), { target: { value: "50" } });
138
+ fireEvent.click(screen.getByRole("button", { name: /record payment/i }));
139
+ expect(recorded).toEqual([]);
140
+ expect(screen.getByText(/more than the balance/i)).toBeDefined();
141
+ });
142
+
143
+ test("renders nothing when closed", () => {
144
+ const { container } = render(
145
+ <PaymentDialog
146
+ open={false}
147
+ balanceCents={1000}
148
+ onOpenChange={() => {}}
149
+ onRecord={() => {}}
150
+ />,
151
+ );
152
+ expect(container.firstChild).toBeNull();
153
+ });
154
+ });
155
+
156
+ describe("StatusBadge", () => {
157
+ test("labels the derived overdue state", () => {
158
+ render(<StatusBadge status="overdue" />);
159
+ expect(screen.getByText("Overdue")).toBeDefined();
160
+ });
161
+
162
+ test("labels the stored ones", () => {
163
+ const { container } = render(
164
+ <>
165
+ <StatusBadge status="draft" />
166
+ <StatusBadge status="paid" />
167
+ </>,
168
+ );
169
+ expect(within(container).getByText("Draft")).toBeDefined();
170
+ expect(within(container).getByText("Paid")).toBeDefined();
171
+ });
172
+ });