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,59 @@
1
+ import { integer, jsonb, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
2
+ import { customers } from './customers';
3
+ import { jobs } from './jobs';
4
+ import { estimates } from './estimates';
5
+
6
+ export const invoiceStatus = pgEnum('invoice_status', [
7
+ 'draft', 'sent', 'partial', 'paid', 'overdue', 'void',
8
+ ]);
9
+
10
+ export const paymentMethod = pgEnum('payment_method', [
11
+ 'card', 'ach', 'cash', 'check', 'manual', 'stripe',
12
+ ]);
13
+
14
+ interface LineItemJson {
15
+ description: string;
16
+ qty: number;
17
+ unitPrice: number; // cents
18
+ taxable?: boolean;
19
+ }
20
+
21
+ export const invoices = pgTable('invoices', {
22
+ id: uuid('id').primaryKey().defaultRandom(),
23
+ invoiceNumber: text('invoice_number').notNull().unique(),
24
+ customerId: uuid('customer_id').notNull().references(() => customers.id, { onDelete: 'restrict' }),
25
+ jobId: uuid('job_id').references(() => jobs.id, { onDelete: 'set null' }),
26
+ estimateId: uuid('estimate_id').references(() => estimates.id, { onDelete: 'set null' }),
27
+ status: invoiceStatus('status').notNull().default('draft'),
28
+ lineItems: jsonb('line_items').$type<LineItemJson[]>().notNull().default([]),
29
+ subtotal: integer('subtotal').notNull().default(0), // cents
30
+ total: integer('total').notNull().default(0),
31
+ amountPaid: integer('amount_paid').notNull().default(0),
32
+ // Stripe metadata key for webhook reconciliation. Each invoice gets a
33
+ // random public token used both as the Stripe metadata link AND for the
34
+ // public payment page at /i/[publicToken] (Phase 18.5 ships that page).
35
+ publicToken: text('public_token').notNull().unique(),
36
+ notes: text('notes'),
37
+ dueDate: timestamp('due_date'),
38
+ sentAt: timestamp('sent_at'),
39
+ paidAt: timestamp('paid_at'),
40
+ createdAt: timestamp('created_at').notNull().defaultNow(),
41
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
42
+ });
43
+
44
+ export const invoicePayments = pgTable('invoice_payments', {
45
+ id: uuid('id').primaryKey().defaultRandom(),
46
+ invoiceId: uuid('invoice_id').notNull().references(() => invoices.id, { onDelete: 'cascade' }),
47
+ amount: integer('amount').notNull(), // cents
48
+ method: paymentMethod('method').notNull(),
49
+ stripeSessionId: text('stripe_session_id'), // links back to Stripe Checkout Session
50
+ stripePaymentIntentId: text('stripe_payment_intent_id'),
51
+ note: text('note'),
52
+ recordedAt: timestamp('recorded_at').notNull().defaultNow(),
53
+ createdAt: timestamp('created_at').notNull().defaultNow(),
54
+ });
55
+
56
+ export type InvoiceRow = typeof invoices.$inferSelect;
57
+ export type NewInvoice = typeof invoices.$inferInsert;
58
+ export type InvoicePaymentRow = typeof invoicePayments.$inferSelect;
59
+ export type NewInvoicePayment = typeof invoicePayments.$inferInsert;
@@ -0,0 +1,110 @@
1
+ 'use server';
2
+
3
+ import { eq } from 'drizzle-orm';
4
+ import { redirect } from 'next/navigation';
5
+ import { revalidatePath } from 'next/cache';
6
+ import { db } from '@/db/client';
7
+ import { estimates, jobs } from '@/db/schema';
8
+ import type { EstimateLineItem, EstimateStatus } from './types';
9
+
10
+ export interface CreateEstimateInput {
11
+ customerId: string;
12
+ status: EstimateStatus;
13
+ lineItems: EstimateLineItem[];
14
+ notes: string | null;
15
+ validUntil: string | null; // ISO
16
+ }
17
+
18
+ export async function createEstimate(input: CreateEstimateInput): Promise<void> {
19
+ if (!input.customerId) throw new Error('Customer is required');
20
+
21
+ const subtotal = input.lineItems.reduce((acc, li) => acc + li.qty * li.unitPrice, 0);
22
+ const totalCost = input.lineItems.reduce(
23
+ (acc, li) => acc + li.qty * (li.unitCost ?? 0),
24
+ 0,
25
+ );
26
+
27
+ const [row] = await db
28
+ .insert(estimates)
29
+ .values({
30
+ customerId: input.customerId,
31
+ status: input.status,
32
+ lineItems: input.lineItems,
33
+ subtotal,
34
+ total: subtotal, // no tax/discount yet — Phase 22 wires those in
35
+ totalCost,
36
+ notes: input.notes ?? null,
37
+ validUntil: input.validUntil ? new Date(input.validUntil) : null,
38
+ sentAt: input.status === 'sent' ? new Date() : null,
39
+ })
40
+ .returning({ id: estimates.id });
41
+
42
+ revalidatePath('/estimates');
43
+ redirect(`/estimates/${row.id}`);
44
+ }
45
+
46
+ export async function setEstimateStatus(estimateId: string, status: EstimateStatus): Promise<void> {
47
+ const updates: {
48
+ status: EstimateStatus;
49
+ updatedAt: Date;
50
+ sentAt?: Date;
51
+ approvedAt?: Date;
52
+ } = {
53
+ status,
54
+ updatedAt: new Date(),
55
+ };
56
+ if (status === 'sent') updates.sentAt = new Date();
57
+ if (status === 'approved') updates.approvedAt = new Date();
58
+ await db.update(estimates).set(updates).where(eq(estimates.id, estimateId));
59
+ revalidatePath(`/estimates/${estimateId}`);
60
+ revalidatePath('/estimates');
61
+ }
62
+
63
+ /**
64
+ * Server action — clones an approved estimate into a new job (status =
65
+ * scheduled, line items copied over), marks the estimate as converted, and
66
+ * redirects to the new job detail page.
67
+ */
68
+ export async function convertEstimateToJob(estimateId: string): Promise<void> {
69
+ const [estimate] = await db.select().from(estimates).where(eq(estimates.id, estimateId)).limit(1);
70
+ if (!estimate) throw new Error('Estimate not found');
71
+ if (estimate.status !== 'approved') {
72
+ throw new Error('Only approved estimates can be converted');
73
+ }
74
+ if (estimate.convertedJobId) {
75
+ redirect(`/jobs/${estimate.convertedJobId}`);
76
+ }
77
+
78
+ const lineItems = estimate.lineItems as EstimateLineItem[];
79
+ const [newJob] = await db
80
+ .insert(jobs)
81
+ .values({
82
+ customerId: estimate.customerId,
83
+ serviceType: 'From estimate',
84
+ status: 'scheduled',
85
+ priority: 'normal',
86
+ assigneeIds: [],
87
+ lineItems: lineItems.map((li) => ({
88
+ description: li.description,
89
+ qty: li.qty,
90
+ unitPrice: li.unitPrice,
91
+ })),
92
+ total: estimate.total,
93
+ notes: estimate.notes,
94
+ })
95
+ .returning({ id: jobs.id });
96
+
97
+ await db
98
+ .update(estimates)
99
+ .set({
100
+ status: 'converted',
101
+ convertedJobId: newJob.id,
102
+ updatedAt: new Date(),
103
+ })
104
+ .where(eq(estimates.id, estimateId));
105
+
106
+ revalidatePath('/estimates');
107
+ revalidatePath('/jobs');
108
+ revalidatePath(`/estimates/${estimateId}`);
109
+ redirect(`/jobs/${newJob.id}`);
110
+ }
@@ -0,0 +1,57 @@
1
+ import { desc, eq } from 'drizzle-orm';
2
+ import { db } from '@/db/client';
3
+ import { estimates as estimatesTable, customers as customersTable } from '@/db/schema';
4
+ import type { Estimate, EstimateLineItem, EstimateStatus } from './types';
5
+
6
+ interface JoinedRow {
7
+ estimate: typeof estimatesTable.$inferSelect;
8
+ customer: typeof customersTable.$inferSelect | null;
9
+ }
10
+
11
+ function toEstimate(row: JoinedRow): Estimate {
12
+ const e = row.estimate;
13
+ const lineItems = e.lineItems as EstimateLineItem[];
14
+ const totalCost = lineItems.reduce(
15
+ (acc, li) => acc + (li.unitCost ?? 0) * li.qty,
16
+ 0,
17
+ );
18
+ const margin = e.total - totalCost;
19
+ const marginPct = e.total > 0 ? Math.round((margin / e.total) * 100) : 0;
20
+ return {
21
+ id: e.id,
22
+ customerId: e.customerId,
23
+ customerName: row.customer?.name ?? '(deleted)',
24
+ status: e.status as EstimateStatus,
25
+ lineItems,
26
+ subtotal: e.subtotal,
27
+ total: e.total,
28
+ totalCost,
29
+ margin,
30
+ marginPct,
31
+ notes: e.notes ?? undefined,
32
+ validUntil: e.validUntil?.toISOString(),
33
+ sentAt: e.sentAt?.toISOString(),
34
+ approvedAt: e.approvedAt?.toISOString(),
35
+ convertedJobId: e.convertedJobId ?? undefined,
36
+ createdAt: e.createdAt.toISOString(),
37
+ };
38
+ }
39
+
40
+ export async function getEstimates(): Promise<Estimate[]> {
41
+ const rows = await db
42
+ .select({ estimate: estimatesTable, customer: customersTable })
43
+ .from(estimatesTable)
44
+ .leftJoin(customersTable, eq(estimatesTable.customerId, customersTable.id))
45
+ .orderBy(desc(estimatesTable.createdAt));
46
+ return rows.map(toEstimate);
47
+ }
48
+
49
+ export async function getEstimate(id: string): Promise<Estimate | null> {
50
+ const [row] = await db
51
+ .select({ estimate: estimatesTable, customer: customersTable })
52
+ .from(estimatesTable)
53
+ .leftJoin(customersTable, eq(estimatesTable.customerId, customersTable.id))
54
+ .where(eq(estimatesTable.id, id))
55
+ .limit(1);
56
+ return row ? toEstimate(row) : null;
57
+ }
@@ -0,0 +1,199 @@
1
+ 'use server';
2
+
3
+ import crypto from 'node:crypto';
4
+ import { eq, sql } from 'drizzle-orm';
5
+ import { headers } from 'next/headers';
6
+ import { redirect } from 'next/navigation';
7
+ import { revalidatePath } from 'next/cache';
8
+ import { db } from '@/db/client';
9
+ import { invoices, invoicePayments, jobs, customers } from '@/db/schema';
10
+ import { stripe, isStripeConfigured } from '@/lib/stripe';
11
+ import type { InvoiceLineItem, PaymentMethod } from './types';
12
+
13
+ function genInvoiceNumber(): string {
14
+ const year = new Date().getUTCFullYear();
15
+ const random = crypto.randomBytes(3).toString('hex').toUpperCase();
16
+ return `INV-${year}-${random}`;
17
+ }
18
+
19
+ export interface CreateInvoiceFromJobInput {
20
+ jobId: string;
21
+ dueDateDays?: number; // days from now; default 30
22
+ customNote?: string;
23
+ }
24
+
25
+ /**
26
+ * Server action — generates an invoice from a Finished/Completed job.
27
+ * Copies the job's line items + total + customer. Status defaults to
28
+ * 'sent' so the customer can pay immediately.
29
+ */
30
+ export async function createInvoiceFromJob(input: CreateInvoiceFromJobInput): Promise<void> {
31
+ const [job] = await db.select().from(jobs).where(eq(jobs.id, input.jobId)).limit(1);
32
+ if (!job) throw new Error('Job not found');
33
+
34
+ const lineItems = (job.lineItems as InvoiceLineItem[]).map((li) => ({
35
+ description: li.description,
36
+ qty: li.qty,
37
+ unitPrice: li.unitPrice,
38
+ taxable: true,
39
+ }));
40
+ const total = lineItems.reduce((acc, li) => acc + li.qty * li.unitPrice, 0);
41
+ if (total <= 0) {
42
+ throw new Error('Job has no priced line items — add some before invoicing.');
43
+ }
44
+
45
+ const dueDate = new Date();
46
+ dueDate.setDate(dueDate.getDate() + (input.dueDateDays ?? 30));
47
+
48
+ const [row] = await db
49
+ .insert(invoices)
50
+ .values({
51
+ invoiceNumber: genInvoiceNumber(),
52
+ customerId: job.customerId,
53
+ jobId: job.id,
54
+ status: 'sent',
55
+ lineItems,
56
+ subtotal: total,
57
+ total,
58
+ amountPaid: 0,
59
+ publicToken: crypto.randomBytes(16).toString('hex'),
60
+ notes: input.customNote ?? null,
61
+ dueDate,
62
+ sentAt: new Date(),
63
+ })
64
+ .returning({ id: invoices.id });
65
+
66
+ revalidatePath('/invoices');
67
+ revalidatePath(`/jobs/${input.jobId}`);
68
+ redirect(`/invoices/${row.id}`);
69
+ }
70
+
71
+ export interface RecordPaymentInput {
72
+ invoiceId: string;
73
+ amount: number; // cents
74
+ method: PaymentMethod;
75
+ note?: string;
76
+ stripeSessionId?: string;
77
+ stripePaymentIntentId?: string;
78
+ }
79
+
80
+ /**
81
+ * Records a payment against an invoice. Re-computes amountPaid + transitions
82
+ * status to 'paid' if fully covered or 'partial' otherwise. Safe to call
83
+ * from both UI (manual entry) and the Stripe webhook (idempotency via
84
+ * stripeSessionId uniqueness check).
85
+ */
86
+ export async function recordInvoicePayment(input: RecordPaymentInput): Promise<void> {
87
+ // Idempotency: if a payment with the same stripeSessionId already exists,
88
+ // bail. Stripe retries webhooks; we don't want to double-record.
89
+ if (input.stripeSessionId) {
90
+ const existing = await db
91
+ .select({ id: invoicePayments.id })
92
+ .from(invoicePayments)
93
+ .where(eq(invoicePayments.stripeSessionId, input.stripeSessionId))
94
+ .limit(1);
95
+ if (existing.length > 0) return;
96
+ }
97
+
98
+ await db.insert(invoicePayments).values({
99
+ invoiceId: input.invoiceId,
100
+ amount: input.amount,
101
+ method: input.method,
102
+ note: input.note ?? null,
103
+ stripeSessionId: input.stripeSessionId ?? null,
104
+ stripePaymentIntentId: input.stripePaymentIntentId ?? null,
105
+ });
106
+
107
+ // Recompute amountPaid by summing payment rows.
108
+ const [{ paid }] = await db
109
+ .select({ paid: sql<number>`COALESCE(SUM(${invoicePayments.amount}), 0)::int` })
110
+ .from(invoicePayments)
111
+ .where(eq(invoicePayments.invoiceId, input.invoiceId));
112
+
113
+ const [invoice] = await db
114
+ .select({ total: invoices.total })
115
+ .from(invoices)
116
+ .where(eq(invoices.id, input.invoiceId))
117
+ .limit(1);
118
+ if (!invoice) return;
119
+
120
+ const fullyPaid = paid >= invoice.total;
121
+ await db
122
+ .update(invoices)
123
+ .set({
124
+ amountPaid: paid,
125
+ status: fullyPaid ? 'paid' : 'partial',
126
+ paidAt: fullyPaid ? new Date() : null,
127
+ updatedAt: new Date(),
128
+ })
129
+ .where(eq(invoices.id, input.invoiceId));
130
+
131
+ revalidatePath(`/invoices/${input.invoiceId}`);
132
+ revalidatePath('/invoices');
133
+ }
134
+
135
+ /**
136
+ * Server action — creates a Stripe Checkout Session for an invoice's
137
+ * remaining amount due and redirects the user to Stripe-hosted checkout.
138
+ * Session metadata.invoiceId + metadata.publicToken are read by the
139
+ * webhook handler to record the payment.
140
+ */
141
+ export async function createCheckoutForInvoice(invoiceId: string): Promise<void> {
142
+ if (!isStripeConfigured) {
143
+ throw new Error('Stripe is not configured. Set STRIPE_SECRET_KEY in .env.local.');
144
+ }
145
+
146
+ // Fetch the invoice + customer email for the Stripe receipt.
147
+ const [row] = await db
148
+ .select({ invoice: invoices, customerEmail: customers.emails })
149
+ .from(invoices)
150
+ .leftJoin(customers, eq(invoices.customerId, customers.id))
151
+ .where(eq(invoices.id, invoiceId))
152
+ .limit(1);
153
+ if (!row) throw new Error('Invoice not found');
154
+ const { invoice } = row;
155
+ const amountDue = Math.max(0, invoice.total - invoice.amountPaid);
156
+ if (amountDue <= 0) throw new Error('Invoice has nothing left to pay');
157
+
158
+ const h = await headers();
159
+ const host = h.get('host') ?? 'localhost:3000';
160
+ const proto = h.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https');
161
+ const origin = `${proto}://${host}`;
162
+ const customerEmail = Array.isArray(row.customerEmail) ? row.customerEmail[0] : undefined;
163
+
164
+ const session = await stripe.checkout.sessions.create({
165
+ mode: 'payment',
166
+ customer_email: customerEmail,
167
+ line_items: [
168
+ {
169
+ price_data: {
170
+ currency: 'usd',
171
+ product_data: { name: `Invoice ${invoice.invoiceNumber}` },
172
+ unit_amount: amountDue,
173
+ },
174
+ quantity: 1,
175
+ },
176
+ ],
177
+ success_url: `${origin}/invoices/${invoice.id}?paid=true`,
178
+ cancel_url: `${origin}/invoices/${invoice.id}?cancelled=true`,
179
+ metadata: {
180
+ invoiceId: invoice.id,
181
+ invoiceNumber: invoice.invoiceNumber,
182
+ publicToken: invoice.publicToken,
183
+ },
184
+ });
185
+
186
+ if (!session.url) throw new Error('Stripe did not return a checkout URL');
187
+ redirect(session.url);
188
+ }
189
+
190
+ /**
191
+ * Public-page variant — accepts a publicToken (used in the customer-facing
192
+ * /i/[token] page so no internal invoiceId leaks into URLs). Resolves the
193
+ * token to an invoice and forwards to createCheckoutForInvoice.
194
+ */
195
+ export async function payByPublicToken(token: string): Promise<void> {
196
+ const [row] = await db.select().from(invoices).where(eq(invoices.publicToken, token)).limit(1);
197
+ if (!row) throw new Error('Invoice not found');
198
+ await createCheckoutForInvoice(row.id);
199
+ }
@@ -0,0 +1,99 @@
1
+ import { desc, eq } from 'drizzle-orm';
2
+ import { db } from '@/db/client';
3
+ import {
4
+ invoices as invoicesTable,
5
+ invoicePayments as paymentsTable,
6
+ customers as customersTable,
7
+ } from '@/db/schema';
8
+ import type { Invoice, InvoiceLineItem, InvoicePayment, InvoiceStatus, PaymentMethod } from './types';
9
+
10
+ interface InvoiceRow {
11
+ invoice: typeof invoicesTable.$inferSelect;
12
+ customer: typeof customersTable.$inferSelect | null;
13
+ }
14
+
15
+ function toInvoice(row: InvoiceRow, payments: (typeof paymentsTable.$inferSelect)[]): Invoice {
16
+ const i = row.invoice;
17
+ const lineItems = i.lineItems as InvoiceLineItem[];
18
+ const amountDue = Math.max(0, i.total - i.amountPaid);
19
+ const isOverdue =
20
+ i.dueDate !== null &&
21
+ amountDue > 0 &&
22
+ new Date(i.dueDate) < new Date() &&
23
+ (i.status === 'sent' || i.status === 'partial');
24
+ return {
25
+ id: i.id,
26
+ invoiceNumber: i.invoiceNumber,
27
+ customerId: i.customerId,
28
+ customerName: row.customer?.name ?? '(deleted)',
29
+ jobId: i.jobId ?? undefined,
30
+ estimateId: i.estimateId ?? undefined,
31
+ status: (isOverdue ? 'overdue' : i.status) as InvoiceStatus,
32
+ lineItems,
33
+ subtotal: i.subtotal,
34
+ total: i.total,
35
+ amountPaid: i.amountPaid,
36
+ amountDue,
37
+ dueDate: i.dueDate?.toISOString(),
38
+ sentAt: i.sentAt?.toISOString(),
39
+ paidAt: i.paidAt?.toISOString(),
40
+ notes: i.notes ?? undefined,
41
+ createdAt: i.createdAt.toISOString(),
42
+ payments: payments.map(
43
+ (p): InvoicePayment => ({
44
+ id: p.id,
45
+ amount: p.amount,
46
+ method: p.method as PaymentMethod,
47
+ recordedAt: p.recordedAt.toISOString(),
48
+ note: p.note ?? undefined,
49
+ }),
50
+ ),
51
+ };
52
+ }
53
+
54
+ export async function getInvoices(): Promise<Invoice[]> {
55
+ const rows = await db
56
+ .select({ invoice: invoicesTable, customer: customersTable })
57
+ .from(invoicesTable)
58
+ .leftJoin(customersTable, eq(invoicesTable.customerId, customersTable.id))
59
+ .orderBy(desc(invoicesTable.createdAt));
60
+ // List page doesn't need full payment history — pass empty for perf.
61
+ return rows.map((r) => toInvoice(r, []));
62
+ }
63
+
64
+ export async function getInvoice(id: string): Promise<Invoice | null> {
65
+ const [row] = await db
66
+ .select({ invoice: invoicesTable, customer: customersTable })
67
+ .from(invoicesTable)
68
+ .leftJoin(customersTable, eq(invoicesTable.customerId, customersTable.id))
69
+ .where(eq(invoicesTable.id, id))
70
+ .limit(1);
71
+ if (!row) return null;
72
+ const payments = await db
73
+ .select()
74
+ .from(paymentsTable)
75
+ .where(eq(paymentsTable.invoiceId, id))
76
+ .orderBy(desc(paymentsTable.recordedAt));
77
+ return toInvoice(row, payments);
78
+ }
79
+
80
+ /**
81
+ * Public-page lookup keyed by the random per-invoice publicToken.
82
+ * The token is the security boundary: anyone with the token can VIEW
83
+ * and PAY the invoice. No login required.
84
+ */
85
+ export async function getInvoiceByPublicToken(token: string): Promise<Invoice | null> {
86
+ const [row] = await db
87
+ .select({ invoice: invoicesTable, customer: customersTable })
88
+ .from(invoicesTable)
89
+ .leftJoin(customersTable, eq(invoicesTable.customerId, customersTable.id))
90
+ .where(eq(invoicesTable.publicToken, token))
91
+ .limit(1);
92
+ if (!row) return null;
93
+ const payments = await db
94
+ .select()
95
+ .from(paymentsTable)
96
+ .where(eq(paymentsTable.invoiceId, row.invoice.id))
97
+ .orderBy(desc(paymentsTable.recordedAt));
98
+ return toInvoice(row, payments);
99
+ }
@@ -0,0 +1,102 @@
1
+ 'use server';
2
+
3
+ import { headers } from 'next/headers';
4
+ import { eq } from 'drizzle-orm';
5
+ import { revalidatePath } from 'next/cache';
6
+ import { db } from '@/db/client';
7
+ import { invoices, customers } from '@/db/schema';
8
+ import { business } from '@/lib/business';
9
+
10
+ /**
11
+ * Sends an invoice email via Resend's REST API. We hit the API directly
12
+ * (no SDK dep) so this works whether or not the comms-email module is
13
+ * installed.
14
+ *
15
+ * Requires RESEND_API_KEY and EMAIL_FROM env vars. Errors surface to the
16
+ * UI via the calling component's error state.
17
+ */
18
+ export async function sendInvoiceEmail(invoiceId: string): Promise<{ ok: true; messageId: string }> {
19
+ const apiKey = process.env.RESEND_API_KEY;
20
+ const from = process.env.EMAIL_FROM;
21
+ if (!apiKey || !from) {
22
+ throw new Error(
23
+ 'Resend not configured. Set RESEND_API_KEY and EMAIL_FROM in .env.local.',
24
+ );
25
+ }
26
+
27
+ const [row] = await db
28
+ .select({ invoice: invoices, customerEmails: customers.emails, customerName: customers.name })
29
+ .from(invoices)
30
+ .leftJoin(customers, eq(invoices.customerId, customers.id))
31
+ .where(eq(invoices.id, invoiceId))
32
+ .limit(1);
33
+ if (!row) throw new Error('Invoice not found');
34
+ const customerEmail = Array.isArray(row.customerEmails) ? row.customerEmails[0] : null;
35
+ if (!customerEmail) throw new Error('Customer has no email on file');
36
+
37
+ const h = await headers();
38
+ const host = h.get('host') ?? 'localhost:3000';
39
+ const proto = h.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https');
40
+ const publicUrl = `${proto}://${host}/i/${row.invoice.publicToken}`;
41
+ const amountDue = Math.max(0, row.invoice.total - row.invoice.amountPaid);
42
+ const amountFmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
43
+ amountDue / 100,
44
+ );
45
+
46
+ const subject = `Invoice ${row.invoice.invoiceNumber} from ${business.name}`;
47
+ const html = `
48
+ <div style="font-family: ui-sans-serif, system-ui, sans-serif; max-width: 520px; margin: 0 auto; padding: 24px;">
49
+ <h1 style="font-size: 20px; margin: 0 0 8px;">${escapeHtml(business.name)}</h1>
50
+ <p style="color: #6b7280; margin: 0 0 24px;">Invoice <strong>${escapeHtml(row.invoice.invoiceNumber)}</strong></p>
51
+ <p>Hi ${escapeHtml(row.customerName ?? 'there')},</p>
52
+ <p>Your invoice for ${amountFmt} is ready. Click below to view details or pay online — it takes about 30 seconds.</p>
53
+ <p style="margin: 24px 0;">
54
+ <a href="${publicUrl}" style="display: inline-block; background: hsl(160 84% 39%); color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 600;">View & pay ${amountFmt}</a>
55
+ </p>
56
+ <p style="color: #6b7280; font-size: 13px;">Or copy this link: <br><a href="${publicUrl}">${publicUrl}</a></p>
57
+ ${business.phone ? `<p style="color: #6b7280; font-size: 13px;">Questions? Call us at <a href="tel:${business.phone}">${business.phone}</a>.</p>` : ''}
58
+ <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 32px 0;">
59
+ <p style="color: #9ca3af; font-size: 11px;">${escapeHtml(business.legalName)}${business.address ? ` · ${escapeHtml(business.address)}` : ''}</p>
60
+ </div>
61
+ `;
62
+
63
+ const resp = await fetch('https://api.resend.com/emails', {
64
+ method: 'POST',
65
+ headers: {
66
+ Authorization: `Bearer ${apiKey}`,
67
+ 'Content-Type': 'application/json',
68
+ },
69
+ body: JSON.stringify({
70
+ from,
71
+ to: customerEmail,
72
+ subject,
73
+ html,
74
+ }),
75
+ });
76
+
77
+ if (!resp.ok) {
78
+ const errBody = await resp.text();
79
+ throw new Error(`Resend API ${resp.status}: ${errBody}`);
80
+ }
81
+ const result = (await resp.json()) as { id?: string };
82
+
83
+ // Mark the invoice as sent if it was still in draft.
84
+ if (row.invoice.status === 'draft') {
85
+ await db
86
+ .update(invoices)
87
+ .set({ status: 'sent', sentAt: new Date(), updatedAt: new Date() })
88
+ .where(eq(invoices.id, invoiceId));
89
+ }
90
+
91
+ revalidatePath(`/invoices/${invoiceId}`);
92
+ return { ok: true, messageId: result.id ?? '' };
93
+ }
94
+
95
+ function escapeHtml(s: string): string {
96
+ return s
97
+ .replace(/&/g, '&amp;')
98
+ .replace(/</g, '&lt;')
99
+ .replace(/>/g, '&gt;')
100
+ .replace(/"/g, '&quot;')
101
+ .replace(/'/g, '&#39;');
102
+ }
@@ -0,0 +1,60 @@
1
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
2
+ import { Badge } from '@/components/ui/badge';
3
+
4
+ const CHECKLISTS = [
5
+ {
6
+ id: 'cl_panel',
7
+ name: 'Panel upgrade inspection',
8
+ items: [
9
+ 'Main breaker rating verified',
10
+ 'Grounding electrode bonded',
11
+ 'Service entrance conductors sized correctly',
12
+ 'Working clearance meets code',
13
+ 'GFCI/AFCI protection installed',
14
+ 'Panel directory labeled',
15
+ 'Permit number posted on-site',
16
+ ],
17
+ },
18
+ {
19
+ id: 'cl_outlet',
20
+ name: 'Outlet/circuit inspection',
21
+ items: [
22
+ 'GFCI tested with built-in test button',
23
+ 'Polarity verified',
24
+ 'Junction box covers in place',
25
+ 'Wire gauge matches breaker rating',
26
+ 'No double-tapped breakers',
27
+ ],
28
+ },
29
+ ];
30
+
31
+ export default function InspectionsPage() {
32
+ return (
33
+ <div className="space-y-6">
34
+ <div>
35
+ <h1 className="text-3xl font-bold tracking-tight">Inspection checklists</h1>
36
+ <p className="text-muted-foreground mt-1 text-sm">
37
+ Pre-built lists technicians complete in the field. Customize per service type.
38
+ </p>
39
+ </div>
40
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
41
+ {CHECKLISTS.map((cl) => (
42
+ <Card key={cl.id}>
43
+ <CardHeader className="flex flex-row items-center justify-between space-y-0">
44
+ <CardTitle className="text-base">{cl.name}</CardTitle>
45
+ <Badge variant="outline">{cl.items.length} items</Badge>
46
+ </CardHeader>
47
+ <CardContent className="space-y-2">
48
+ {cl.items.map((item, i) => (
49
+ <div key={i} className="flex items-start gap-2 text-sm">
50
+ <input type="checkbox" disabled className="mt-1" />
51
+ <span>{item}</span>
52
+ </div>
53
+ ))}
54
+ </CardContent>
55
+ </Card>
56
+ ))}
57
+ </div>
58
+ </div>
59
+ );
60
+ }