create-crm-starter 0.1.0

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 (261) hide show
  1. package/README.md +53 -0
  2. package/dist/index.js +2341 -0
  3. package/package.json +51 -0
  4. package/template/base/_dot_gitignore +11 -0
  5. package/template/base/eslint.config.mjs +7 -0
  6. package/template/base/next.config.ts +17 -0
  7. package/template/base/package.json +35 -0
  8. package/template/base/postcss.config.mjs +7 -0
  9. package/template/base/scripts/setup.mjs +146 -0
  10. package/template/base/src/app/globals.css +68 -0
  11. package/template/base/src/app/layout.tsx +19 -0
  12. package/template/base/src/app/page.tsx +37 -0
  13. package/template/base/src/components/ui/badge.tsx +29 -0
  14. package/template/base/src/components/ui/button.tsx +49 -0
  15. package/template/base/src/components/ui/card.tsx +54 -0
  16. package/template/base/src/components/ui/input.tsx +19 -0
  17. package/template/base/src/components/ui/label.tsx +19 -0
  18. package/template/base/src/components/ui/separator.tsx +25 -0
  19. package/template/base/src/components/ui/skeleton.tsx +7 -0
  20. package/template/base/src/lib/utils.ts +17 -0
  21. package/template/base/tsconfig.json +21 -0
  22. package/template/extras/auth-better-auth/src/app/(auth)/layout.tsx +7 -0
  23. package/template/extras/auth-better-auth/src/app/(auth)/sign-in/page.tsx +65 -0
  24. package/template/extras/auth-better-auth/src/app/(auth)/sign-up/page.tsx +70 -0
  25. package/template/extras/auth-better-auth/src/app/(dashboard)/admin/page.tsx +68 -0
  26. package/template/extras/auth-better-auth/src/app/(dashboard)/dashboard/page.tsx +42 -0
  27. package/template/extras/auth-better-auth/src/app/(dashboard)/layout.tsx +41 -0
  28. package/template/extras/auth-better-auth/src/app/api/auth/[...all]/route.ts +4 -0
  29. package/template/extras/auth-better-auth/src/components/sign-out-button.tsx +19 -0
  30. package/template/extras/auth-better-auth/src/lib/auth-client.ts +7 -0
  31. package/template/extras/auth-better-auth/src/lib/auth-server.ts +51 -0
  32. package/template/extras/auth-better-auth/src/lib/auth.ts +76 -0
  33. package/template/extras/auth-better-auth/src/middleware.ts +25 -0
  34. package/template/extras/auth-clerk/src/app/(auth)/layout.tsx +7 -0
  35. package/template/extras/auth-clerk/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +5 -0
  36. package/template/extras/auth-clerk/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx +5 -0
  37. package/template/extras/auth-clerk/src/app/(dashboard)/admin/page.tsx +37 -0
  38. package/template/extras/auth-clerk/src/app/(dashboard)/dashboard/page.tsx +42 -0
  39. package/template/extras/auth-clerk/src/app/(dashboard)/layout.tsx +57 -0
  40. package/template/extras/auth-clerk/src/lib/auth.ts +55 -0
  41. package/template/extras/auth-clerk/src/middleware.ts +17 -0
  42. package/template/extras/calendar-dispatch/_shared/src/app/(dashboard)/calendar/page.tsx +39 -0
  43. package/template/extras/calendar-dispatch/drizzle/src/app/(dashboard)/calendar/page.tsx +21 -0
  44. package/template/extras/calendar-dispatch/drizzle/src/components/calendar/calendar-board.tsx +195 -0
  45. package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/actions.ts +35 -0
  46. package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/data.ts +74 -0
  47. package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/[id]/page.tsx +48 -0
  48. package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/new/page.tsx +15 -0
  49. package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/page.tsx +83 -0
  50. package/template/extras/checklists/_shared/src/components/jobs/job-checklists-section.tsx +18 -0
  51. package/template/extras/checklists/_shared/src/lib/checklists/data.ts +17 -0
  52. package/template/extras/checklists/_shared/src/lib/checklists/sample-data.ts +56 -0
  53. package/template/extras/checklists/_shared/src/lib/checklists/types.ts +47 -0
  54. package/template/extras/checklists/drizzle/src/app/(dashboard)/checklists/new/page.tsx +10 -0
  55. package/template/extras/checklists/drizzle/src/components/checklists/new-template-form.tsx +158 -0
  56. package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-client.tsx +202 -0
  57. package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-section.tsx +24 -0
  58. package/template/extras/checklists/drizzle/src/db/schema/checklists.ts +52 -0
  59. package/template/extras/checklists/drizzle/src/lib/checklists/actions.ts +112 -0
  60. package/template/extras/checklists/drizzle/src/lib/checklists/data.ts +80 -0
  61. package/template/extras/comms-email/src/app/(dashboard)/email/page.tsx +32 -0
  62. package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/new/page.tsx +35 -0
  63. package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/page.tsx +55 -0
  64. package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/[customerId]/page.tsx +102 -0
  65. package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/new/page.tsx +120 -0
  66. package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/page.tsx +70 -0
  67. package/template/extras/comms-sms/drizzle/src/app/api/twilio/sms/route.ts +69 -0
  68. package/template/extras/comms-sms/drizzle/src/db/schema/sms-messages.ts +27 -0
  69. package/template/extras/comms-sms/drizzle/src/lib/sms/actions.ts +107 -0
  70. package/template/extras/comms-sms/drizzle/src/lib/sms/data.ts +111 -0
  71. package/template/extras/customer-portal/_shared/src/app/portal/[token]/layout.tsx +51 -0
  72. package/template/extras/customer-portal/drizzle/src/app/portal/[token]/appointments/page.tsx +78 -0
  73. package/template/extras/customer-portal/drizzle/src/app/portal/[token]/estimates/page.tsx +94 -0
  74. package/template/extras/customer-portal/drizzle/src/app/portal/[token]/invoices/page.tsx +71 -0
  75. package/template/extras/customer-portal/drizzle/src/app/portal/[token]/page.tsx +118 -0
  76. package/template/extras/customer-portal/drizzle/src/components/portal/estimate-approval.tsx +141 -0
  77. package/template/extras/customer-portal/drizzle/src/components/portal/signature-pad.tsx +126 -0
  78. package/template/extras/customer-portal/drizzle/src/lib/portal/actions.ts +107 -0
  79. package/template/extras/customer-portal/drizzle/src/lib/portal/data.ts +158 -0
  80. package/template/extras/customers/_fragments/convex.txt +28 -0
  81. package/template/extras/customers/_shared/src/app/(dashboard)/customers/[id]/page.tsx +81 -0
  82. package/template/extras/customers/_shared/src/app/(dashboard)/customers/new/page.tsx +16 -0
  83. package/template/extras/customers/_shared/src/app/(dashboard)/customers/page.tsx +73 -0
  84. package/template/extras/customers/_shared/src/lib/customers/data.ts +15 -0
  85. package/template/extras/customers/_shared/src/lib/customers/sample-data.ts +67 -0
  86. package/template/extras/customers/_shared/src/lib/customers/types.ts +31 -0
  87. package/template/extras/customers/convex/convex/customers.ts +52 -0
  88. package/template/extras/customers/convex/src/lib/customers/data.ts +64 -0
  89. package/template/extras/customers/drizzle/src/app/(dashboard)/customers/new/page.tsx +82 -0
  90. package/template/extras/customers/drizzle/src/db/schema/customers.ts +34 -0
  91. package/template/extras/customers/drizzle/src/lib/customers/actions.ts +67 -0
  92. package/template/extras/customers/drizzle/src/lib/customers/data.ts +34 -0
  93. package/template/extras/db-convex/convex/_generated/README.md +13 -0
  94. package/template/extras/db-convex/convex/_generated/api.d.ts +8 -0
  95. package/template/extras/db-convex/convex/_generated/api.js +12 -0
  96. package/template/extras/db-convex/convex/_generated/dataModel.d.ts +9 -0
  97. package/template/extras/db-convex/convex/_generated/server.d.ts +18 -0
  98. package/template/extras/db-convex/convex/_generated/server.js +12 -0
  99. package/template/extras/db-convex/convex/auth.config.ts +17 -0
  100. package/template/extras/db-convex/convex/schema.ts +28 -0
  101. package/template/extras/db-convex/src/app/layout.tsx +20 -0
  102. package/template/extras/db-convex/src/components/providers.tsx +28 -0
  103. package/template/extras/db-convex/src/lib/convex.ts +6 -0
  104. package/template/extras/db-drizzle-pg/docker-compose.yml +21 -0
  105. package/template/extras/db-drizzle-pg/drizzle.config.ts +24 -0
  106. package/template/extras/db-drizzle-pg/src/app/layout.tsx +20 -0
  107. package/template/extras/db-drizzle-pg/src/components/providers.tsx +16 -0
  108. package/template/extras/db-drizzle-pg/src/db/client.ts +14 -0
  109. package/template/extras/db-drizzle-pg/src/db/schema/auth.ts +62 -0
  110. package/template/extras/db-drizzle-pg/src/db/schema/index.ts +3 -0
  111. package/template/extras/emergency-dispatch/src/app/(dashboard)/emergency/page.tsx +43 -0
  112. package/template/extras/equipment-tracking/src/app/(dashboard)/equipment/page.tsx +61 -0
  113. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/[id]/page.tsx +123 -0
  114. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/new/page.tsx +22 -0
  115. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/page.tsx +102 -0
  116. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/[id]/page.tsx +168 -0
  117. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/page.tsx +100 -0
  118. package/template/extras/estimates-invoices/_shared/src/components/estimates/convert-to-job-button.tsx +14 -0
  119. package/template/extras/estimates-invoices/_shared/src/components/invoices/pay-invoice-button.tsx +15 -0
  120. package/template/extras/estimates-invoices/_shared/src/components/invoices/send-invoice-email-button.tsx +14 -0
  121. package/template/extras/estimates-invoices/_shared/src/lib/estimates/data.ts +14 -0
  122. package/template/extras/estimates-invoices/_shared/src/lib/estimates/sample-data.ts +74 -0
  123. package/template/extras/estimates-invoices/_shared/src/lib/estimates/types.ts +60 -0
  124. package/template/extras/estimates-invoices/_shared/src/lib/invoices/data.ts +14 -0
  125. package/template/extras/estimates-invoices/_shared/src/lib/invoices/sample-data.ts +83 -0
  126. package/template/extras/estimates-invoices/_shared/src/lib/invoices/types.ts +78 -0
  127. package/template/extras/estimates-invoices/drizzle/src/app/(dashboard)/estimates/new/page.tsx +18 -0
  128. package/template/extras/estimates-invoices/drizzle/src/app/api/stripe/webhook/route.ts +87 -0
  129. package/template/extras/estimates-invoices/drizzle/src/app/i/[token]/page.tsx +148 -0
  130. package/template/extras/estimates-invoices/drizzle/src/components/estimates/convert-to-job-button.tsx +18 -0
  131. package/template/extras/estimates-invoices/drizzle/src/components/estimates/new-estimate-form.tsx +261 -0
  132. package/template/extras/estimates-invoices/drizzle/src/components/invoices/pay-invoice-button.tsx +19 -0
  133. package/template/extras/estimates-invoices/drizzle/src/components/invoices/public-pay-button.tsx +20 -0
  134. package/template/extras/estimates-invoices/drizzle/src/components/invoices/send-invoice-email-button.tsx +37 -0
  135. package/template/extras/estimates-invoices/drizzle/src/components/jobs/generate-invoice-button.tsx +23 -0
  136. package/template/extras/estimates-invoices/drizzle/src/db/schema/estimates.ts +41 -0
  137. package/template/extras/estimates-invoices/drizzle/src/db/schema/invoices.ts +59 -0
  138. package/template/extras/estimates-invoices/drizzle/src/lib/estimates/actions.ts +110 -0
  139. package/template/extras/estimates-invoices/drizzle/src/lib/estimates/data.ts +57 -0
  140. package/template/extras/estimates-invoices/drizzle/src/lib/invoices/actions.ts +199 -0
  141. package/template/extras/estimates-invoices/drizzle/src/lib/invoices/data.ts +99 -0
  142. package/template/extras/estimates-invoices/drizzle/src/lib/invoices/email-actions.ts +102 -0
  143. package/template/extras/inspection-checklists/src/app/(dashboard)/inspections/page.tsx +60 -0
  144. package/template/extras/jobs/_fragments/convex.txt +21 -0
  145. package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/[id]/page.tsx +102 -0
  146. package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/page.tsx +72 -0
  147. package/template/extras/jobs/_shared/src/components/jobs/advance-status-button.tsx +21 -0
  148. package/template/extras/jobs/_shared/src/components/jobs/generate-invoice-button.tsx +15 -0
  149. package/template/extras/jobs/_shared/src/components/jobs/photo-gallery.tsx +17 -0
  150. package/template/extras/jobs/_shared/src/components/jobs/photos-section.tsx +18 -0
  151. package/template/extras/jobs/_shared/src/lib/jobs/data.ts +14 -0
  152. package/template/extras/jobs/_shared/src/lib/jobs/sample-data.ts +50 -0
  153. package/template/extras/jobs/_shared/src/lib/jobs/types.ts +62 -0
  154. package/template/extras/jobs/convex/convex/jobs.ts +46 -0
  155. package/template/extras/jobs/convex/src/lib/jobs/data.ts +65 -0
  156. package/template/extras/jobs/drizzle/src/app/(dashboard)/jobs/new/page.tsx +18 -0
  157. package/template/extras/jobs/drizzle/src/components/jobs/advance-status-button.tsx +34 -0
  158. package/template/extras/jobs/drizzle/src/components/jobs/new-job-form.tsx +275 -0
  159. package/template/extras/jobs/drizzle/src/components/jobs/photo-gallery.tsx +130 -0
  160. package/template/extras/jobs/drizzle/src/components/jobs/photos-section.tsx +7 -0
  161. package/template/extras/jobs/drizzle/src/db/schema/job-attachments.ts +26 -0
  162. package/template/extras/jobs/drizzle/src/db/schema/jobs.ts +29 -0
  163. package/template/extras/jobs/drizzle/src/lib/jobs/actions.ts +71 -0
  164. package/template/extras/jobs/drizzle/src/lib/jobs/data.ts +48 -0
  165. package/template/extras/jobs/drizzle/src/lib/jobs/photos-actions.ts +121 -0
  166. package/template/extras/jobs/drizzle/src/lib/r2.ts +45 -0
  167. package/template/extras/landing-page/_shared/src/app/book/page.tsx +43 -0
  168. package/template/extras/landing-page/_shared/src/app/book/thanks/page.tsx +31 -0
  169. package/template/extras/landing-page/_shared/src/app/page.tsx +81 -0
  170. package/template/extras/landing-page/drizzle/src/app/book/page.tsx +105 -0
  171. package/template/extras/landing-page/drizzle/src/lib/booking/actions.ts +97 -0
  172. package/template/extras/maintenance-plans/src/app/(dashboard)/maintenance-plans/page.tsx +72 -0
  173. package/template/extras/mobile/mobile/README.md +67 -0
  174. package/template/extras/mobile/mobile/_dot_env.example +5 -0
  175. package/template/extras/mobile/mobile/_dot_gitignore +26 -0
  176. package/template/extras/mobile/mobile/app/(app)/_layout.tsx +37 -0
  177. package/template/extras/mobile/mobile/app/(app)/estimate.tsx +135 -0
  178. package/template/extras/mobile/mobile/app/(app)/inbox/[customerId].tsx +103 -0
  179. package/template/extras/mobile/mobile/app/(app)/inbox/index.tsx +70 -0
  180. package/template/extras/mobile/mobile/app/(app)/index.tsx +111 -0
  181. package/template/extras/mobile/mobile/app/(app)/job/[id]/checklist.tsx +99 -0
  182. package/template/extras/mobile/mobile/app/(app)/job/[id]/invoice.tsx +143 -0
  183. package/template/extras/mobile/mobile/app/(app)/job/[id].tsx +259 -0
  184. package/template/extras/mobile/mobile/app/_layout.tsx +14 -0
  185. package/template/extras/mobile/mobile/app/index.tsx +23 -0
  186. package/template/extras/mobile/mobile/app/sign-in.tsx +101 -0
  187. package/template/extras/mobile/mobile/app.brand.ts +14 -0
  188. package/template/extras/mobile/mobile/app.config.ts +40 -0
  189. package/template/extras/mobile/mobile/app.features.ts +11 -0
  190. package/template/extras/mobile/mobile/components/SignaturePad.tsx +60 -0
  191. package/template/extras/mobile/mobile/eas.json +22 -0
  192. package/template/extras/mobile/mobile/lib/api.ts +253 -0
  193. package/template/extras/mobile/mobile/lib/auth.ts +51 -0
  194. package/template/extras/mobile/mobile/lib/format.ts +23 -0
  195. package/template/extras/mobile/mobile/lib/push.ts +24 -0
  196. package/template/extras/mobile/mobile/lib/theme.ts +16 -0
  197. package/template/extras/mobile/mobile/package.json +34 -0
  198. package/template/extras/mobile/mobile/tsconfig.json +11 -0
  199. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/customers/[id]/route.ts +18 -0
  200. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/devices/route.ts +40 -0
  201. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/attachments/route.ts +59 -0
  202. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/item/route.ts +34 -0
  203. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/route.ts +15 -0
  204. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/route.ts +35 -0
  205. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/status/route.ts +28 -0
  206. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-in/route.ts +35 -0
  207. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-out/route.ts +27 -0
  208. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/route.ts +36 -0
  209. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/route.ts +30 -0
  210. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/me/route.ts +26 -0
  211. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/uploads/sign/route.ts +46 -0
  212. package/template/extras/mobile-api/drizzle/src/db/schema/push-tokens.ts +21 -0
  213. package/template/extras/mobile-api/drizzle/src/db/schema/time-entries.ts +23 -0
  214. package/template/extras/mobile-api/drizzle/src/lib/mobile/cors.ts +30 -0
  215. package/template/extras/mobile-api/drizzle/src/lib/mobile/guard.ts +49 -0
  216. package/template/extras/mobile-api/drizzle/src/lib/push/send.ts +56 -0
  217. package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/estimates/route.ts +52 -0
  218. package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/price-book/route.ts +14 -0
  219. package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/payments/route.ts +32 -0
  220. package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/route.ts +28 -0
  221. package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/jobs/[id]/invoice/route.ts +82 -0
  222. package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/send/route.ts +32 -0
  223. package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/[customerId]/route.ts +15 -0
  224. package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/route.ts +14 -0
  225. package/template/extras/payments-stripe/src/app/(dashboard)/payments/page.tsx +56 -0
  226. package/template/extras/payments-stripe/src/app/api/stripe/webhook/route.ts +56 -0
  227. package/template/extras/payments-stripe/src/components/payments/demo-checkout-button.tsx +18 -0
  228. package/template/extras/payments-stripe/src/lib/payments/actions.ts +63 -0
  229. package/template/extras/payments-stripe/src/lib/stripe.ts +25 -0
  230. package/template/extras/permit-tracking/src/app/(dashboard)/permits/page.tsx +56 -0
  231. package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/new/page.tsx +20 -0
  232. package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/page.tsx +79 -0
  233. package/template/extras/price-book/_shared/src/components/price-book/item-picker.tsx +145 -0
  234. package/template/extras/price-book/_shared/src/lib/price-book/actions.ts +14 -0
  235. package/template/extras/price-book/_shared/src/lib/price-book/data.ts +28 -0
  236. package/template/extras/price-book/_shared/src/lib/price-book/sample-data.ts +62 -0
  237. package/template/extras/price-book/_shared/src/lib/price-book/types.ts +35 -0
  238. package/template/extras/price-book/drizzle/src/app/(dashboard)/price-book/new/page.tsx +18 -0
  239. package/template/extras/price-book/drizzle/src/components/price-book/new-item-form.tsx +254 -0
  240. package/template/extras/price-book/drizzle/src/db/schema/price-book.ts +33 -0
  241. package/template/extras/price-book/drizzle/src/lib/price-book/actions.ts +72 -0
  242. package/template/extras/price-book/drizzle/src/lib/price-book/data.ts +81 -0
  243. package/template/extras/reporting/src/app/(dashboard)/reports/page.tsx +66 -0
  244. package/template/extras/reviews/src/app/(dashboard)/reviews/page.tsx +58 -0
  245. package/template/extras/seed/_shared/scripts/seed.ts +35 -0
  246. package/template/extras/seed/drizzle/scripts/seed.ts +314 -0
  247. package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/[id]/page.tsx +114 -0
  248. package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/new/page.tsx +18 -0
  249. package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/page.tsx +92 -0
  250. package/template/extras/service-plans/_shared/src/components/service-plans/subscribe-customer-section.tsx +17 -0
  251. package/template/extras/service-plans/_shared/src/lib/service-plans/data.ts +14 -0
  252. package/template/extras/service-plans/_shared/src/lib/service-plans/sample-data.ts +83 -0
  253. package/template/extras/service-plans/_shared/src/lib/service-plans/types.ts +57 -0
  254. package/template/extras/service-plans/drizzle/src/app/(dashboard)/service-plans/new/page.tsx +10 -0
  255. package/template/extras/service-plans/drizzle/src/app/api/stripe/webhook/route.ts +143 -0
  256. package/template/extras/service-plans/drizzle/src/components/service-plans/new-plan-form.tsx +126 -0
  257. package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-form.tsx +88 -0
  258. package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-section.tsx +12 -0
  259. package/template/extras/service-plans/drizzle/src/db/schema/service-plans.ts +46 -0
  260. package/template/extras/service-plans/drizzle/src/lib/service-plans/actions.ts +196 -0
  261. package/template/extras/service-plans/drizzle/src/lib/service-plans/data.ts +124 -0
@@ -0,0 +1,56 @@
1
+ import type { ChecklistTemplate } from './types';
2
+
3
+ export const sampleTemplates: ChecklistTemplate[] = [
4
+ {
5
+ id: 'tpl_hvac_tune_up',
6
+ name: 'HVAC tune-up',
7
+ description: 'Standard maintenance checklist for annual tune-ups',
8
+ items: [
9
+ { id: 'i1', label: 'Replace air filter', kind: 'checkbox', required: true, sortOrder: 0 },
10
+ { id: 'i2', label: 'Clean condenser coil', kind: 'checkbox', required: true, sortOrder: 1 },
11
+ { id: 'i3', label: 'Check refrigerant pressure', kind: 'text', required: true, sortOrder: 2, helperText: 'PSI reading' },
12
+ { id: 'i4', label: 'Inspect electrical connections', kind: 'checkbox', required: true, sortOrder: 3 },
13
+ { id: 'i5', label: 'Photo of completed work', kind: 'photo', required: true, sortOrder: 4 },
14
+ { id: 'i6', label: 'Customer walkthrough completed', kind: 'checkbox', required: false, sortOrder: 5 },
15
+ ],
16
+ itemCount: 6,
17
+ requiredCount: 5,
18
+ attachedJobCount: 7,
19
+ createdAt: '2025-08-15T10:00:00Z',
20
+ },
21
+ {
22
+ id: 'tpl_panel_upgrade',
23
+ name: 'Electrical panel upgrade',
24
+ description: 'Code-required inspection items for panel replacement',
25
+ items: [
26
+ { id: 'p1', label: 'Main breaker rating verified', kind: 'checkbox', required: true, sortOrder: 0 },
27
+ { id: 'p2', label: 'Grounding electrode bonded', kind: 'checkbox', required: true, sortOrder: 1 },
28
+ { id: 'p3', label: 'Service entrance conductors sized correctly', kind: 'text', required: true, sortOrder: 2, helperText: 'AWG + ampacity' },
29
+ { id: 'p4', label: 'Working clearance meets code', kind: 'checkbox', required: true, sortOrder: 3 },
30
+ { id: 'p5', label: 'GFCI/AFCI protection installed', kind: 'checkbox', required: true, sortOrder: 4 },
31
+ { id: 'p6', label: 'Panel directory labeled', kind: 'checkbox', required: true, sortOrder: 5 },
32
+ { id: 'p7', label: 'Permit number posted', kind: 'text', required: true, sortOrder: 6 },
33
+ { id: 'p8', label: 'Final inspection photo', kind: 'photo', required: true, sortOrder: 7 },
34
+ ],
35
+ itemCount: 8,
36
+ requiredCount: 8,
37
+ attachedJobCount: 3,
38
+ createdAt: '2025-09-01T14:00:00Z',
39
+ },
40
+ {
41
+ id: 'tpl_plumb_drain',
42
+ name: 'Drain cleaning',
43
+ description: 'Pre/post checks for drain service calls',
44
+ items: [
45
+ { id: 'd1', label: 'Pre-service photo', kind: 'photo', required: true, sortOrder: 0 },
46
+ { id: 'd2', label: 'Cleaning method used', kind: 'text', required: true, sortOrder: 1, helperText: 'Auger / hydro-jet / enzyme' },
47
+ { id: 'd3', label: 'Post-service photo', kind: 'photo', required: true, sortOrder: 2 },
48
+ { id: 'd4', label: 'Water test passed', kind: 'checkbox', required: true, sortOrder: 3 },
49
+ { id: 'd5', label: 'Customer notified of any concerns', kind: 'checkbox', required: false, sortOrder: 4 },
50
+ ],
51
+ itemCount: 5,
52
+ requiredCount: 4,
53
+ attachedJobCount: 12,
54
+ createdAt: '2025-10-20T09:00:00Z',
55
+ },
56
+ ];
@@ -0,0 +1,47 @@
1
+ export const CHECKLIST_ITEM_KINDS = ['checkbox', 'text', 'photo'] as const;
2
+ export type ChecklistItemKind = (typeof CHECKLIST_ITEM_KINDS)[number];
3
+
4
+ export const CHECKLIST_ITEM_KIND_LABEL: Record<ChecklistItemKind, string> = {
5
+ checkbox: 'Checkbox',
6
+ text: 'Text answer',
7
+ photo: 'Photo upload',
8
+ };
9
+
10
+ export interface ChecklistTemplateItem {
11
+ id: string;
12
+ label: string;
13
+ kind: ChecklistItemKind;
14
+ required: boolean;
15
+ sortOrder: number;
16
+ helperText?: string;
17
+ }
18
+
19
+ export interface ChecklistTemplate {
20
+ id: string;
21
+ name: string;
22
+ description?: string;
23
+ items: ChecklistTemplateItem[];
24
+ itemCount: number; // = items.length, denormalized for list view
25
+ requiredCount: number; // count of items where required=true
26
+ attachedJobCount: number; // how many jobs use this template currently
27
+ createdAt: string;
28
+ }
29
+
30
+ export interface JobChecklistItem extends ChecklistTemplateItem {
31
+ completed: boolean;
32
+ completedAt?: string;
33
+ /** For kind='text' answers. */
34
+ value?: string;
35
+ }
36
+
37
+ export interface JobChecklist {
38
+ id: string;
39
+ jobId: string;
40
+ templateId?: string;
41
+ templateName: string;
42
+ items: JobChecklistItem[];
43
+ /** Computed: all required items completed. Drives the "blocks job finish" UI. */
44
+ allRequiredComplete: boolean;
45
+ /** Computed: total completion percent (0-100). */
46
+ progressPct: number;
47
+ }
@@ -0,0 +1,10 @@
1
+ import { NewTemplateForm } from '@/components/checklists/new-template-form';
2
+
3
+ export default function NewChecklistTemplatePage() {
4
+ return (
5
+ <div className="mx-auto max-w-3xl space-y-6">
6
+ <h1 className="text-3xl font-bold tracking-tight">New checklist template</h1>
7
+ <NewTemplateForm />
8
+ </div>
9
+ );
10
+ }
@@ -0,0 +1,158 @@
1
+ 'use client';
2
+
3
+ import { useState, useTransition } from 'react';
4
+ import Link from 'next/link';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
7
+ import { Input } from '@/components/ui/input';
8
+ import { Label } from '@/components/ui/label';
9
+ import { createChecklistTemplate } from '@/lib/checklists/actions';
10
+ import { CHECKLIST_ITEM_KINDS, CHECKLIST_ITEM_KIND_LABEL, type ChecklistItemKind } from '@/lib/checklists/types';
11
+
12
+ interface DraftItem {
13
+ key: string;
14
+ label: string;
15
+ kind: ChecklistItemKind;
16
+ required: boolean;
17
+ helperText: string;
18
+ }
19
+
20
+ const NEW_ITEM = (): DraftItem => ({
21
+ key: crypto.randomUUID(),
22
+ label: '',
23
+ kind: 'checkbox',
24
+ required: true,
25
+ helperText: '',
26
+ });
27
+
28
+ export function NewTemplateForm() {
29
+ const [pending, start] = useTransition();
30
+ const [error, setError] = useState<string | null>(null);
31
+ const [name, setName] = useState('');
32
+ const [description, setDescription] = useState('');
33
+ const [items, setItems] = useState<DraftItem[]>([NEW_ITEM()]);
34
+
35
+ function updateItem(key: string, patch: Partial<DraftItem>) {
36
+ setItems((prev) => prev.map((it) => (it.key === key ? { ...it, ...patch } : it)));
37
+ }
38
+ function removeItem(key: string) {
39
+ setItems((prev) => (prev.length === 1 ? prev : prev.filter((it) => it.key !== key)));
40
+ }
41
+ function addItem() {
42
+ setItems((prev) => [...prev, NEW_ITEM()]);
43
+ }
44
+
45
+ function handleSubmit(e: React.FormEvent) {
46
+ e.preventDefault();
47
+ setError(null);
48
+ if (!name.trim()) { setError('Name is required.'); return; }
49
+ const filled = items.filter((it) => it.label.trim());
50
+ if (filled.length === 0) { setError('Add at least one item with a label.'); return; }
51
+ start(async () => {
52
+ try {
53
+ await createChecklistTemplate({
54
+ name,
55
+ description: description || undefined,
56
+ items: filled.map(({ key: _key, ...rest }) => ({
57
+ label: rest.label,
58
+ kind: rest.kind,
59
+ required: rest.required,
60
+ helperText: rest.helperText || undefined,
61
+ })),
62
+ });
63
+ } catch (err) {
64
+ setError((err as Error).message);
65
+ }
66
+ });
67
+ }
68
+
69
+ return (
70
+ <form onSubmit={handleSubmit} className="space-y-6">
71
+ <Card>
72
+ <CardHeader>
73
+ <CardTitle>Template basics</CardTitle>
74
+ <CardDescription>Name and an optional description.</CardDescription>
75
+ </CardHeader>
76
+ <CardContent className="space-y-4">
77
+ <div className="space-y-2">
78
+ <Label htmlFor="name">Name *</Label>
79
+ <Input id="name" required value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. HVAC tune-up" />
80
+ </div>
81
+ <div className="space-y-2">
82
+ <Label htmlFor="description">Description</Label>
83
+ <Input id="description" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="What jobs is this for?" />
84
+ </div>
85
+ </CardContent>
86
+ </Card>
87
+
88
+ <Card>
89
+ <CardHeader>
90
+ <CardTitle>Items</CardTitle>
91
+ <CardDescription>Required items block job &quot;Finished&quot; until completed.</CardDescription>
92
+ </CardHeader>
93
+ <CardContent className="space-y-3">
94
+ {items.map((it) => (
95
+ <div key={it.key} className="border-input space-y-2 rounded-md border p-3">
96
+ <div className="grid grid-cols-[1fr_8rem_2.5rem] gap-2">
97
+ <Input
98
+ value={it.label}
99
+ onChange={(e) => updateItem(it.key, { label: e.target.value })}
100
+ placeholder="Item label"
101
+ />
102
+ <select
103
+ value={it.kind}
104
+ onChange={(e) => updateItem(it.key, { kind: e.target.value as ChecklistItemKind })}
105
+ className="border-input bg-background h-10 w-full rounded-md border px-2 text-sm"
106
+ >
107
+ {CHECKLIST_ITEM_KINDS.map((k) => (
108
+ <option key={k} value={k}>{CHECKLIST_ITEM_KIND_LABEL[k]}</option>
109
+ ))}
110
+ </select>
111
+ <Button
112
+ type="button"
113
+ variant="ghost"
114
+ size="icon"
115
+ onClick={() => removeItem(it.key)}
116
+ disabled={items.length === 1}
117
+ aria-label="Remove"
118
+ >
119
+
120
+ </Button>
121
+ </div>
122
+ <div className="flex items-center justify-between gap-2">
123
+ <Input
124
+ value={it.helperText}
125
+ onChange={(e) => updateItem(it.key, { helperText: e.target.value })}
126
+ placeholder="Helper text (optional)"
127
+ className="text-xs"
128
+ />
129
+ <label className="flex cursor-pointer items-center gap-2 text-xs whitespace-nowrap">
130
+ <input
131
+ type="checkbox"
132
+ checked={it.required}
133
+ onChange={(e) => updateItem(it.key, { required: e.target.checked })}
134
+ />
135
+ Required
136
+ </label>
137
+ </div>
138
+ </div>
139
+ ))}
140
+ <Button type="button" variant="outline" size="sm" onClick={addItem}>
141
+ + Add item
142
+ </Button>
143
+ </CardContent>
144
+ </Card>
145
+
146
+ {error && <p className="text-destructive text-sm">{error}</p>}
147
+
148
+ <div className="flex justify-end gap-3">
149
+ <Button type="button" variant="outline" asChild>
150
+ <Link href="/checklists">Cancel</Link>
151
+ </Button>
152
+ <Button type="submit" disabled={pending}>
153
+ {pending ? 'Creating…' : 'Create template'}
154
+ </Button>
155
+ </div>
156
+ </form>
157
+ );
158
+ }
@@ -0,0 +1,202 @@
1
+ 'use client';
2
+
3
+ import { useState, useTransition } from 'react';
4
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
5
+ import { Badge } from '@/components/ui/badge';
6
+ import { Button } from '@/components/ui/button';
7
+ import { Input } from '@/components/ui/input';
8
+ import {
9
+ attachChecklistToJob,
10
+ detachJobChecklist,
11
+ setJobChecklistItem,
12
+ } from '@/lib/checklists/actions';
13
+ import type { JobChecklist, JobChecklistItem } from '@/lib/checklists/types';
14
+
15
+ interface ClientProps {
16
+ jobId: string;
17
+ initialAttached: JobChecklist[];
18
+ templates: { id: string; name: string }[];
19
+ }
20
+
21
+ export function JobChecklistsClient({ jobId, initialAttached, templates }: ClientProps) {
22
+ const [attached, setAttached] = useState(initialAttached);
23
+ const [pending, start] = useTransition();
24
+ const [error, setError] = useState<string | null>(null);
25
+ const [selectedTemplate, setSelectedTemplate] = useState(templates[0]?.id ?? '');
26
+
27
+ function handleAttach() {
28
+ if (!selectedTemplate) return;
29
+ setError(null);
30
+ start(async () => {
31
+ try {
32
+ await attachChecklistToJob(jobId, selectedTemplate);
33
+ // Optimistic-ish reload — the server revalidated the page so a
34
+ // soft navigation would refresh attached[]. We just clear the
35
+ // picker to indicate the action happened.
36
+ location.reload();
37
+ } catch (err) {
38
+ setError((err as Error).message);
39
+ }
40
+ });
41
+ }
42
+
43
+ function handleDetach(checklistId: string) {
44
+ if (!confirm('Detach this checklist? Completion state will be lost.')) return;
45
+ start(async () => {
46
+ try {
47
+ await detachJobChecklist(checklistId);
48
+ setAttached((prev) => prev.filter((c) => c.id !== checklistId));
49
+ } catch (err) {
50
+ setError((err as Error).message);
51
+ }
52
+ });
53
+ }
54
+
55
+ function handleToggle(checklistId: string, item: JobChecklistItem) {
56
+ start(async () => {
57
+ try {
58
+ await setJobChecklistItem(checklistId, item.id, { completed: !item.completed });
59
+ setAttached((prev) =>
60
+ prev.map((c) =>
61
+ c.id !== checklistId
62
+ ? c
63
+ : {
64
+ ...c,
65
+ items: c.items.map((it) =>
66
+ it.id !== item.id ? it : { ...it, completed: !it.completed },
67
+ ),
68
+ },
69
+ ),
70
+ );
71
+ } catch (err) {
72
+ setError((err as Error).message);
73
+ }
74
+ });
75
+ }
76
+
77
+ function handleTextChange(checklistId: string, item: JobChecklistItem, value: string) {
78
+ setAttached((prev) =>
79
+ prev.map((c) =>
80
+ c.id !== checklistId
81
+ ? c
82
+ : { ...c, items: c.items.map((it) => (it.id !== item.id ? it : { ...it, value })) },
83
+ ),
84
+ );
85
+ }
86
+
87
+ function handleTextBlur(checklistId: string, item: JobChecklistItem) {
88
+ start(async () => {
89
+ try {
90
+ await setJobChecklistItem(checklistId, item.id, { value: item.value ?? '' });
91
+ } catch (err) {
92
+ setError((err as Error).message);
93
+ }
94
+ });
95
+ }
96
+
97
+ return (
98
+ <Card>
99
+ <CardHeader className="flex flex-row items-center justify-between space-y-0">
100
+ <CardTitle>Checklists ({attached.length})</CardTitle>
101
+ {templates.length > 0 && (
102
+ <div className="flex items-center gap-2">
103
+ <select
104
+ value={selectedTemplate}
105
+ onChange={(e) => setSelectedTemplate(e.target.value)}
106
+ className="border-input bg-background h-9 rounded-md border px-2 text-sm"
107
+ >
108
+ {templates.map((t) => (
109
+ <option key={t.id} value={t.id}>{t.name}</option>
110
+ ))}
111
+ </select>
112
+ <Button size="sm" onClick={handleAttach} disabled={pending}>
113
+ Attach
114
+ </Button>
115
+ </div>
116
+ )}
117
+ </CardHeader>
118
+ <CardContent className="space-y-4">
119
+ {error && <p className="text-destructive text-sm">{error}</p>}
120
+ {attached.length === 0 ? (
121
+ <p className="text-muted-foreground text-sm">
122
+ No checklists attached.{' '}
123
+ {templates.length === 0 && (
124
+ <>Create a template at <code>/checklists/new</code> first.</>
125
+ )}
126
+ </p>
127
+ ) : (
128
+ attached.map((cl) => {
129
+ const completedCount = cl.items.filter((i) => i.completed).length;
130
+ return (
131
+ <div key={cl.id} className="border-border space-y-3 rounded-md border p-3">
132
+ <div className="flex items-center justify-between">
133
+ <div>
134
+ <div className="font-semibold">{cl.templateName}</div>
135
+ <div className="text-muted-foreground text-xs">
136
+ {completedCount} / {cl.items.length} complete
137
+ {!cl.allRequiredComplete && (
138
+ <span className="text-destructive ml-2">· Required items pending</span>
139
+ )}
140
+ </div>
141
+ </div>
142
+ <div className="flex items-center gap-2">
143
+ <Badge variant={cl.allRequiredComplete ? 'default' : 'destructive'}>
144
+ {cl.progressPct}%
145
+ </Badge>
146
+ <button
147
+ type="button"
148
+ onClick={() => handleDetach(cl.id)}
149
+ className="text-muted-foreground hover:text-destructive text-xs underline"
150
+ >
151
+ Detach
152
+ </button>
153
+ </div>
154
+ </div>
155
+ <div className="space-y-2">
156
+ {[...cl.items]
157
+ .sort((a, b) => a.sortOrder - b.sortOrder)
158
+ .map((item) => (
159
+ <div key={item.id} className="flex items-start gap-3">
160
+ <input
161
+ type="checkbox"
162
+ checked={item.completed}
163
+ onChange={() => handleToggle(cl.id, item)}
164
+ disabled={pending}
165
+ className="mt-1 h-4 w-4 cursor-pointer"
166
+ />
167
+ <div className="flex-1">
168
+ <div className={`flex flex-wrap items-center gap-2 text-sm ${item.completed ? 'text-muted-foreground line-through' : ''}`}>
169
+ {item.label}
170
+ {item.required && !item.completed && (
171
+ <Badge variant="destructive" className="text-[10px]">required</Badge>
172
+ )}
173
+ </div>
174
+ {item.helperText && (
175
+ <div className="text-muted-foreground mt-0.5 text-xs">{item.helperText}</div>
176
+ )}
177
+ {item.kind === 'text' && (
178
+ <Input
179
+ value={item.value ?? ''}
180
+ onChange={(e) => handleTextChange(cl.id, item, e.target.value)}
181
+ onBlur={() => handleTextBlur(cl.id, item)}
182
+ placeholder="Answer…"
183
+ className="mt-1 text-xs"
184
+ />
185
+ )}
186
+ {item.kind === 'photo' && (
187
+ <div className="text-muted-foreground mt-1 text-[11px]">
188
+ Photo uploads in the Photos section above count toward this item.
189
+ </div>
190
+ )}
191
+ </div>
192
+ </div>
193
+ ))}
194
+ </div>
195
+ </div>
196
+ );
197
+ })
198
+ )}
199
+ </CardContent>
200
+ </Card>
201
+ );
202
+ }
@@ -0,0 +1,24 @@
1
+ import { asc, isNull } from 'drizzle-orm';
2
+ import { db } from '@/db/client';
3
+ import { checklistTemplates } from '@/db/schema';
4
+ import { getJobChecklists } from '@/lib/checklists/data';
5
+ import { JobChecklistsClient } from './job-checklists-client';
6
+
7
+ export async function JobChecklistsSection({ jobId }: { jobId: string }) {
8
+ const [attached, templates] = await Promise.all([
9
+ getJobChecklists(jobId),
10
+ db
11
+ .select({ id: checklistTemplates.id, name: checklistTemplates.name })
12
+ .from(checklistTemplates)
13
+ .where(isNull(checklistTemplates.archivedAt))
14
+ .orderBy(asc(checklistTemplates.name)),
15
+ ]);
16
+
17
+ return (
18
+ <JobChecklistsClient
19
+ jobId={jobId}
20
+ initialAttached={attached}
21
+ templates={templates}
22
+ />
23
+ );
24
+ }
@@ -0,0 +1,52 @@
1
+ import { jsonb, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
2
+ import { jobs } from './jobs';
3
+
4
+ interface ChecklistItemJson {
5
+ id: string;
6
+ label: string;
7
+ kind: 'checkbox' | 'text' | 'photo';
8
+ required: boolean;
9
+ sortOrder: number;
10
+ helperText?: string;
11
+ }
12
+
13
+ interface JobChecklistItemJson extends ChecklistItemJson {
14
+ completed: boolean;
15
+ completedAt?: string;
16
+ value?: string;
17
+ }
18
+
19
+ /**
20
+ * Reusable templates — admin-editable. items is stored as jsonb because
21
+ * they're effectively static per-template and we never need to query
22
+ * within them. Soft-delete via archivedAt.
23
+ */
24
+ export const checklistTemplates = pgTable('checklist_templates', {
25
+ id: uuid('id').primaryKey().defaultRandom(),
26
+ name: text('name').notNull(),
27
+ description: text('description'),
28
+ items: jsonb('items').$type<ChecklistItemJson[]>().notNull().default([]),
29
+ archivedAt: timestamp('archived_at'),
30
+ createdAt: timestamp('created_at').notNull().defaultNow(),
31
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
32
+ });
33
+
34
+ /**
35
+ * Per-job instance — copies the template's items at attach time so
36
+ * template edits don't retroactively change in-flight job checklists.
37
+ * Completion state lives inline on each item.
38
+ */
39
+ export const jobChecklists = pgTable('job_checklists', {
40
+ id: uuid('id').primaryKey().defaultRandom(),
41
+ jobId: uuid('job_id').notNull().references(() => jobs.id, { onDelete: 'cascade' }),
42
+ templateId: uuid('template_id').references(() => checklistTemplates.id, { onDelete: 'set null' }),
43
+ templateName: text('template_name').notNull(),
44
+ items: jsonb('items').$type<JobChecklistItemJson[]>().notNull().default([]),
45
+ attachedAt: timestamp('attached_at').notNull().defaultNow(),
46
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
47
+ });
48
+
49
+ export type ChecklistTemplateRow = typeof checklistTemplates.$inferSelect;
50
+ export type NewChecklistTemplate = typeof checklistTemplates.$inferInsert;
51
+ export type JobChecklistRow = typeof jobChecklists.$inferSelect;
52
+ export type NewJobChecklist = typeof jobChecklists.$inferInsert;
@@ -0,0 +1,112 @@
1
+ 'use server';
2
+
3
+ import crypto from 'node:crypto';
4
+ import { eq } from 'drizzle-orm';
5
+ import { redirect } from 'next/navigation';
6
+ import { revalidatePath } from 'next/cache';
7
+ import { db } from '@/db/client';
8
+ import { checklistTemplates, jobChecklists } from '@/db/schema';
9
+ import type { ChecklistTemplateItem, JobChecklistItem } from './types';
10
+
11
+ export interface CreateTemplateInput {
12
+ name: string;
13
+ description?: string;
14
+ items: Array<Omit<ChecklistTemplateItem, 'id' | 'sortOrder'>>;
15
+ }
16
+
17
+ export async function createChecklistTemplate(input: CreateTemplateInput): Promise<void> {
18
+ if (!input.name.trim()) throw new Error('Name is required');
19
+ if (input.items.length === 0) throw new Error('Add at least one item');
20
+
21
+ const items: ChecklistTemplateItem[] = input.items.map((it, i) => ({
22
+ id: crypto.randomBytes(4).toString('hex'),
23
+ label: it.label.trim(),
24
+ kind: it.kind,
25
+ required: it.required,
26
+ helperText: it.helperText?.trim() || undefined,
27
+ sortOrder: i,
28
+ }));
29
+
30
+ const [row] = await db
31
+ .insert(checklistTemplates)
32
+ .values({
33
+ name: input.name.trim(),
34
+ description: input.description?.trim() || null,
35
+ items,
36
+ })
37
+ .returning({ id: checklistTemplates.id });
38
+
39
+ revalidatePath('/checklists');
40
+ redirect(`/checklists/${row.id}`);
41
+ }
42
+
43
+ /**
44
+ * Server action — attaches a template to a job by copying the template's
45
+ * items into a new job_checklists row with completed=false for each.
46
+ */
47
+ export async function attachChecklistToJob(jobId: string, templateId: string): Promise<void> {
48
+ const [template] = await db
49
+ .select()
50
+ .from(checklistTemplates)
51
+ .where(eq(checklistTemplates.id, templateId))
52
+ .limit(1);
53
+ if (!template) throw new Error('Template not found');
54
+
55
+ const items: JobChecklistItem[] = (template.items as ChecklistTemplateItem[]).map((it) => ({
56
+ ...it,
57
+ completed: false,
58
+ }));
59
+
60
+ await db.insert(jobChecklists).values({
61
+ jobId,
62
+ templateId: template.id,
63
+ templateName: template.name,
64
+ items,
65
+ });
66
+
67
+ revalidatePath(`/jobs/${jobId}`);
68
+ }
69
+
70
+ export async function setJobChecklistItem(
71
+ jobChecklistId: string,
72
+ itemId: string,
73
+ patch: { completed?: boolean; value?: string },
74
+ ): Promise<void> {
75
+ const [row] = await db
76
+ .select()
77
+ .from(jobChecklists)
78
+ .where(eq(jobChecklists.id, jobChecklistId))
79
+ .limit(1);
80
+ if (!row) throw new Error('Checklist not found');
81
+
82
+ const items = (row.items as JobChecklistItem[]).map((it) => {
83
+ if (it.id !== itemId) return it;
84
+ const next = { ...it };
85
+ if (patch.completed !== undefined) {
86
+ next.completed = patch.completed;
87
+ next.completedAt = patch.completed ? new Date().toISOString() : undefined;
88
+ }
89
+ if (patch.value !== undefined) {
90
+ next.value = patch.value;
91
+ }
92
+ return next;
93
+ });
94
+
95
+ await db
96
+ .update(jobChecklists)
97
+ .set({ items, updatedAt: new Date() })
98
+ .where(eq(jobChecklists.id, jobChecklistId));
99
+
100
+ revalidatePath(`/jobs/${row.jobId}`);
101
+ }
102
+
103
+ export async function detachJobChecklist(jobChecklistId: string): Promise<void> {
104
+ const [row] = await db
105
+ .select({ jobId: jobChecklists.jobId })
106
+ .from(jobChecklists)
107
+ .where(eq(jobChecklists.id, jobChecklistId))
108
+ .limit(1);
109
+ if (!row) return;
110
+ await db.delete(jobChecklists).where(eq(jobChecklists.id, jobChecklistId));
111
+ revalidatePath(`/jobs/${row.jobId}`);
112
+ }