@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,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,147 @@
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 { Textarea } from "@/components/ui/textarea";
14
+ import { PRIORITIES } from "@/lib/tickets";
15
+
16
+ export interface TicketDraft {
17
+ subject: string;
18
+ body: string;
19
+ customerId: string;
20
+ priority: string;
21
+ }
22
+
23
+ /**
24
+ * Open a ticket on a customer's behalf — the "log a phone call" path. The body
25
+ * is required as well as the subject: a ticket whose thread starts empty tells
26
+ * the next agent nothing about what was actually reported.
27
+ */
28
+ export function NewTicketDialog({
29
+ open,
30
+ customers,
31
+ onOpenChange,
32
+ onCreate,
33
+ }: {
34
+ open: boolean;
35
+ customers: Array<{ id: string; name: string }>;
36
+ onOpenChange: (open: boolean) => void;
37
+ onCreate: (draft: TicketDraft) => void | Promise<void>;
38
+ }) {
39
+ const [subject, setSubject] = useState("");
40
+ const [body, setBody] = useState("");
41
+ const [customerId, setCustomerId] = useState("");
42
+ const [priority, setPriority] = useState("normal");
43
+ const [busy, setBusy] = useState(false);
44
+
45
+ useEffect(() => {
46
+ if (!open) return;
47
+ setSubject("");
48
+ setBody("");
49
+ setCustomerId("");
50
+ setPriority("normal");
51
+ setBusy(false);
52
+ }, [open]);
53
+
54
+ const canSubmit = subject.trim().length > 0 && body.trim().length > 0 && !busy;
55
+
56
+ async function submit(event: React.FormEvent) {
57
+ event.preventDefault();
58
+ if (!canSubmit) return;
59
+ setBusy(true);
60
+ try {
61
+ await onCreate({
62
+ subject: subject.trim(),
63
+ body: body.trim(),
64
+ customerId,
65
+ priority,
66
+ });
67
+ onOpenChange(false);
68
+ } finally {
69
+ setBusy(false);
70
+ }
71
+ }
72
+
73
+ return (
74
+ <Dialog open={open} onOpenChange={onOpenChange}>
75
+ <DialogContent>
76
+ <DialogHeader>
77
+ <DialogTitle>New ticket</DialogTitle>
78
+ </DialogHeader>
79
+ <form onSubmit={submit} className="grid gap-4">
80
+ <div className="space-y-1.5">
81
+ <Label htmlFor="ticket-subject">Subject</Label>
82
+ <Input
83
+ id="ticket-subject"
84
+ autoFocus
85
+ value={subject}
86
+ placeholder="Export is timing out on large date ranges"
87
+ onChange={(event) => setSubject(event.target.value)}
88
+ />
89
+ </div>
90
+
91
+ <div className="grid grid-cols-2 gap-3">
92
+ <div className="space-y-1.5">
93
+ <Label htmlFor="ticket-customer">Customer</Label>
94
+ <Select
95
+ id="ticket-customer"
96
+ value={customerId}
97
+ onChange={(event) => setCustomerId(event.target.value)}
98
+ >
99
+ <option value="">—</option>
100
+ {customers.map((customer) => (
101
+ <option key={customer.id} value={customer.id}>
102
+ {customer.name}
103
+ </option>
104
+ ))}
105
+ </Select>
106
+ </div>
107
+ <div className="space-y-1.5">
108
+ <Label htmlFor="ticket-priority">Priority</Label>
109
+ <Select
110
+ id="ticket-priority"
111
+ value={priority}
112
+ onChange={(event) => setPriority(event.target.value)}
113
+ >
114
+ {PRIORITIES.map((p) => (
115
+ <option key={p.id} value={p.id}>
116
+ {p.label}
117
+ </option>
118
+ ))}
119
+ </Select>
120
+ </div>
121
+ </div>
122
+
123
+ <div className="space-y-1.5">
124
+ <Label htmlFor="ticket-body">What did they report?</Label>
125
+ <Textarea
126
+ id="ticket-body"
127
+ rows={4}
128
+ value={body}
129
+ placeholder="Pulling a 90-day export spins and eventually errors…"
130
+ onChange={(event) => setBody(event.target.value)}
131
+ className="resize-none"
132
+ />
133
+ </div>
134
+
135
+ <DialogFooter>
136
+ <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
137
+ Cancel
138
+ </Button>
139
+ <Button type="submit" disabled={!canSubmit}>
140
+ {busy ? "Creating…" : "Create ticket"}
141
+ </Button>
142
+ </DialogFooter>
143
+ </form>
144
+ </DialogContent>
145
+ </Dialog>
146
+ );
147
+ }
@@ -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,61 @@
1
+ import React from "react";
2
+ import { cn } from "@/lib/utils";
3
+ import { priorityById, statusById } from "@/lib/tickets";
4
+
5
+ const PRIORITY_DOT: Record<string, string> = {
6
+ urgent: "bg-stage-lost",
7
+ high: "bg-stage-proposal",
8
+ normal: "bg-stage-qualified",
9
+ low: "bg-stage-lead",
10
+ };
11
+
12
+ /** Priority as a dot plus a label — readable in a dense row without a chip. */
13
+ export function PriorityBadge({
14
+ priority,
15
+ className,
16
+ }: {
17
+ priority: string;
18
+ className?: string;
19
+ }) {
20
+ const meta = priorityById(priority);
21
+ return (
22
+ <span className={cn("inline-flex items-center gap-1.5 whitespace-nowrap", className)}>
23
+ <span
24
+ className={cn(
25
+ "size-1.5 shrink-0 rounded-full",
26
+ PRIORITY_DOT[priority] ?? "bg-muted-foreground",
27
+ )}
28
+ />
29
+ <span className="text-muted-foreground">{meta?.label ?? priority}</span>
30
+ </span>
31
+ );
32
+ }
33
+
34
+ const STATUS_STYLE: Record<string, string> = {
35
+ open: "border-stage-qualified/40 text-stage-qualified",
36
+ pending: "border-stage-proposal/40 text-stage-proposal",
37
+ solved: "border-stage-won/40 text-stage-won",
38
+ closed: "border-border text-muted-foreground",
39
+ };
40
+
41
+ /** Status as an outlined chip — it's the field agents filter on most. */
42
+ export function StatusBadge({
43
+ status,
44
+ className,
45
+ }: {
46
+ status: string;
47
+ className?: string;
48
+ }) {
49
+ const meta = statusById(status);
50
+ return (
51
+ <span
52
+ className={cn(
53
+ "inline-flex h-5 items-center rounded border px-1.5 text-[11px] font-medium whitespace-nowrap",
54
+ STATUS_STYLE[status] ?? "border-border text-muted-foreground",
55
+ className,
56
+ )}
57
+ >
58
+ {meta?.label ?? status}
59
+ </span>
60
+ );
61
+ }
@@ -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,111 @@
1
+ import React from "react";
2
+ import { Link } from "@pylonsync/react";
3
+ import { Command, Inbox, 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: "Inbox", icon: <Inbox /> },
17
+ { href: "/customers", label: "Customers", icon: <Users /> },
18
+ ];
19
+
20
+ /**
21
+ * The fixed left rail. Presentational: the active path, the counts, and the
22
+ * sign-out handler all arrive as props, so it renders identically in a test.
23
+ */
24
+ export function Sidebar({
25
+ workspace,
26
+ email,
27
+ pathname,
28
+ counts,
29
+ onOpenCommand,
30
+ onSignOut,
31
+ }: {
32
+ workspace: string;
33
+ email: string;
34
+ pathname: string;
35
+ counts?: Record<string, number>;
36
+ onOpenCommand: () => void;
37
+ onSignOut: () => void;
38
+ }) {
39
+ return (
40
+ <aside className="flex w-[224px] shrink-0 flex-col border-r border-border bg-surface-1">
41
+ <div className="flex h-12 items-center gap-2 px-3">
42
+ <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-primary text-[11px] font-bold text-primary-foreground">
43
+ {workspace.slice(0, 1).toUpperCase()}
44
+ </span>
45
+ <span className="truncate text-[13px] font-semibold">{workspace}</span>
46
+ </div>
47
+
48
+ <div className="px-2 pb-2">
49
+ <button
50
+ type="button"
51
+ onClick={onOpenCommand}
52
+ className={cn(
53
+ "flex w-full items-center gap-2 rounded-md border border-border bg-background px-2 py-1.5",
54
+ "text-[12px] text-muted-foreground transition-colors hover:border-ring/40 hover:text-foreground",
55
+ )}
56
+ >
57
+ <Command className="size-3.5" />
58
+ <span className="flex-1 text-left">Search…</span>
59
+ <Kbd>⌘K</Kbd>
60
+ </button>
61
+ </div>
62
+
63
+ <nav className="flex-1 space-y-0.5 px-2" aria-label="Main">
64
+ {NAV.map((item) => {
65
+ const active =
66
+ item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
67
+ return (
68
+ <Link
69
+ key={item.href}
70
+ href={item.href}
71
+ aria-current={active ? "page" : undefined}
72
+ className={cn(
73
+ "flex h-7 items-center gap-2 rounded-md px-2 text-[13px] transition-colors",
74
+ "[&_svg]:size-3.5 [&_svg]:shrink-0",
75
+ active
76
+ ? "bg-surface-2 font-medium text-foreground"
77
+ : "text-muted-foreground hover:bg-surface-2/60 hover:text-foreground",
78
+ )}
79
+ >
80
+ {item.icon}
81
+ <span className="flex-1 truncate">{item.label}</span>
82
+ {counts?.[item.href] ? (
83
+ <span className="tabular text-[11px] text-muted-foreground">
84
+ {counts[item.href]}
85
+ </span>
86
+ ) : null}
87
+ </Link>
88
+ );
89
+ })}
90
+ </nav>
91
+
92
+ <div className="border-t border-border p-2">
93
+ <div className="flex items-center gap-2 rounded-md px-1.5 py-1">
94
+ <Avatar name={email} size="sm" />
95
+ <span className="min-w-0 flex-1 truncate text-[12px] text-muted-foreground">
96
+ {email}
97
+ </span>
98
+ <button
99
+ type="button"
100
+ onClick={onSignOut}
101
+ title="Sign out"
102
+ aria-label="Sign out"
103
+ className="rounded p-1 text-muted-foreground transition-colors hover:bg-surface-2 hover:text-foreground"
104
+ >
105
+ <LogOut className="size-3.5" />
106
+ </button>
107
+ </div>
108
+ </div>
109
+ </aside>
110
+ );
111
+ }
@@ -0,0 +1,47 @@
1
+ import React from "react";
2
+ import { cn } from "@/lib/utils";
3
+ import { duration } from "@/lib/format";
4
+ import { minutesToBreach, slaState, type Ticket } from "@/lib/tickets";
5
+
6
+ /**
7
+ * How long is left to reply, or how long we're already late.
8
+ *
9
+ * Renders nothing once a ticket has been answered or closed — a queue where
10
+ * every row carries a badge is a queue where none of them mean anything.
11
+ */
12
+ export function SlaIndicator({
13
+ ticket,
14
+ now,
15
+ className,
16
+ }: {
17
+ ticket: Ticket;
18
+ /** Injectable so the value is deterministic in a test. */
19
+ now?: number;
20
+ className?: string;
21
+ }) {
22
+ const state = slaState(ticket, now);
23
+ if (state === "none" || state === "met") return null;
24
+
25
+ const minutes = minutesToBreach(ticket, now);
26
+ if (minutes === null) return null;
27
+
28
+ const breached = state === "breached";
29
+ return (
30
+ <span
31
+ title={breached ? "First response overdue" : "First response due"}
32
+ className={cn(
33
+ "tabular inline-flex items-center gap-1 text-[11px] whitespace-nowrap",
34
+ breached ? "text-destructive" : "text-muted-foreground",
35
+ className,
36
+ )}
37
+ >
38
+ <span
39
+ className={cn(
40
+ "size-1.5 rounded-full",
41
+ breached ? "bg-destructive" : "bg-muted-foreground/60",
42
+ )}
43
+ />
44
+ {breached ? `${duration(minutes)} over` : `${duration(minutes)} left`}
45
+ </span>
46
+ );
47
+ }
@@ -0,0 +1,143 @@
1
+ import React, { useState } from "react";
2
+ import { Lock } from "lucide-react";
3
+ import { cn } from "@/lib/utils";
4
+ import { Avatar } from "@/components/avatar";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Textarea } from "@/components/ui/textarea";
7
+ import { relativeTime } from "@/lib/format";
8
+
9
+ export interface MessageRow {
10
+ id: string;
11
+ body: string;
12
+ fromCustomer?: boolean | null;
13
+ internal?: boolean | null;
14
+ authorId?: string | null;
15
+ createdAt?: string | null;
16
+ }
17
+
18
+ /**
19
+ * The conversation, oldest first, with the reply box pinned at the bottom.
20
+ *
21
+ * Internal notes are visually distinct because sending one to the customer by
22
+ * mistake is the expensive error in a helpdesk — the composer says which mode
23
+ * it's in, and the note keeps that marking in the thread forever.
24
+ */
25
+ export function Thread({
26
+ messages,
27
+ authorName,
28
+ customerName,
29
+ now,
30
+ onSend,
31
+ }: {
32
+ messages: MessageRow[];
33
+ authorName: (id: string | null | undefined) => string | null;
34
+ customerName: string | null;
35
+ now?: number;
36
+ onSend: (body: string, internal: boolean) => void | Promise<void>;
37
+ }) {
38
+ const [body, setBody] = useState("");
39
+ const [internal, setInternal] = useState(false);
40
+ const [busy, setBusy] = useState(false);
41
+
42
+ const ordered = [...messages].sort((a, b) =>
43
+ (a.createdAt ?? "").localeCompare(b.createdAt ?? ""),
44
+ );
45
+
46
+ async function submit(event: React.FormEvent) {
47
+ event.preventDefault();
48
+ const text = body.trim();
49
+ if (!text || busy) return;
50
+ setBusy(true);
51
+ try {
52
+ await onSend(text, internal);
53
+ setBody("");
54
+ } finally {
55
+ setBusy(false);
56
+ }
57
+ }
58
+
59
+ return (
60
+ <div className="flex min-h-0 flex-1 flex-col">
61
+ <div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-6 py-5">
62
+ {ordered.length === 0 ? (
63
+ <p className="py-8 text-center text-[12px] text-muted-foreground">
64
+ No messages on this ticket yet.
65
+ </p>
66
+ ) : (
67
+ ordered.map((message) => {
68
+ const who = message.fromCustomer
69
+ ? customerName
70
+ : authorName(message.authorId);
71
+ return (
72
+ <article
73
+ key={message.id}
74
+ className={cn(
75
+ "rounded-lg border p-3",
76
+ message.internal
77
+ ? "border-stage-proposal/30 bg-stage-proposal/5"
78
+ : message.fromCustomer
79
+ ? "border-border bg-surface-1"
80
+ : "border-border bg-card",
81
+ )}
82
+ >
83
+ <div className="mb-1.5 flex items-center gap-2 text-[11px] text-muted-foreground">
84
+ <Avatar name={who ?? "?"} size="sm" />
85
+ <span className="font-medium text-foreground">{who ?? "Unknown"}</span>
86
+ {message.internal ? (
87
+ <span className="inline-flex items-center gap-1 text-stage-proposal">
88
+ <Lock className="size-3" />
89
+ Internal note
90
+ </span>
91
+ ) : null}
92
+ <time
93
+ className="ml-auto"
94
+ dateTime={message.createdAt ?? undefined}
95
+ >
96
+ {relativeTime(message.createdAt, now)}
97
+ </time>
98
+ </div>
99
+ <p className="whitespace-pre-wrap text-[13px] leading-6">
100
+ {message.body}
101
+ </p>
102
+ </article>
103
+ );
104
+ })
105
+ )}
106
+ </div>
107
+
108
+ <form
109
+ onSubmit={submit}
110
+ className={cn(
111
+ "shrink-0 border-t p-3 transition-colors",
112
+ internal ? "border-stage-proposal/40 bg-stage-proposal/5" : "border-border",
113
+ )}
114
+ >
115
+ <Textarea
116
+ value={body}
117
+ rows={3}
118
+ placeholder={internal ? "Internal note — the customer won't see this…" : "Reply to the customer…"}
119
+ aria-label={internal ? "Internal note" : "Reply"}
120
+ onChange={(event) => setBody(event.target.value)}
121
+ onKeyDown={(event) => {
122
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) submit(event);
123
+ }}
124
+ className="min-h-20 resize-none"
125
+ />
126
+ <div className="mt-2 flex items-center justify-between gap-2">
127
+ <label className="flex items-center gap-2 text-[12px] text-muted-foreground">
128
+ <input
129
+ type="checkbox"
130
+ checked={internal}
131
+ onChange={(event) => setInternal(event.target.checked)}
132
+ className="size-3.5 accent-[var(--stage-proposal)]"
133
+ />
134
+ Internal note
135
+ </label>
136
+ <Button type="submit" size="sm" disabled={!body.trim() || busy}>
137
+ {busy ? "Sending…" : internal ? "Add note" : "Send reply"}
138
+ </Button>
139
+ </div>
140
+ </form>
141
+ </div>
142
+ );
143
+ }