@pylonsync/create-pylon 0.3.344 → 0.3.345

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,169 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogFooter,
6
+ DialogHeader,
7
+ DialogTitle,
8
+ } from "@/components/ui/dialog";
9
+ import { Button } from "@/components/ui/button";
10
+ import { Input } from "@/components/ui/input";
11
+ import { Label } from "@/components/ui/label";
12
+ import { Select } from "@/components/ui/select";
13
+ import { BOARD_STAGES } from "@/lib/pipeline";
14
+
15
+ export interface DealDraft {
16
+ title: string;
17
+ companyId: string;
18
+ value: number;
19
+ stage: string;
20
+ closeDate: string;
21
+ }
22
+
23
+ /**
24
+ * New deal. A dialog rather than an inline row: creating is deliberate and
25
+ * occasional, and a permanent form above the board would push the pipeline —
26
+ * the thing you came to look at — off the screen.
27
+ */
28
+ export function DealDialog({
29
+ open,
30
+ companies,
31
+ defaultStage = "lead",
32
+ onOpenChange,
33
+ onCreate,
34
+ }: {
35
+ open: boolean;
36
+ companies: Array<{ id: string; name: string }>;
37
+ defaultStage?: string;
38
+ onOpenChange: (open: boolean) => void;
39
+ onCreate: (draft: DealDraft) => void | Promise<void>;
40
+ }) {
41
+ const [title, setTitle] = useState("");
42
+ const [companyId, setCompanyId] = useState("");
43
+ const [value, setValue] = useState("");
44
+ const [stage, setStage] = useState(defaultStage);
45
+ const [closeDate, setCloseDate] = useState("");
46
+ const [busy, setBusy] = useState(false);
47
+
48
+ // Reset on each open so a cancelled draft doesn't reappear next time.
49
+ useEffect(() => {
50
+ if (!open) return;
51
+ setTitle("");
52
+ setCompanyId("");
53
+ setValue("");
54
+ setStage(defaultStage);
55
+ setCloseDate("");
56
+ setBusy(false);
57
+ }, [open, defaultStage]);
58
+
59
+ const canSubmit = title.trim().length > 0 && !busy;
60
+
61
+ async function submit(event: React.FormEvent) {
62
+ event.preventDefault();
63
+ if (!canSubmit) return;
64
+ setBusy(true);
65
+ try {
66
+ await onCreate({
67
+ title: title.trim(),
68
+ companyId,
69
+ value: Number(value) || 0,
70
+ stage,
71
+ closeDate,
72
+ });
73
+ onOpenChange(false);
74
+ } finally {
75
+ setBusy(false);
76
+ }
77
+ }
78
+
79
+ return (
80
+ <Dialog open={open} onOpenChange={onOpenChange}>
81
+ <DialogContent>
82
+ <DialogHeader>
83
+ <DialogTitle>New deal</DialogTitle>
84
+ </DialogHeader>
85
+ <form onSubmit={submit} className="grid gap-4">
86
+ <div className="space-y-1.5">
87
+ <Label htmlFor="deal-title">Title</Label>
88
+ <Input
89
+ id="deal-title"
90
+ autoFocus
91
+ value={title}
92
+ placeholder="Fleet dispatch rollout"
93
+ onChange={(event) => setTitle(event.target.value)}
94
+ />
95
+ </div>
96
+
97
+ <div className="space-y-1.5">
98
+ <Label htmlFor="deal-company">Company</Label>
99
+ <Select
100
+ id="deal-company"
101
+ value={companyId}
102
+ onChange={(event) => setCompanyId(event.target.value)}
103
+ >
104
+ <option value="">—</option>
105
+ {companies.map((company) => (
106
+ <option key={company.id} value={company.id}>
107
+ {company.name}
108
+ </option>
109
+ ))}
110
+ </Select>
111
+ </div>
112
+
113
+ <div className="grid grid-cols-2 gap-3">
114
+ <div className="space-y-1.5">
115
+ <Label htmlFor="deal-value">Value</Label>
116
+ <Input
117
+ id="deal-value"
118
+ type="number"
119
+ min="0"
120
+ step="100"
121
+ inputMode="numeric"
122
+ value={value}
123
+ placeholder="10000"
124
+ onChange={(event) => setValue(event.target.value)}
125
+ />
126
+ </div>
127
+ <div className="space-y-1.5">
128
+ <Label htmlFor="deal-stage">Stage</Label>
129
+ <Select
130
+ id="deal-stage"
131
+ value={stage}
132
+ onChange={(event) => setStage(event.target.value)}
133
+ >
134
+ {BOARD_STAGES.map((s) => (
135
+ <option key={s.id} value={s.id}>
136
+ {s.label}
137
+ </option>
138
+ ))}
139
+ </Select>
140
+ </div>
141
+ </div>
142
+
143
+ <div className="space-y-1.5">
144
+ <Label htmlFor="deal-close">Expected close</Label>
145
+ <Input
146
+ id="deal-close"
147
+ type="date"
148
+ value={closeDate}
149
+ onChange={(event) => setCloseDate(event.target.value)}
150
+ />
151
+ </div>
152
+
153
+ <DialogFooter>
154
+ <Button
155
+ type="button"
156
+ variant="ghost"
157
+ onClick={() => onOpenChange(false)}
158
+ >
159
+ Cancel
160
+ </Button>
161
+ <Button type="submit" disabled={!canSubmit}>
162
+ {busy ? "Creating…" : "Create deal"}
163
+ </Button>
164
+ </DialogFooter>
165
+ </form>
166
+ </DialogContent>
167
+ </Dialog>
168
+ );
169
+ }
@@ -0,0 +1,43 @@
1
+ import React from "react";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ /**
5
+ * An empty state that says what this screen is for and offers the one action
6
+ * that fills it. "No records found" tells a new user nothing and gives them
7
+ * nowhere to go.
8
+ */
9
+ export function EmptyState({
10
+ icon,
11
+ title,
12
+ description,
13
+ action,
14
+ className,
15
+ }: {
16
+ icon?: React.ReactNode;
17
+ title: string;
18
+ description?: string;
19
+ action?: React.ReactNode;
20
+ className?: string;
21
+ }) {
22
+ return (
23
+ <div
24
+ className={cn(
25
+ "flex flex-col items-center justify-center px-6 py-16 text-center",
26
+ className,
27
+ )}
28
+ >
29
+ {icon ? (
30
+ <div className="mb-3 flex size-9 items-center justify-center rounded-lg bg-surface-2 text-muted-foreground [&_svg]:size-4">
31
+ {icon}
32
+ </div>
33
+ ) : null}
34
+ <p className="text-[13px] font-medium">{title}</p>
35
+ {description ? (
36
+ <p className="mt-1 max-w-sm text-[12px] leading-5 text-muted-foreground">
37
+ {description}
38
+ </p>
39
+ ) : null}
40
+ {action ? <div className="mt-4">{action}</div> : null}
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,23 @@
1
+ import React from "react";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ /** A keyboard hint. Present in the UI because the shortcuts are real. */
5
+ export function Kbd({
6
+ children,
7
+ className,
8
+ }: {
9
+ children: React.ReactNode;
10
+ className?: string;
11
+ }) {
12
+ return (
13
+ <kbd
14
+ className={cn(
15
+ "inline-flex h-5 min-w-5 items-center justify-center rounded border border-border bg-surface-2 px-1.5",
16
+ "font-sans text-[10px] font-medium text-muted-foreground",
17
+ className,
18
+ )}
19
+ >
20
+ {children}
21
+ </kbd>
22
+ );
23
+ }
@@ -0,0 +1,46 @@
1
+ import React from "react";
2
+ import { metrics, money, percent, type Deal } from "@/lib/pipeline";
3
+
4
+ /**
5
+ * The four numbers a sales lead actually looks at. Derived, never stored — see
6
+ * lib/pipeline.ts, where the arithmetic is unit-tested.
7
+ */
8
+ export function MetricsBar({ deals }: { deals: Deal[] }) {
9
+ const m = metrics(deals);
10
+ return (
11
+ <dl className="grid grid-cols-2 gap-px border-b border-border bg-border sm:grid-cols-4">
12
+ <Metric
13
+ label="Open pipeline"
14
+ value={money(m.open)}
15
+ hint={`${m.openCount} deal${m.openCount === 1 ? "" : "s"}`}
16
+ />
17
+ <Metric
18
+ label="Weighted"
19
+ value={money(m.weighted)}
20
+ hint="by stage probability"
21
+ />
22
+ <Metric label="Won" value={money(m.won)} hint="closed" />
23
+ <Metric label="Win rate" value={percent(m.winRate)} hint="of closed deals" />
24
+ </dl>
25
+ );
26
+ }
27
+
28
+ function Metric({
29
+ label,
30
+ value,
31
+ hint,
32
+ }: {
33
+ label: string;
34
+ value: string;
35
+ hint: string;
36
+ }) {
37
+ return (
38
+ <div className="bg-background px-4 py-3">
39
+ <dt className="text-[11px] text-muted-foreground">{label}</dt>
40
+ <dd className="tabular mt-0.5 text-[18px] font-semibold tracking-tight">
41
+ {value}
42
+ </dd>
43
+ <p className="mt-0.5 text-[11px] text-muted-foreground">{hint}</p>
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,36 @@
1
+ import React from "react";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ /**
5
+ * The bar at the top of every view: title on the left, actions on the right.
6
+ * Fixed height so switching pages doesn't shift the content below it.
7
+ */
8
+ export function PageHeader({
9
+ title,
10
+ count,
11
+ children,
12
+ className,
13
+ }: {
14
+ title: string;
15
+ /** Shown beside the title — the number of rows in view. */
16
+ count?: number;
17
+ children?: React.ReactNode;
18
+ className?: string;
19
+ }) {
20
+ return (
21
+ <header
22
+ className={cn(
23
+ "flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-4",
24
+ className,
25
+ )}
26
+ >
27
+ <div className="flex min-w-0 items-baseline gap-2">
28
+ <h1 className="truncate text-[13px] font-semibold">{title}</h1>
29
+ {typeof count === "number" ? (
30
+ <span className="tabular text-[12px] text-muted-foreground">{count}</span>
31
+ ) : null}
32
+ </div>
33
+ <div className="flex items-center gap-2">{children}</div>
34
+ </header>
35
+ );
36
+ }
@@ -0,0 +1,105 @@
1
+ import React, { useState } from "react";
2
+ import { cn } from "@/lib/utils";
3
+ import { DealCard } from "@/components/deal-card";
4
+ import { BOARD_STAGES, groupByStage, money, type Deal } from "@/lib/pipeline";
5
+
6
+ /**
7
+ * The pipeline, as columns you can drag deals between.
8
+ *
9
+ * Presentational on purpose: it renders whatever deals it's handed and reports
10
+ * a move through `onMove`. The container writes to `db`, the write syncs, and
11
+ * the new rows arrive back here — which is also how a teammate's move appears,
12
+ * so there is exactly one code path for "a deal changed stage".
13
+ */
14
+ export function PipelineBoard({
15
+ deals,
16
+ companyName,
17
+ ownerName,
18
+ onMove,
19
+ onOpen,
20
+ }: {
21
+ deals: Deal[];
22
+ companyName: (id: string | null | undefined) => string | null;
23
+ ownerName: (id: string | null | undefined) => string | null;
24
+ onMove: (dealId: string, stage: string) => void;
25
+ onOpen: (dealId: string) => void;
26
+ }) {
27
+ const [dragging, setDragging] = useState<string | null>(null);
28
+ const [over, setOver] = useState<string | null>(null);
29
+ const columns = groupByStage(deals, BOARD_STAGES);
30
+
31
+ return (
32
+ <div className="flex h-full gap-3 overflow-x-auto p-4">
33
+ {columns.map((column) => {
34
+ const isTarget = over === column.stage.id;
35
+ return (
36
+ <section
37
+ key={column.stage.id}
38
+ onDragOver={(event) => {
39
+ // Preventing default is what marks this a valid drop target.
40
+ event.preventDefault();
41
+ event.dataTransfer.dropEffect = "move";
42
+ if (over !== column.stage.id) setOver(column.stage.id);
43
+ }}
44
+ onDragLeave={(event) => {
45
+ // Ignore bubbling from children, or the highlight flickers.
46
+ if (event.currentTarget.contains(event.relatedTarget as Node)) return;
47
+ setOver((current) => (current === column.stage.id ? null : current));
48
+ }}
49
+ onDrop={(event) => {
50
+ event.preventDefault();
51
+ const id = event.dataTransfer.getData("text/plain") || dragging;
52
+ setOver(null);
53
+ setDragging(null);
54
+ if (!id) return;
55
+ const deal = deals.find((d) => d.id === id);
56
+ // A drop back on the same column is a no-op, not a write.
57
+ if (!deal || deal.stage === column.stage.id) return;
58
+ onMove(id, column.stage.id);
59
+ }}
60
+ className={cn(
61
+ "flex w-[268px] shrink-0 flex-col rounded-xl border transition-colors",
62
+ isTarget
63
+ ? "border-ring/60 bg-surface-2/60"
64
+ : "border-border bg-surface-1/50",
65
+ )}
66
+ aria-label={column.stage.label}
67
+ >
68
+ <header className="flex items-center gap-2 px-3 py-2.5">
69
+ <h2 className="text-[12px] font-medium">{column.stage.label}</h2>
70
+ <span className="tabular text-[11px] text-muted-foreground">
71
+ {column.deals.length}
72
+ </span>
73
+ <span className="tabular ml-auto text-[11px] text-muted-foreground">
74
+ {money(column.total)}
75
+ </span>
76
+ </header>
77
+
78
+ <div className="flex min-h-24 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2">
79
+ {column.deals.map((deal) => (
80
+ <DealCard
81
+ key={deal.id}
82
+ deal={deal}
83
+ company={companyName(deal.companyId)}
84
+ owner={ownerName(deal.ownerId)}
85
+ dragging={dragging === deal.id}
86
+ onOpen={onOpen}
87
+ onDragStart={setDragging}
88
+ onDragEnd={() => {
89
+ setDragging(null);
90
+ setOver(null);
91
+ }}
92
+ />
93
+ ))}
94
+ {column.deals.length === 0 ? (
95
+ <p className="px-1 py-6 text-center text-[11px] text-muted-foreground">
96
+ {isTarget ? "Drop here" : "Nothing here"}
97
+ </p>
98
+ ) : null}
99
+ </div>
100
+ </section>
101
+ );
102
+ })}
103
+ </div>
104
+ );
105
+ }
@@ -0,0 +1,126 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogFooter,
6
+ DialogHeader,
7
+ DialogTitle,
8
+ } from "@/components/ui/dialog";
9
+ import { Button } from "@/components/ui/button";
10
+ import { Input } from "@/components/ui/input";
11
+ import { Label } from "@/components/ui/label";
12
+ import { Select } from "@/components/ui/select";
13
+
14
+ export interface FieldSpec {
15
+ name: string;
16
+ label: string;
17
+ type?: "text" | "email" | "tel";
18
+ placeholder?: string;
19
+ required?: boolean;
20
+ /** Renders a picker instead of a text input. */
21
+ options?: Array<{ value: string; label: string }>;
22
+ }
23
+
24
+ /**
25
+ * A create dialog driven by a field list — used for companies and contacts,
26
+ * which differ only in their fields. Deals get their own dialog because value,
27
+ * stage and close date need real layout rather than a stack of text inputs.
28
+ */
29
+ export function RecordDialog({
30
+ open,
31
+ title,
32
+ fields,
33
+ submitLabel,
34
+ onOpenChange,
35
+ onCreate,
36
+ }: {
37
+ open: boolean;
38
+ title: string;
39
+ fields: FieldSpec[];
40
+ submitLabel: string;
41
+ onOpenChange: (open: boolean) => void;
42
+ onCreate: (values: Record<string, string>) => void | Promise<void>;
43
+ }) {
44
+ const [values, setValues] = useState<Record<string, string>>({});
45
+ const [busy, setBusy] = useState(false);
46
+
47
+ useEffect(() => {
48
+ if (!open) return;
49
+ setValues({});
50
+ setBusy(false);
51
+ }, [open]);
52
+
53
+ const missing = fields.some(
54
+ (field) => field.required && !(values[field.name] ?? "").trim(),
55
+ );
56
+ const canSubmit = !missing && !busy;
57
+
58
+ async function submit(event: React.FormEvent) {
59
+ event.preventDefault();
60
+ if (!canSubmit) return;
61
+ setBusy(true);
62
+ try {
63
+ const trimmed: Record<string, string> = {};
64
+ for (const [key, value] of Object.entries(values)) {
65
+ const clean = value.trim();
66
+ if (clean) trimmed[key] = clean;
67
+ }
68
+ await onCreate(trimmed);
69
+ onOpenChange(false);
70
+ } finally {
71
+ setBusy(false);
72
+ }
73
+ }
74
+
75
+ return (
76
+ <Dialog open={open} onOpenChange={onOpenChange}>
77
+ <DialogContent>
78
+ <DialogHeader>
79
+ <DialogTitle>{title}</DialogTitle>
80
+ </DialogHeader>
81
+ <form onSubmit={submit} className="grid gap-4">
82
+ {fields.map((field, index) => (
83
+ <div key={field.name} className="space-y-1.5">
84
+ <Label htmlFor={`record-${field.name}`}>{field.label}</Label>
85
+ {field.options ? (
86
+ <Select
87
+ id={`record-${field.name}`}
88
+ value={values[field.name] ?? ""}
89
+ onChange={(event) =>
90
+ setValues((v) => ({ ...v, [field.name]: event.target.value }))
91
+ }
92
+ >
93
+ <option value="">—</option>
94
+ {field.options.map((option) => (
95
+ <option key={option.value} value={option.value}>
96
+ {option.label}
97
+ </option>
98
+ ))}
99
+ </Select>
100
+ ) : (
101
+ <Input
102
+ id={`record-${field.name}`}
103
+ autoFocus={index === 0}
104
+ type={field.type ?? "text"}
105
+ placeholder={field.placeholder}
106
+ value={values[field.name] ?? ""}
107
+ onChange={(event) =>
108
+ setValues((v) => ({ ...v, [field.name]: event.target.value }))
109
+ }
110
+ />
111
+ )}
112
+ </div>
113
+ ))}
114
+ <DialogFooter>
115
+ <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
116
+ Cancel
117
+ </Button>
118
+ <Button type="submit" disabled={!canSubmit}>
119
+ {busy ? "Saving…" : submitLabel}
120
+ </Button>
121
+ </DialogFooter>
122
+ </form>
123
+ </DialogContent>
124
+ </Dialog>
125
+ );
126
+ }
@@ -0,0 +1,112 @@
1
+ import React from "react";
2
+ import { Link } from "@pylonsync/react";
3
+ import { Building2, Command, LayoutGrid, LogOut, Users } from "lucide-react";
4
+ import { cn } from "@/lib/utils";
5
+ import { Avatar } from "@/components/avatar";
6
+ import { Kbd } from "@/components/kbd";
7
+
8
+ export interface NavItem {
9
+ href: string;
10
+ label: string;
11
+ icon: React.ReactNode;
12
+ count?: number;
13
+ }
14
+
15
+ export const NAV: Array<Omit<NavItem, "count">> = [
16
+ { href: "/", label: "Pipeline", icon: <LayoutGrid /> },
17
+ { href: "/companies", label: "Companies", icon: <Building2 /> },
18
+ { href: "/contacts", label: "Contacts", icon: <Users /> },
19
+ ];
20
+
21
+ /**
22
+ * The fixed left rail. Presentational: the active path, the counts, and the
23
+ * sign-out handler all arrive as props, so it renders identically in a test.
24
+ */
25
+ export function Sidebar({
26
+ workspace,
27
+ email,
28
+ pathname,
29
+ counts,
30
+ onOpenCommand,
31
+ onSignOut,
32
+ }: {
33
+ workspace: string;
34
+ email: string;
35
+ pathname: string;
36
+ counts?: Record<string, number>;
37
+ onOpenCommand: () => void;
38
+ onSignOut: () => void;
39
+ }) {
40
+ return (
41
+ <aside className="flex w-[224px] shrink-0 flex-col border-r border-border bg-surface-1">
42
+ <div className="flex h-12 items-center gap-2 px-3">
43
+ <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-primary text-[11px] font-bold text-primary-foreground">
44
+ {workspace.slice(0, 1).toUpperCase()}
45
+ </span>
46
+ <span className="truncate text-[13px] font-semibold">{workspace}</span>
47
+ </div>
48
+
49
+ <div className="px-2 pb-2">
50
+ <button
51
+ type="button"
52
+ onClick={onOpenCommand}
53
+ className={cn(
54
+ "flex w-full items-center gap-2 rounded-md border border-border bg-background px-2 py-1.5",
55
+ "text-[12px] text-muted-foreground transition-colors hover:border-ring/40 hover:text-foreground",
56
+ )}
57
+ >
58
+ <Command className="size-3.5" />
59
+ <span className="flex-1 text-left">Search…</span>
60
+ <Kbd>⌘K</Kbd>
61
+ </button>
62
+ </div>
63
+
64
+ <nav className="flex-1 space-y-0.5 px-2" aria-label="Main">
65
+ {NAV.map((item) => {
66
+ const active =
67
+ item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
68
+ return (
69
+ <Link
70
+ key={item.href}
71
+ href={item.href}
72
+ aria-current={active ? "page" : undefined}
73
+ className={cn(
74
+ "flex h-7 items-center gap-2 rounded-md px-2 text-[13px] transition-colors",
75
+ "[&_svg]:size-3.5 [&_svg]:shrink-0",
76
+ active
77
+ ? "bg-surface-2 font-medium text-foreground"
78
+ : "text-muted-foreground hover:bg-surface-2/60 hover:text-foreground",
79
+ )}
80
+ >
81
+ {item.icon}
82
+ <span className="flex-1 truncate">{item.label}</span>
83
+ {counts?.[item.href] ? (
84
+ <span className="tabular text-[11px] text-muted-foreground">
85
+ {counts[item.href]}
86
+ </span>
87
+ ) : null}
88
+ </Link>
89
+ );
90
+ })}
91
+ </nav>
92
+
93
+ <div className="border-t border-border p-2">
94
+ <div className="flex items-center gap-2 rounded-md px-1.5 py-1">
95
+ <Avatar name={email} size="sm" />
96
+ <span className="min-w-0 flex-1 truncate text-[12px] text-muted-foreground">
97
+ {email}
98
+ </span>
99
+ <button
100
+ type="button"
101
+ onClick={onSignOut}
102
+ title="Sign out"
103
+ aria-label="Sign out"
104
+ className="rounded p-1 text-muted-foreground transition-colors hover:bg-surface-2 hover:text-foreground"
105
+ >
106
+ <LogOut className="size-3.5" />
107
+ </button>
108
+ </div>
109
+ </div>
110
+ </aside>
111
+ );
112
+ }
@@ -0,0 +1,33 @@
1
+ import React from "react";
2
+ import { cn } from "@/lib/utils";
3
+ import { stageById } from "@/lib/pipeline";
4
+
5
+ const DOT: Record<string, string> = {
6
+ lead: "bg-stage-lead",
7
+ qualified: "bg-stage-qualified",
8
+ proposal: "bg-stage-proposal",
9
+ won: "bg-stage-won",
10
+ lost: "bg-stage-lost",
11
+ };
12
+
13
+ /**
14
+ * A stage as a dot plus a label. A dot carries the status at a glance in a
15
+ * dense row without the visual weight of a filled chip on every line.
16
+ */
17
+ export function StageBadge({
18
+ stage,
19
+ className,
20
+ }: {
21
+ stage: string;
22
+ className?: string;
23
+ }) {
24
+ const meta = stageById(stage);
25
+ return (
26
+ <span className={cn("inline-flex items-center gap-1.5 whitespace-nowrap", className)}>
27
+ <span
28
+ className={cn("size-1.5 shrink-0 rounded-full", DOT[stage] ?? "bg-muted-foreground")}
29
+ />
30
+ <span className="text-muted-foreground">{meta?.label ?? stage}</span>
31
+ </span>
32
+ );
33
+ }