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,55 @@
1
+ import { auth, currentUser } from '@clerk/nextjs/server';
2
+ import { redirect } from 'next/navigation';
3
+
4
+ export type AppRole = 'admin' | 'dispatcher' | 'technician' | 'csr' | 'sales' | 'accountant';
5
+
6
+ export interface AppUser {
7
+ id: string;
8
+ email: string;
9
+ firstName: string | null;
10
+ lastName: string | null;
11
+ role: AppRole;
12
+ }
13
+
14
+ function roleFrom(metadata: unknown): AppRole {
15
+ const role = (metadata as { role?: string } | undefined)?.role;
16
+ switch (role) {
17
+ case 'admin':
18
+ case 'dispatcher':
19
+ case 'technician':
20
+ case 'csr':
21
+ case 'sales':
22
+ case 'accountant':
23
+ return role;
24
+ default:
25
+ return 'csr';
26
+ }
27
+ }
28
+
29
+ export async function getCurrentUser(): Promise<AppUser | null> {
30
+ const { userId } = await auth();
31
+ if (!userId) return null;
32
+ const user = await currentUser();
33
+ if (!user) return null;
34
+ return {
35
+ id: user.id,
36
+ email: user.primaryEmailAddress?.emailAddress ?? '',
37
+ firstName: user.firstName,
38
+ lastName: user.lastName,
39
+ role: roleFrom(user.publicMetadata),
40
+ };
41
+ }
42
+
43
+ export async function requireUser(): Promise<AppUser> {
44
+ const u = await getCurrentUser();
45
+ if (!u) redirect('/sign-in');
46
+ return u;
47
+ }
48
+
49
+ export async function requireRole(role: AppRole | AppRole[]): Promise<AppUser> {
50
+ const u = await requireUser();
51
+ const allowed = Array.isArray(role) ? role : [role];
52
+ // Admin always has access.
53
+ if (u.role === 'admin' || allowed.includes(u.role)) return u;
54
+ redirect('/dashboard');
55
+ }
@@ -0,0 +1,17 @@
1
+ import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
2
+
3
+ const isProtectedRoute = createRouteMatcher(['/dashboard(.*)', '/admin(.*)']);
4
+
5
+ export default clerkMiddleware(async (auth, req) => {
6
+ if (isProtectedRoute(req)) {
7
+ await auth.protect();
8
+ }
9
+ });
10
+
11
+ export const config = {
12
+ matcher: [
13
+ // Match all routes except static files and Next internals.
14
+ '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
15
+ '/(api|trpc)(.*)',
16
+ ],
17
+ };
@@ -0,0 +1,39 @@
1
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
2
+
3
+ const HOURS = Array.from({ length: 10 }, (_, i) => 8 + i); // 8am-6pm
4
+ const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
5
+ const TECHS = ['Carlos M.', 'Sarah T.', 'Alex K.'];
6
+
7
+ export default function CalendarPage() {
8
+ return (
9
+ <div className="space-y-6">
10
+ <div>
11
+ <h1 className="text-3xl font-bold tracking-tight">Calendar</h1>
12
+ <p className="text-muted-foreground mt-1 text-sm">
13
+ Drag-drop dispatch board lands in a follow-up phase (uses @dnd-kit/core).
14
+ </p>
15
+ </div>
16
+ <Card>
17
+ <CardHeader><CardTitle>This week</CardTitle></CardHeader>
18
+ <CardContent className="overflow-x-auto p-0">
19
+ <div className="min-w-[800px]">
20
+ <div className="grid grid-cols-7 border-b text-xs">
21
+ <div className="bg-muted/30 p-2 font-medium">Tech</div>
22
+ {DAYS.map((d) => <div key={d} className="bg-muted/30 p-2 text-center font-medium">{d}</div>)}
23
+ </div>
24
+ {TECHS.map((tech) => (
25
+ <div key={tech} className="grid grid-cols-7 border-b">
26
+ <div className="border-r p-2 text-sm font-medium">{tech}</div>
27
+ {DAYS.map((d) => (
28
+ <div key={d} className="min-h-[80px] border-r p-1 last:border-r-0">
29
+ {/* Empty cell — drag jobs here in the real impl */}
30
+ </div>
31
+ ))}
32
+ </div>
33
+ ))}
34
+ </div>
35
+ </CardContent>
36
+ </Card>
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,21 @@
1
+ import { getCalendarWeek } from '@/lib/calendar/data';
2
+ import { CalendarBoard } from '@/components/calendar/calendar-board';
3
+
4
+ export default async function CalendarPage() {
5
+ const week = await getCalendarWeek();
6
+ return (
7
+ <div className="space-y-6">
8
+ <div>
9
+ <h1 className="text-3xl font-bold tracking-tight">Calendar</h1>
10
+ <p className="text-muted-foreground mt-1 text-sm">
11
+ Drag jobs from <span className="font-medium text-foreground">Unassigned</span> onto a tech × day cell to schedule.
12
+ Updates persist to Postgres via a server action.
13
+ </p>
14
+ </div>
15
+ <CalendarBoard
16
+ initialJobs={week.jobs}
17
+ weekStart={week.weekStart.toISOString()}
18
+ />
19
+ </div>
20
+ );
21
+ }
@@ -0,0 +1,195 @@
1
+ 'use client';
2
+
3
+ import { useState, useTransition } from 'react';
4
+ import {
5
+ DndContext,
6
+ DragOverlay,
7
+ PointerSensor,
8
+ useDraggable,
9
+ useDroppable,
10
+ useSensor,
11
+ useSensors,
12
+ type DragEndEvent,
13
+ type DragStartEvent,
14
+ } from '@dnd-kit/core';
15
+ import type { Job } from '@/lib/jobs/types';
16
+ import { JOB_STATUS_COLOR } from '@/lib/jobs/types';
17
+ import { assignJob } from '@/lib/calendar/actions';
18
+ import { Card } from '@/components/ui/card';
19
+
20
+ const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
21
+ const TECHS = [
22
+ { id: 't_carlos', name: 'Carlos M.' },
23
+ { id: 't_sarah', name: 'Sarah T.' },
24
+ { id: 't_alex', name: 'Alex K.' },
25
+ ] as const;
26
+
27
+ interface CalendarBoardProps {
28
+ initialJobs: Job[];
29
+ weekStart: string; // ISO
30
+ }
31
+
32
+ function addDays(iso: string, n: number): Date {
33
+ const d = new Date(iso);
34
+ d.setDate(d.getDate() + n);
35
+ return d;
36
+ }
37
+
38
+ function getCellId(techId: string, dayIdx: number): string {
39
+ return `${techId}__${dayIdx}`;
40
+ }
41
+
42
+ function parseCellId(id: string): { techId: string; dayIdx: number } | null {
43
+ const [techId, idxRaw] = id.split('__');
44
+ if (!techId || !idxRaw) return null;
45
+ return { techId, dayIdx: Number(idxRaw) };
46
+ }
47
+
48
+ function findCellForJob(job: Job, weekStart: string): string | null {
49
+ if (!job.scheduledAt || job.assigneeIds.length === 0) return null;
50
+ const techId = job.assigneeIds[0];
51
+ if (!TECHS.find((t) => t.id === techId)) return null;
52
+ const start = new Date(weekStart);
53
+ const jobDay = new Date(job.scheduledAt);
54
+ const diffMs = jobDay.getTime() - start.getTime();
55
+ const dayIdx = Math.floor(diffMs / 86_400_000);
56
+ if (dayIdx < 0 || dayIdx > 5) return null;
57
+ return getCellId(techId, dayIdx);
58
+ }
59
+
60
+ function JobCard({ job, dragging = false }: { job: Job; dragging?: boolean }) {
61
+ return (
62
+ <div
63
+ className={`rounded-md border p-2 text-xs shadow-sm ${dragging ? 'opacity-50' : ''}`}
64
+ style={{ backgroundColor: 'white' }}
65
+ >
66
+ <div className="font-medium">{job.customerName}</div>
67
+ <div className="text-muted-foreground truncate">{job.serviceType}</div>
68
+ <span className={`mt-1 inline-block rounded px-1 text-[10px] ${JOB_STATUS_COLOR[job.status]}`}>
69
+ {job.status}
70
+ </span>
71
+ {job.priority === 'emergency' && (
72
+ <span className="text-destructive ml-1 text-[10px] font-bold">⚠</span>
73
+ )}
74
+ </div>
75
+ );
76
+ }
77
+
78
+ function DraggableJob({ job }: { job: Job }) {
79
+ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: job.id });
80
+ return (
81
+ <div ref={setNodeRef} {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing">
82
+ <JobCard job={job} dragging={isDragging} />
83
+ </div>
84
+ );
85
+ }
86
+
87
+ function DropCell({ id, children }: { id: string; children: React.ReactNode }) {
88
+ const { setNodeRef, isOver } = useDroppable({ id });
89
+ return (
90
+ <div
91
+ ref={setNodeRef}
92
+ className={`min-h-[80px] border-r p-1 last:border-r-0 ${isOver ? 'bg-brand/10' : ''}`}
93
+ >
94
+ {children}
95
+ </div>
96
+ );
97
+ }
98
+
99
+ export function CalendarBoard({ initialJobs, weekStart }: CalendarBoardProps) {
100
+ const [jobs, setJobs] = useState(initialJobs);
101
+ const [activeId, setActiveId] = useState<string | null>(null);
102
+ const [, startTransition] = useTransition();
103
+
104
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
105
+
106
+ function onDragStart(e: DragStartEvent) {
107
+ setActiveId(String(e.active.id));
108
+ }
109
+
110
+ function onDragEnd(e: DragEndEvent) {
111
+ setActiveId(null);
112
+ if (!e.over) return;
113
+ const jobId = String(e.active.id);
114
+ const cell = parseCellId(String(e.over.id));
115
+ if (!cell) return;
116
+ const scheduledAt = addDays(weekStart, cell.dayIdx);
117
+ scheduledAt.setHours(9, 0, 0, 0); // default 9am slot
118
+
119
+ // Optimistic local update.
120
+ setJobs((prev) =>
121
+ prev.map((j) =>
122
+ j.id === jobId
123
+ ? { ...j, scheduledAt: scheduledAt.toISOString(), assigneeIds: [cell.techId] }
124
+ : j,
125
+ ),
126
+ );
127
+ startTransition(() =>
128
+ assignJob({
129
+ jobId,
130
+ scheduledAt: scheduledAt.toISOString(),
131
+ technicianId: cell.techId,
132
+ }),
133
+ );
134
+ }
135
+
136
+ const unscheduled = jobs.filter((j) => !j.scheduledAt || j.assigneeIds.length === 0);
137
+ const activeJob = activeId ? jobs.find((j) => j.id === activeId) : null;
138
+
139
+ return (
140
+ <DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
141
+ <div className="grid grid-cols-[16rem_1fr] gap-4">
142
+ <Card className="flex flex-col gap-2 p-3">
143
+ <div className="text-sm font-semibold">Unassigned ({unscheduled.length})</div>
144
+ <DropCell id="unassigned__-1">
145
+ <div className="flex flex-col gap-2">
146
+ {unscheduled.map((j) => <DraggableJob key={j.id} job={j} />)}
147
+ {unscheduled.length === 0 && (
148
+ <div className="text-muted-foreground py-4 text-center text-xs">
149
+ All jobs scheduled
150
+ </div>
151
+ )}
152
+ </div>
153
+ </DropCell>
154
+ </Card>
155
+ <Card className="overflow-x-auto p-0">
156
+ <div className="min-w-[800px]">
157
+ <div className="grid grid-cols-7 border-b text-xs">
158
+ <div className="bg-muted/30 p-2 font-medium">Tech</div>
159
+ {DAYS.map((d, i) => {
160
+ const date = addDays(weekStart, i);
161
+ return (
162
+ <div key={d} className="bg-muted/30 p-2 text-center font-medium">
163
+ {d}
164
+ <div className="text-muted-foreground mt-0.5 text-[10px]">
165
+ {date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
166
+ </div>
167
+ </div>
168
+ );
169
+ })}
170
+ </div>
171
+ {TECHS.map((tech) => (
172
+ <div key={tech.id} className="grid grid-cols-7 border-b last:border-b-0">
173
+ <div className="border-r p-2 text-sm font-medium">{tech.name}</div>
174
+ {DAYS.map((_, dayIdx) => {
175
+ const cellId = getCellId(tech.id, dayIdx);
176
+ const cellJobs = jobs.filter(
177
+ (j) => findCellForJob(j, weekStart) === cellId,
178
+ );
179
+ return (
180
+ <DropCell key={cellId} id={cellId}>
181
+ <div className="flex flex-col gap-1">
182
+ {cellJobs.map((j) => <DraggableJob key={j.id} job={j} />)}
183
+ </div>
184
+ </DropCell>
185
+ );
186
+ })}
187
+ </div>
188
+ ))}
189
+ </div>
190
+ </Card>
191
+ </div>
192
+ <DragOverlay>{activeJob && <JobCard job={activeJob} />}</DragOverlay>
193
+ </DndContext>
194
+ );
195
+ }
@@ -0,0 +1,35 @@
1
+ 'use server';
2
+
3
+ import { eq } from 'drizzle-orm';
4
+ import { revalidatePath } from 'next/cache';
5
+ import { db } from '@/db/client';
6
+ import { jobs } from '@/db/schema';
7
+
8
+ export interface AssignJobInput {
9
+ jobId: string;
10
+ scheduledAt: string | null; // ISO or null to unschedule
11
+ technicianId?: string; // optional — single-tech assignment
12
+ }
13
+
14
+ /**
15
+ * Server action — sets a job's scheduled time and (optionally) its
16
+ * assigned technician. Called when a job card is dropped on a calendar
17
+ * cell.
18
+ */
19
+ export async function assignJob(input: AssignJobInput): Promise<void> {
20
+ const updates: {
21
+ scheduledAt: Date | null;
22
+ assigneeIds?: string[];
23
+ updatedAt: Date;
24
+ } = {
25
+ scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : null,
26
+ updatedAt: new Date(),
27
+ };
28
+ if (input.technicianId) {
29
+ updates.assigneeIds = [input.technicianId];
30
+ }
31
+ await db.update(jobs).set(updates).where(eq(jobs.id, input.jobId));
32
+ // <crm-starter:assign-hook>
33
+ revalidatePath('/calendar');
34
+ revalidatePath(`/jobs/${input.jobId}`);
35
+ }
@@ -0,0 +1,74 @@
1
+ import { and, gte, isNull, lte, or } from 'drizzle-orm';
2
+ import { db } from '@/db/client';
3
+ import { jobs as jobsTable, customers as customersTable } from '@/db/schema';
4
+ import type { Job, JobStatus, LineItem } from '@/lib/jobs/types';
5
+
6
+ export interface CalendarWeek {
7
+ weekStart: Date;
8
+ jobs: Job[];
9
+ }
10
+
11
+ function startOfWeek(d: Date): Date {
12
+ const x = new Date(d);
13
+ x.setHours(0, 0, 0, 0);
14
+ const day = x.getDay();
15
+ const diff = (day + 6) % 7; // Monday-start week
16
+ x.setDate(x.getDate() - diff);
17
+ return x;
18
+ }
19
+
20
+ function addDays(d: Date, n: number): Date {
21
+ const x = new Date(d);
22
+ x.setDate(x.getDate() + n);
23
+ return x;
24
+ }
25
+
26
+ function toJob(row: { job: typeof jobsTable.$inferSelect; customer: typeof customersTable.$inferSelect | null }): Job {
27
+ const j = row.job;
28
+ return {
29
+ id: j.id,
30
+ customerId: j.customerId,
31
+ customerName: row.customer?.name ?? '(deleted)',
32
+ serviceType: j.serviceType,
33
+ status: j.status as JobStatus,
34
+ priority: j.priority as Job['priority'],
35
+ scheduledAt: j.scheduledAt?.toISOString(),
36
+ arrivalWindow: j.arrivalWindow ?? undefined,
37
+ assigneeIds: j.assigneeIds,
38
+ assigneeNames: [],
39
+ lineItems: j.lineItems as LineItem[],
40
+ total: j.total,
41
+ notes: j.notes ?? undefined,
42
+ createdAt: j.createdAt.toISOString(),
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Returns jobs scheduled within the given week PLUS unscheduled jobs
48
+ * in the active part of the pipeline (so dispatchers can drag them onto
49
+ * the calendar).
50
+ */
51
+ export async function getCalendarWeek(weekStart?: Date): Promise<CalendarWeek> {
52
+ const start = startOfWeek(weekStart ?? new Date());
53
+ const end = addDays(start, 7);
54
+
55
+ const rows = await db
56
+ .select({ job: jobsTable, customer: customersTable })
57
+ .from(jobsTable)
58
+ .leftJoin(customersTable, eq(jobsTable.customerId, customersTable.id))
59
+ .where(
60
+ or(
61
+ and(gte(jobsTable.scheduledAt, start), lte(jobsTable.scheduledAt, end)),
62
+ and(
63
+ isNull(jobsTable.scheduledAt),
64
+ // Only show unscheduled jobs in scheduleable statuses.
65
+ or(eq(jobsTable.status, 'scheduled'), eq(jobsTable.status, 'estimate'), eq(jobsTable.status, 'lead')),
66
+ ),
67
+ ),
68
+ );
69
+
70
+ return { weekStart: start, jobs: rows.map(toJob) };
71
+ }
72
+
73
+ // Local re-export so the import block above stays clean.
74
+ import { eq } from 'drizzle-orm';
@@ -0,0 +1,48 @@
1
+ import Link from 'next/link';
2
+ import { notFound } from 'next/navigation';
3
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
4
+ import { Badge } from '@/components/ui/badge';
5
+ import { getChecklistTemplate } from '@/lib/checklists/data';
6
+ import { CHECKLIST_ITEM_KIND_LABEL } from '@/lib/checklists/types';
7
+
8
+ interface PageProps { params: Promise<{ id: string }>; }
9
+
10
+ export default async function ChecklistTemplateDetailPage({ params }: PageProps) {
11
+ const { id } = await params;
12
+ const template = await getChecklistTemplate(id);
13
+ if (!template) notFound();
14
+ const sorted = [...template.items].sort((a, b) => a.sortOrder - b.sortOrder);
15
+ return (
16
+ <div className="space-y-6">
17
+ <div>
18
+ <Link href="/checklists" className="text-muted-foreground text-sm hover:underline">← Checklists</Link>
19
+ <h1 className="mt-1 text-3xl font-bold tracking-tight">{template.name}</h1>
20
+ {template.description && (
21
+ <p className="text-muted-foreground mt-1">{template.description}</p>
22
+ )}
23
+ </div>
24
+ <Card>
25
+ <CardHeader>
26
+ <CardTitle>Items ({template.itemCount})</CardTitle>
27
+ </CardHeader>
28
+ <CardContent className="space-y-3">
29
+ {sorted.map((item, i) => (
30
+ <div key={item.id} className="border-border flex items-start gap-3 border-b pb-3 last:border-b-0">
31
+ <div className="text-muted-foreground w-6 text-sm">{i + 1}.</div>
32
+ <div className="flex-1">
33
+ <div className="flex flex-wrap items-center gap-2">
34
+ <span className="font-medium">{item.label}</span>
35
+ <Badge variant="outline" className="text-xs">{CHECKLIST_ITEM_KIND_LABEL[item.kind]}</Badge>
36
+ {item.required && <Badge variant="destructive" className="text-xs">Required</Badge>}
37
+ </div>
38
+ {item.helperText && (
39
+ <p className="text-muted-foreground mt-1 text-xs">{item.helperText}</p>
40
+ )}
41
+ </div>
42
+ </div>
43
+ ))}
44
+ </CardContent>
45
+ </Card>
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,15 @@
1
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
2
+
3
+ export default function NewChecklistTemplatePage() {
4
+ return (
5
+ <div className="mx-auto max-w-2xl space-y-6">
6
+ <h1 className="text-3xl font-bold tracking-tight">New checklist template</h1>
7
+ <Card>
8
+ <CardHeader><CardTitle>Form goes here</CardTitle></CardHeader>
9
+ <CardContent className="text-muted-foreground text-sm">
10
+ Wire this to your stack to enable. Drizzle ships a working version.
11
+ </CardContent>
12
+ </Card>
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,83 @@
1
+ import Link from 'next/link';
2
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Badge } from '@/components/ui/badge';
5
+ import { getChecklistTemplates } from '@/lib/checklists/data';
6
+ import { CHECKLIST_ITEM_KIND_LABEL } from '@/lib/checklists/types';
7
+ import { formatDate } from '@/lib/utils';
8
+
9
+ export default async function ChecklistsPage() {
10
+ const templates = await getChecklistTemplates();
11
+ return (
12
+ <div className="space-y-6">
13
+ <div className="flex items-center justify-between">
14
+ <div>
15
+ <h1 className="text-3xl font-bold tracking-tight">Checklists</h1>
16
+ <p className="text-muted-foreground mt-1 text-sm">
17
+ {templates.length} template{templates.length === 1 ? '' : 's'} · attach to jobs to enforce required completion steps
18
+ </p>
19
+ </div>
20
+ <Button asChild>
21
+ <Link href="/checklists/new">New template</Link>
22
+ </Button>
23
+ </div>
24
+ {templates.length === 0 ? (
25
+ <Card>
26
+ <CardContent className="p-6 text-muted-foreground text-sm">
27
+ No checklist templates yet. Build one for your most common job type.
28
+ </CardContent>
29
+ </Card>
30
+ ) : (
31
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
32
+ {templates.map((t) => {
33
+ const kindCounts = t.items.reduce<Record<string, number>>((acc, item) => {
34
+ acc[item.kind] = (acc[item.kind] ?? 0) + 1;
35
+ return acc;
36
+ }, {});
37
+ return (
38
+ <Card key={t.id}>
39
+ <CardHeader>
40
+ <div className="flex items-start justify-between">
41
+ <CardTitle className="text-base">
42
+ <Link href={`/checklists/${t.id}`} className="hover:underline">{t.name}</Link>
43
+ </CardTitle>
44
+ <Badge variant="outline">{t.attachedJobCount} jobs using</Badge>
45
+ </div>
46
+ {t.description && (
47
+ <p className="text-muted-foreground mt-1 text-sm">{t.description}</p>
48
+ )}
49
+ </CardHeader>
50
+ <CardContent className="space-y-3">
51
+ <div className="grid grid-cols-3 gap-3 text-center">
52
+ <div>
53
+ <div className="text-2xl font-bold">{t.itemCount}</div>
54
+ <div className="text-muted-foreground text-xs uppercase">Items</div>
55
+ </div>
56
+ <div>
57
+ <div className="text-destructive text-2xl font-bold">{t.requiredCount}</div>
58
+ <div className="text-muted-foreground text-xs uppercase">Required</div>
59
+ </div>
60
+ <div>
61
+ <div className="text-2xl font-bold">{Object.keys(kindCounts).length}</div>
62
+ <div className="text-muted-foreground text-xs uppercase">Kinds</div>
63
+ </div>
64
+ </div>
65
+ <div className="flex flex-wrap gap-1">
66
+ {Object.entries(kindCounts).map(([kind, count]) => (
67
+ <Badge key={kind} variant="secondary" className="text-xs">
68
+ {CHECKLIST_ITEM_KIND_LABEL[kind as keyof typeof CHECKLIST_ITEM_KIND_LABEL]} · {count}
69
+ </Badge>
70
+ ))}
71
+ </div>
72
+ <div className="text-muted-foreground border-t pt-2 text-xs">
73
+ Created {formatDate(t.createdAt)}
74
+ </div>
75
+ </CardContent>
76
+ </Card>
77
+ );
78
+ })}
79
+ </div>
80
+ )}
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,18 @@
1
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
2
+
3
+ /**
4
+ * Default stub for Convex / sample-data. Drizzle's installer overwrites
5
+ * this with a server component that fetches attached checklists and
6
+ * renders the interactive completion UI.
7
+ */
8
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
9
+ export function JobChecklistsSection({ jobId }: { jobId: string }) {
10
+ return (
11
+ <Card>
12
+ <CardHeader><CardTitle>Checklists</CardTitle></CardHeader>
13
+ <CardContent className="text-muted-foreground text-sm">
14
+ Job checklists ship on the Drizzle stack. Templates live at <code>/checklists</code>.
15
+ </CardContent>
16
+ </Card>
17
+ );
18
+ }
@@ -0,0 +1,17 @@
1
+ import { sampleTemplates } from './sample-data';
2
+ import type { ChecklistTemplate, JobChecklist } from './types';
3
+
4
+ export async function getChecklistTemplates(): Promise<ChecklistTemplate[]> {
5
+ return sampleTemplates;
6
+ }
7
+
8
+ export async function getChecklistTemplate(id: string): Promise<ChecklistTemplate | null> {
9
+ return sampleTemplates.find((t) => t.id === id) ?? null;
10
+ }
11
+
12
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
13
+ export async function getJobChecklists(_jobId: string): Promise<JobChecklist[]> {
14
+ // The stub returns no attached checklists. Drizzle override fetches
15
+ // from the DB.
16
+ return [];
17
+ }