@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,109 @@
1
+ import React, { useState } from "react";
2
+ import { cn } from "@/lib/utils";
3
+ import { TaskCard } from "@/components/task-card";
4
+ import { duration, groupByStatus, type Task } from "@/lib/work";
5
+
6
+ /**
7
+ * The task board.
8
+ *
9
+ * Presentational: it renders whatever tasks it\'s handed and reports a move
10
+ * through `onMove`. The container writes, the write syncs, and the new rows
11
+ * arrive back here — which is also how a teammate\'s move appears, so there is
12
+ * exactly one code path for "a task changed column".
13
+ */
14
+ export function TaskBoard({
15
+ tasks,
16
+ assigneeName,
17
+ minutesFor,
18
+ onMove,
19
+ onOpen,
20
+ }: {
21
+ tasks: Task[];
22
+ assigneeName: (id: string | null | undefined) => string | null;
23
+ minutesFor: (taskId: string) => number;
24
+ onMove: (taskId: string, status: string) => void;
25
+ onOpen: (taskId: string) => void;
26
+ }) {
27
+ const [dragging, setDragging] = useState<string | null>(null);
28
+ const [over, setOver] = useState<string | null>(null);
29
+ const columns = groupByStatus(tasks);
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.status.id;
35
+ const logged = column.tasks.reduce(
36
+ (sum, task) => sum + minutesFor(task.id),
37
+ 0,
38
+ );
39
+ return (
40
+ <section
41
+ key={column.status.id}
42
+ onDragOver={(event) => {
43
+ // Preventing default is what marks this a valid drop target.
44
+ event.preventDefault();
45
+ event.dataTransfer.dropEffect = "move";
46
+ if (over !== column.status.id) setOver(column.status.id);
47
+ }}
48
+ onDragLeave={(event) => {
49
+ // Ignore bubbling from children, or the highlight flickers.
50
+ if (event.currentTarget.contains(event.relatedTarget as Node)) return;
51
+ setOver((current) => (current === column.status.id ? null : current));
52
+ }}
53
+ onDrop={(event) => {
54
+ event.preventDefault();
55
+ const id = event.dataTransfer.getData("text/plain") || dragging;
56
+ setOver(null);
57
+ setDragging(null);
58
+ if (!id) return;
59
+ const task = tasks.find((t) => t.id === id);
60
+ // A drop back on the same column is a no-op, not a write.
61
+ if (!task || task.status === column.status.id) return;
62
+ onMove(id, column.status.id);
63
+ }}
64
+ className={cn(
65
+ "flex w-[268px] shrink-0 flex-col rounded-xl border transition-colors",
66
+ isTarget ? "border-ring/60 bg-surface-2/60" : "border-border bg-surface-1/50",
67
+ )}
68
+ aria-label={column.status.label}
69
+ >
70
+ <header className="flex items-center gap-2 px-3 py-2.5">
71
+ <h2 className="text-[12px] font-medium">{column.status.label}</h2>
72
+ <span className="tabular text-[11px] text-muted-foreground">
73
+ {column.tasks.length}
74
+ </span>
75
+ {logged > 0 ? (
76
+ <span className="tabular ml-auto text-[11px] text-muted-foreground">
77
+ {duration(logged)}
78
+ </span>
79
+ ) : null}
80
+ </header>
81
+
82
+ <div className="flex min-h-24 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2">
83
+ {column.tasks.map((task) => (
84
+ <TaskCard
85
+ key={task.id}
86
+ task={task}
87
+ assignee={assigneeName(task.assigneeId)}
88
+ loggedMinutes={minutesFor(task.id)}
89
+ dragging={dragging === task.id}
90
+ onOpen={onOpen}
91
+ onDragStart={setDragging}
92
+ onDragEnd={() => {
93
+ setDragging(null);
94
+ setOver(null);
95
+ }}
96
+ />
97
+ ))}
98
+ {column.tasks.length === 0 ? (
99
+ <p className="px-1 py-6 text-center text-[11px] text-muted-foreground">
100
+ {isTarget ? "Drop here" : "Nothing here"}
101
+ </p>
102
+ ) : null}
103
+ </div>
104
+ </section>
105
+ );
106
+ })}
107
+ </div>
108
+ );
109
+ }
@@ -0,0 +1,77 @@
1
+ import React from "react";
2
+ import { cn } from "@/lib/utils";
3
+ import { Avatar } from "@/components/avatar";
4
+ import { duration, type Task } from "@/lib/work";
5
+
6
+ /**
7
+ * One task on the board. Draggable, but never the source of truth for which
8
+ * column it sits in — the parent decides that from the synced row, so a
9
+ * teammate\'s move lands here through the same path as your own.
10
+ */
11
+ export function TaskCard({
12
+ task,
13
+ assignee,
14
+ loggedMinutes,
15
+ onOpen,
16
+ onDragStart,
17
+ onDragEnd,
18
+ dragging,
19
+ }: {
20
+ task: Task;
21
+ assignee?: string | null;
22
+ loggedMinutes: number;
23
+ onOpen: (id: string) => void;
24
+ onDragStart: (id: string) => void;
25
+ onDragEnd: () => void;
26
+ dragging?: boolean;
27
+ }) {
28
+ const estimate = Number(task.estimateMinutes) || 0;
29
+ // Only flag an overrun — showing "3h / 8h" on every card is noise, but a task
30
+ // that has blown its estimate is worth seeing from the board.
31
+ const over = estimate > 0 && loggedMinutes > estimate;
32
+
33
+ return (
34
+ <article
35
+ draggable
36
+ onDragStart={(event) => {
37
+ // setData is required for Firefox to start a drag at all.
38
+ event.dataTransfer.setData("text/plain", task.id);
39
+ event.dataTransfer.effectAllowed = "move";
40
+ onDragStart(task.id);
41
+ }}
42
+ onDragEnd={onDragEnd}
43
+ onClick={() => onOpen(task.id)}
44
+ onKeyDown={(event) => {
45
+ if (event.key === "Enter" || event.key === " ") {
46
+ event.preventDefault();
47
+ onOpen(task.id);
48
+ }
49
+ }}
50
+ role="button"
51
+ tabIndex={0}
52
+ aria-label={task.title}
53
+ className={cn(
54
+ "group cursor-pointer rounded-lg border border-border bg-card p-2.5 transition-all",
55
+ "hover:border-ring/40 focus-visible:border-ring",
56
+ dragging && "opacity-40",
57
+ )}
58
+ >
59
+ <p className="line-clamp-2 text-[13px] font-medium leading-snug">{task.title}</p>
60
+ <div className="mt-2.5 flex items-center justify-between gap-2">
61
+ {assignee ? <Avatar name={assignee} size="sm" /> : <span />}
62
+ {loggedMinutes > 0 ? (
63
+ <span
64
+ className={cn(
65
+ "tabular text-[11px]",
66
+ over ? "text-destructive" : "text-muted-foreground",
67
+ )}
68
+ title={estimate > 0 ? `Estimate ${duration(estimate)}` : undefined}
69
+ >
70
+ {duration(loggedMinutes)}
71
+ {over ? ` / ${duration(estimate)}` : ""}
72
+ </span>
73
+ ) : null}
74
+ </div>
75
+ </article>
76
+ );
77
+ }
@@ -0,0 +1,114 @@
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 { duration, parseDuration } from "@/lib/work";
13
+
14
+ /**
15
+ * Log time against a task.
16
+ *
17
+ * The field accepts however people write time — "90", "1.5h", "1h30", "45m".
18
+ * Rejecting "1h30" because it isn\'t "90" is the kind of friction that stops
19
+ * time being logged at all, which costs far more than a lenient parser.
20
+ */
21
+ export function TimeDialog({
22
+ open,
23
+ taskTitle,
24
+ onOpenChange,
25
+ onLog,
26
+ }: {
27
+ open: boolean;
28
+ taskTitle: string;
29
+ onOpenChange: (open: boolean) => void;
30
+ onLog: (minutes: number, note: string) => void | Promise<void>;
31
+ }) {
32
+ const [value, setValue] = useState("");
33
+ const [note, setNote] = useState("");
34
+ const [busy, setBusy] = useState(false);
35
+
36
+ useEffect(() => {
37
+ if (!open) return;
38
+ setValue("");
39
+ setNote("");
40
+ setBusy(false);
41
+ }, [open]);
42
+
43
+ const minutes = parseDuration(value);
44
+ const tooMuch = minutes !== null && Math.abs(minutes) > 1440;
45
+ const canSubmit = minutes !== null && minutes !== 0 && !tooMuch && !busy;
46
+
47
+ async function submit(event: React.FormEvent) {
48
+ event.preventDefault();
49
+ if (!canSubmit) return;
50
+ setBusy(true);
51
+ try {
52
+ await onLog(minutes as number, note.trim());
53
+ onOpenChange(false);
54
+ } finally {
55
+ setBusy(false);
56
+ }
57
+ }
58
+
59
+ return (
60
+ <Dialog open={open} onOpenChange={onOpenChange}>
61
+ <DialogContent>
62
+ <DialogHeader>
63
+ <DialogTitle>Log time</DialogTitle>
64
+ </DialogHeader>
65
+ <form onSubmit={submit} className="grid gap-4">
66
+ <p className="truncate text-[12px] text-muted-foreground">{taskTitle}</p>
67
+
68
+ <div className="space-y-1.5">
69
+ <Label htmlFor="time-value">Time</Label>
70
+ <Input
71
+ id="time-value"
72
+ autoFocus
73
+ placeholder="1h30, 90, or 45m"
74
+ value={value}
75
+ onChange={(event) => setValue(event.target.value)}
76
+ />
77
+ {minutes !== null && !tooMuch ? (
78
+ <p className="text-[11px] text-muted-foreground">{duration(minutes)}</p>
79
+ ) : null}
80
+ {tooMuch ? (
81
+ <p className="text-[11px] text-destructive">
82
+ That\'s more than a day — check the value.
83
+ </p>
84
+ ) : null}
85
+ {minutes === null && value.trim() ? (
86
+ <p className="text-[11px] text-destructive">
87
+ Try 90, 1.5h, 1h30, or 45m.
88
+ </p>
89
+ ) : null}
90
+ </div>
91
+
92
+ <div className="space-y-1.5">
93
+ <Label htmlFor="time-note">Note</Label>
94
+ <Input
95
+ id="time-note"
96
+ placeholder="Optional"
97
+ value={note}
98
+ onChange={(event) => setNote(event.target.value)}
99
+ />
100
+ </div>
101
+
102
+ <DialogFooter>
103
+ <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
104
+ Cancel
105
+ </Button>
106
+ <Button type="submit" disabled={!canSubmit}>
107
+ {busy ? "Logging…" : "Log time"}
108
+ </Button>
109
+ </DialogFooter>
110
+ </form>
111
+ </DialogContent>
112
+ </Dialog>
113
+ );
114
+ }
@@ -0,0 +1,37 @@
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const badgeVariants = cva(
7
+ "inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "border-transparent bg-primary text-primary-foreground",
12
+ secondary: "border-transparent bg-secondary text-secondary-foreground",
13
+ destructive: "border-transparent bg-destructive text-white",
14
+ outline: "text-foreground",
15
+ },
16
+ },
17
+ defaultVariants: {
18
+ variant: "default",
19
+ },
20
+ },
21
+ );
22
+
23
+ export interface BadgeProps
24
+ extends React.HTMLAttributes<HTMLSpanElement>,
25
+ VariantProps<typeof badgeVariants> {}
26
+
27
+ function Badge({ className, variant, ...props }: BadgeProps) {
28
+ return (
29
+ <span
30
+ data-slot="badge"
31
+ className={cn(badgeVariants({ variant }), className)}
32
+ {...props}
33
+ />
34
+ );
35
+ }
36
+
37
+ export { Badge, badgeVariants };
@@ -0,0 +1,56 @@
1
+ import * as React from "react";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
13
+ destructive:
14
+ "bg-destructive text-white hover:bg-destructive/90",
15
+ outline:
16
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17
+ secondary:
18
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19
+ ghost: "hover:bg-accent hover:text-accent-foreground",
20
+ link: "text-primary underline-offset-4 hover:underline",
21
+ },
22
+ size: {
23
+ default: "h-9 px-4 py-2",
24
+ sm: "h-8 rounded-md px-3 text-xs",
25
+ lg: "h-10 rounded-md px-8",
26
+ icon: "h-9 w-9",
27
+ },
28
+ },
29
+ defaultVariants: {
30
+ variant: "default",
31
+ size: "default",
32
+ },
33
+ },
34
+ );
35
+
36
+ export interface ButtonProps
37
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
38
+ VariantProps<typeof buttonVariants> {
39
+ asChild?: boolean;
40
+ }
41
+
42
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
43
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
44
+ const Comp = asChild ? Slot : "button";
45
+ return (
46
+ <Comp
47
+ className={cn(buttonVariants({ variant, size, className }))}
48
+ ref={ref}
49
+ {...props}
50
+ />
51
+ );
52
+ },
53
+ );
54
+ Button.displayName = "Button";
55
+
56
+ export { Button, buttonVariants };
@@ -0,0 +1,90 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
6
+ return (
7
+ <div
8
+ data-slot="card"
9
+ className={cn(
10
+ "rounded-xl border bg-card text-card-foreground shadow-sm",
11
+ className,
12
+ )}
13
+ {...props}
14
+ />
15
+ );
16
+ }
17
+
18
+ function CardHeader({
19
+ className,
20
+ ...props
21
+ }: React.HTMLAttributes<HTMLDivElement>) {
22
+ return (
23
+ <div
24
+ data-slot="card-header"
25
+ className={cn("flex flex-col space-y-1.5 p-6", className)}
26
+ {...props}
27
+ />
28
+ );
29
+ }
30
+
31
+ function CardTitle({
32
+ className,
33
+ ...props
34
+ }: React.HTMLAttributes<HTMLDivElement>) {
35
+ return (
36
+ <div
37
+ data-slot="card-title"
38
+ className={cn("font-semibold leading-none tracking-tight", className)}
39
+ {...props}
40
+ />
41
+ );
42
+ }
43
+
44
+ function CardDescription({
45
+ className,
46
+ ...props
47
+ }: React.HTMLAttributes<HTMLDivElement>) {
48
+ return (
49
+ <div
50
+ data-slot="card-description"
51
+ className={cn("text-sm text-muted-foreground", className)}
52
+ {...props}
53
+ />
54
+ );
55
+ }
56
+
57
+ function CardContent({
58
+ className,
59
+ ...props
60
+ }: React.HTMLAttributes<HTMLDivElement>) {
61
+ return (
62
+ <div
63
+ data-slot="card-content"
64
+ className={cn("p-6 pt-0", className)}
65
+ {...props}
66
+ />
67
+ );
68
+ }
69
+
70
+ function CardFooter({
71
+ className,
72
+ ...props
73
+ }: React.HTMLAttributes<HTMLDivElement>) {
74
+ return (
75
+ <div
76
+ data-slot="card-footer"
77
+ className={cn("flex items-center p-6 pt-0", className)}
78
+ {...props}
79
+ />
80
+ );
81
+ }
82
+
83
+ export {
84
+ Card,
85
+ CardHeader,
86
+ CardFooter,
87
+ CardTitle,
88
+ CardDescription,
89
+ CardContent,
90
+ };
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
5
+ import { X } from "lucide-react";
6
+
7
+ import { cn } from "@/lib/utils";
8
+
9
+ const Dialog = DialogPrimitive.Root;
10
+ const DialogTrigger = DialogPrimitive.Trigger;
11
+ const DialogPortal = DialogPrimitive.Portal;
12
+ const DialogClose = DialogPrimitive.Close;
13
+
14
+ const DialogOverlay = React.forwardRef<
15
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
16
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
17
+ >(({ className, ...props }, ref) => (
18
+ <DialogPrimitive.Overlay
19
+ ref={ref}
20
+ className={cn(
21
+ "fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
22
+ className,
23
+ )}
24
+ {...props}
25
+ />
26
+ ));
27
+ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
28
+
29
+ const DialogContent = React.forwardRef<
30
+ React.ElementRef<typeof DialogPrimitive.Content>,
31
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
32
+ >(({ className, children, ...props }, ref) => (
33
+ <DialogPortal>
34
+ <DialogOverlay />
35
+ <DialogPrimitive.Content
36
+ ref={ref}
37
+ className={cn(
38
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border bg-background p-6 shadow-lg duration-200",
39
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
40
+ className,
41
+ )}
42
+ {...props}
43
+ >
44
+ {children}
45
+ <DialogPrimitive.Close className="absolute right-3 top-3 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
46
+ <X className="size-4" />
47
+ <span className="sr-only">Close</span>
48
+ </DialogPrimitive.Close>
49
+ </DialogPrimitive.Content>
50
+ </DialogPortal>
51
+ ));
52
+ DialogContent.displayName = DialogPrimitive.Content.displayName;
53
+
54
+ function DialogHeader({
55
+ className,
56
+ ...props
57
+ }: React.HTMLAttributes<HTMLDivElement>) {
58
+ return (
59
+ <div className={cn("flex flex-col gap-1.5 text-left", className)} {...props} />
60
+ );
61
+ }
62
+
63
+ function DialogFooter({
64
+ className,
65
+ ...props
66
+ }: React.HTMLAttributes<HTMLDivElement>) {
67
+ return (
68
+ <div
69
+ className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
70
+ {...props}
71
+ />
72
+ );
73
+ }
74
+
75
+ const DialogTitle = React.forwardRef<
76
+ React.ElementRef<typeof DialogPrimitive.Title>,
77
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
78
+ >(({ className, ...props }, ref) => (
79
+ <DialogPrimitive.Title
80
+ ref={ref}
81
+ className={cn("text-base font-semibold leading-none tracking-tight", className)}
82
+ {...props}
83
+ />
84
+ ));
85
+ DialogTitle.displayName = DialogPrimitive.Title.displayName;
86
+
87
+ const DialogDescription = React.forwardRef<
88
+ React.ElementRef<typeof DialogPrimitive.Description>,
89
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
90
+ >(({ className, ...props }, ref) => (
91
+ <DialogPrimitive.Description
92
+ ref={ref}
93
+ className={cn("text-sm text-muted-foreground", className)}
94
+ {...props}
95
+ />
96
+ ));
97
+ DialogDescription.displayName = DialogPrimitive.Description.displayName;
98
+
99
+ export {
100
+ Dialog,
101
+ DialogClose,
102
+ DialogContent,
103
+ DialogDescription,
104
+ DialogFooter,
105
+ DialogHeader,
106
+ DialogOverlay,
107
+ DialogPortal,
108
+ DialogTitle,
109
+ DialogTrigger,
110
+ };
@@ -0,0 +1,30 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ function Input({
6
+ className,
7
+ type,
8
+ ...props
9
+ }: React.InputHTMLAttributes<HTMLInputElement>) {
10
+ return (
11
+ <input
12
+ type={type}
13
+ data-slot="input"
14
+ className={cn(
15
+ "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors",
16
+ "placeholder:text-muted-foreground",
17
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
18
+ "disabled:cursor-not-allowed disabled:opacity-50",
19
+ // Date/time inputs render a native picker indicator that ignores the
20
+ // text color; nudge it so it stays visible in dark mode.
21
+ "[&::-webkit-calendar-picker-indicator]:opacity-60 dark:[&::-webkit-calendar-picker-indicator]:invert",
22
+ "file:border-0 file:bg-transparent file:text-sm file:font-medium",
23
+ className,
24
+ )}
25
+ {...props}
26
+ />
27
+ );
28
+ }
29
+
30
+ export { Input };
@@ -0,0 +1,21 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ function Label({
6
+ className,
7
+ ...props
8
+ }: React.LabelHTMLAttributes<HTMLLabelElement>) {
9
+ return (
10
+ <label
11
+ data-slot="label"
12
+ className={cn(
13
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
14
+ className,
15
+ )}
16
+ {...props}
17
+ />
18
+ );
19
+ }
20
+
21
+ export { Label };
@@ -0,0 +1,39 @@
1
+ import * as React from "react";
2
+ import { ChevronDown } from "lucide-react";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ /**
7
+ * A styled native `<select>`.
8
+ *
9
+ * shadcn's Select is a Radix listbox; this deliberately isn't. A native select
10
+ * needs no extra dependency, is keyboard- and screen-reader-correct for free,
11
+ * and opens as the platform picker on mobile — which is what you want for the
12
+ * plain "pick one value" case that scaffolded forms are full of. Reach for
13
+ * `npx shadcn add select` when you need custom option rendering or search.
14
+ */
15
+ function Select({
16
+ className,
17
+ children,
18
+ ...props
19
+ }: React.SelectHTMLAttributes<HTMLSelectElement>) {
20
+ return (
21
+ <div className="relative">
22
+ <select
23
+ data-slot="select"
24
+ className={cn(
25
+ "flex h-9 w-full appearance-none rounded-md border border-input bg-transparent py-1 pl-3 pr-8 text-sm shadow-sm transition-colors",
26
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
27
+ "disabled:cursor-not-allowed disabled:opacity-50",
28
+ className,
29
+ )}
30
+ {...props}
31
+ >
32
+ {children}
33
+ </select>
34
+ <ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 size-4 -translate-y-1/2 opacity-50" />
35
+ </div>
36
+ );
37
+ }
38
+
39
+ export { Select };