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,87 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { NextResponse } from 'next/server';
3
+ import type Stripe from 'stripe';
4
+ import { stripe } from '@/lib/stripe';
5
+ import { recordInvoicePayment } from '@/lib/invoices/actions';
6
+
7
+ /**
8
+ * Stripe webhook endpoint with invoice reconciliation.
9
+ *
10
+ * This file is the estimates-invoices module's upgrade of the
11
+ * payments-stripe webhook stub: it dispatches checkout.session.completed
12
+ * events to the invoices.recordInvoicePayment server action so paid
13
+ * invoices automatically transition to 'paid' status with the payment row.
14
+ *
15
+ * Set STRIPE_WEBHOOK_SECRET in .env.local. For local dev:
16
+ * stripe listen --forward-to localhost:3000/api/stripe/webhook
17
+ */
18
+ export async function POST(req: NextRequest) {
19
+ const secret = process.env.STRIPE_WEBHOOK_SECRET;
20
+ if (!secret) {
21
+ return NextResponse.json({ error: 'STRIPE_WEBHOOK_SECRET not set' }, { status: 500 });
22
+ }
23
+
24
+ const signature = req.headers.get('stripe-signature');
25
+ if (!signature) {
26
+ return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
27
+ }
28
+
29
+ const body = await req.text();
30
+ let event: Stripe.Event;
31
+ try {
32
+ event = stripe.webhooks.constructEvent(body, signature, secret);
33
+ } catch (err) {
34
+ const message = err instanceof Error ? err.message : 'Signature verification failed';
35
+ return NextResponse.json({ error: message }, { status: 400 });
36
+ }
37
+
38
+ switch (event.type) {
39
+ case 'checkout.session.completed': {
40
+ const session = event.data.object as Stripe.Checkout.Session;
41
+ const invoiceId = session.metadata?.invoiceId;
42
+ const amount = session.amount_total;
43
+ const paymentIntentId =
44
+ typeof session.payment_intent === 'string' ? session.payment_intent : null;
45
+
46
+ if (invoiceId && amount && amount > 0) {
47
+ try {
48
+ await recordInvoicePayment({
49
+ invoiceId,
50
+ amount,
51
+ method: 'stripe',
52
+ stripeSessionId: session.id,
53
+ stripePaymentIntentId: paymentIntentId ?? undefined,
54
+ note: 'Stripe Checkout',
55
+ });
56
+ console.log(
57
+ `[stripe] reconciled invoice ${invoiceId} +${amount}¢ (session ${session.id})`,
58
+ );
59
+ } catch (err) {
60
+ console.error(`[stripe] failed to record payment for invoice ${invoiceId}:`, err);
61
+ // Return 500 so Stripe retries the webhook.
62
+ return NextResponse.json({ error: 'payment recording failed' }, { status: 500 });
63
+ }
64
+ } else {
65
+ // No invoiceId in metadata — likely a demo checkout or non-invoice
66
+ // payment. Log so we don't lose money silently.
67
+ console.log(
68
+ '[stripe] checkout.session.completed (no invoiceId metadata)',
69
+ session.id,
70
+ session.amount_total,
71
+ session.metadata,
72
+ );
73
+ }
74
+ break;
75
+ }
76
+ case 'payment_intent.succeeded':
77
+ case 'payment_intent.payment_failed':
78
+ // Stripe Checkout's session.completed event covers the success path;
79
+ // these are useful if you take payments via PaymentIntents directly.
80
+ break;
81
+ default:
82
+ // Acknowledge unhandled events so Stripe doesn't keep retrying them.
83
+ break;
84
+ }
85
+
86
+ return NextResponse.json({ received: true });
87
+ }
@@ -0,0 +1,148 @@
1
+ import { notFound } from 'next/navigation';
2
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
3
+ import { Badge } from '@/components/ui/badge';
4
+ import { business } from '@/lib/business';
5
+ import { getInvoiceByPublicToken } from '@/lib/invoices/data';
6
+ import { INVOICE_STATUS_LABEL, INVOICE_STATUS_VARIANT } from '@/lib/invoices/types';
7
+ import { PublicPayButton } from '@/components/invoices/public-pay-button';
8
+ import { formatCurrency, formatDate } from '@/lib/utils';
9
+
10
+ interface PageProps { params: Promise<{ token: string }>; }
11
+
12
+ export default async function PublicInvoicePage({ params }: PageProps) {
13
+ const { token } = await params;
14
+ const invoice = await getInvoiceByPublicToken(token);
15
+ if (!invoice) notFound();
16
+
17
+ const isPaid = invoice.amountDue <= 0;
18
+ const overdueDays =
19
+ invoice.dueDate && invoice.amountDue > 0 && new Date(invoice.dueDate) < new Date()
20
+ ? Math.floor((Date.now() - new Date(invoice.dueDate).getTime()) / 86_400_000)
21
+ : 0;
22
+
23
+ return (
24
+ <main className="bg-muted/30 min-h-screen">
25
+ <header className="bg-background border-border border-b">
26
+ <div className="mx-auto max-w-2xl px-6 py-6">
27
+ <div className="flex items-center gap-3">
28
+ {business.brand.logoSrc && (
29
+ // eslint-disable-next-line @next/next/no-img-element
30
+ <img src={business.brand.logoSrc} alt={business.name} className="h-10 w-auto" />
31
+ )}
32
+ <div>
33
+ <h1 className="text-xl font-bold">{business.name}</h1>
34
+ {business.phone && (
35
+ <p className="text-muted-foreground text-sm">
36
+ Questions? <a href={`tel:${business.phone}`} className="hover:underline">{business.phone}</a>
37
+ </p>
38
+ )}
39
+ </div>
40
+ </div>
41
+ </div>
42
+ </header>
43
+ <div className="mx-auto max-w-2xl space-y-6 px-6 py-8">
44
+ <div className="flex items-center justify-between">
45
+ <div>
46
+ <p className="text-muted-foreground text-sm">Invoice</p>
47
+ <h2 className="font-mono text-2xl font-bold">{invoice.invoiceNumber}</h2>
48
+ </div>
49
+ <Badge variant={INVOICE_STATUS_VARIANT[invoice.status]} className="text-base">
50
+ {INVOICE_STATUS_LABEL[invoice.status]}
51
+ </Badge>
52
+ </div>
53
+
54
+ <Card>
55
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
56
+ <CardTitle className="text-base">Amount due</CardTitle>
57
+ <span className="text-3xl font-bold">{formatCurrency(invoice.amountDue)}</span>
58
+ </CardHeader>
59
+ <CardContent className="pt-0">
60
+ <div className="text-muted-foreground text-sm">
61
+ of {formatCurrency(invoice.total)} total
62
+ {invoice.amountPaid > 0 && (
63
+ <span className="text-emerald-600 ml-2">
64
+ · {formatCurrency(invoice.amountPaid)} paid
65
+ </span>
66
+ )}
67
+ </div>
68
+ {overdueDays > 0 && (
69
+ <div className="text-destructive mt-2 text-sm font-bold">
70
+ {overdueDays} day{overdueDays === 1 ? '' : 's'} overdue
71
+ </div>
72
+ )}
73
+ {invoice.dueDate && !isPaid && (
74
+ <div className="text-muted-foreground mt-2 text-xs">
75
+ Due {formatDate(invoice.dueDate)}
76
+ </div>
77
+ )}
78
+ {!isPaid && (
79
+ <div className="mt-4">
80
+ <PublicPayButton token={token} amountDue={invoice.amountDue} />
81
+ </div>
82
+ )}
83
+ {isPaid && (
84
+ <p className="text-emerald-600 mt-3 text-sm font-medium">✓ Thank you — this invoice is paid in full.</p>
85
+ )}
86
+ </CardContent>
87
+ </Card>
88
+
89
+ <Card>
90
+ <CardHeader><CardTitle>Line items</CardTitle></CardHeader>
91
+ <CardContent className="p-0">
92
+ <table className="w-full text-sm">
93
+ <thead className="text-muted-foreground bg-muted/50 text-xs uppercase">
94
+ <tr>
95
+ <th className="px-4 py-3 text-left font-medium">Description</th>
96
+ <th className="px-4 py-3 text-right font-medium">Qty</th>
97
+ <th className="px-4 py-3 text-right font-medium">Unit price</th>
98
+ <th className="px-4 py-3 text-right font-medium">Subtotal</th>
99
+ </tr>
100
+ </thead>
101
+ <tbody className="divide-border divide-y">
102
+ {invoice.lineItems.map((li, i) => (
103
+ <tr key={i}>
104
+ <td className="px-4 py-3">{li.description}</td>
105
+ <td className="px-4 py-3 text-right">{li.qty}</td>
106
+ <td className="px-4 py-3 text-right">{formatCurrency(li.unitPrice)}</td>
107
+ <td className="px-4 py-3 text-right font-medium">{formatCurrency(li.qty * li.unitPrice)}</td>
108
+ </tr>
109
+ ))}
110
+ </tbody>
111
+ <tfoot className="bg-muted/30">
112
+ <tr>
113
+ <td colSpan={3} className="px-4 py-3 text-right text-muted-foreground">Total</td>
114
+ <td className="px-4 py-3 text-right font-bold">{formatCurrency(invoice.total)}</td>
115
+ </tr>
116
+ </tfoot>
117
+ </table>
118
+ </CardContent>
119
+ </Card>
120
+
121
+ {invoice.payments.length > 0 && (
122
+ <Card>
123
+ <CardHeader><CardTitle>Payments</CardTitle></CardHeader>
124
+ <CardContent className="text-sm space-y-2">
125
+ {invoice.payments.map((p) => (
126
+ <div key={p.id} className="flex justify-between border-b pb-2 last:border-b-0">
127
+ <span className="text-muted-foreground">{formatDate(p.recordedAt)} · {p.method}</span>
128
+ <span className="font-medium">{formatCurrency(p.amount)}</span>
129
+ </div>
130
+ ))}
131
+ </CardContent>
132
+ </Card>
133
+ )}
134
+
135
+ {invoice.notes && (
136
+ <Card>
137
+ <CardHeader><CardTitle>Notes</CardTitle></CardHeader>
138
+ <CardContent className="text-sm whitespace-pre-wrap">{invoice.notes}</CardContent>
139
+ </Card>
140
+ )}
141
+
142
+ <footer className="text-muted-foreground py-4 text-center text-xs">
143
+ {business.legalName} · {business.address || ''}
144
+ </footer>
145
+ </div>
146
+ </main>
147
+ );
148
+ }
@@ -0,0 +1,18 @@
1
+ 'use client';
2
+
3
+ import { useTransition } from 'react';
4
+ import { Button } from '@/components/ui/button';
5
+ import { convertEstimateToJob } from '@/lib/estimates/actions';
6
+
7
+ export function ConvertToJobButton({ estimateId }: { estimateId: string }) {
8
+ const [pending, start] = useTransition();
9
+ return (
10
+ <Button
11
+ size="sm"
12
+ disabled={pending}
13
+ onClick={() => start(() => convertEstimateToJob(estimateId))}
14
+ >
15
+ {pending ? 'Converting...' : '→ Convert to job'}
16
+ </Button>
17
+ );
18
+ }
@@ -0,0 +1,261 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useState, useTransition } from 'react';
4
+ import Link from 'next/link';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
7
+ import { Input } from '@/components/ui/input';
8
+ import { Label } from '@/components/ui/label';
9
+ import { createEstimate } from '@/lib/estimates/actions';
10
+ import { ESTIMATE_STATUSES, ESTIMATE_STATUS_LABEL, type EstimateLineItem, type EstimateStatus } from '@/lib/estimates/types';
11
+ import { PriceBookItemPicker } from '@/components/price-book/item-picker';
12
+ import { formatCurrency } from '@/lib/utils';
13
+
14
+ interface CustomerOption {
15
+ id: string;
16
+ name: string;
17
+ }
18
+
19
+ interface DraftLine extends EstimateLineItem {
20
+ key: string;
21
+ }
22
+
23
+ const NEW_LINE = (): DraftLine => ({
24
+ key: crypto.randomUUID(),
25
+ description: '',
26
+ qty: 1,
27
+ unitPrice: 0,
28
+ unitCost: 0,
29
+ taxable: true,
30
+ });
31
+
32
+ export function NewEstimateForm({ customers }: { customers: CustomerOption[] }) {
33
+ const [pending, start] = useTransition();
34
+ const [error, setError] = useState<string | null>(null);
35
+
36
+ const [customerId, setCustomerId] = useState(customers[0]?.id ?? '');
37
+ const [status, setStatus] = useState<EstimateStatus>('draft');
38
+ const [validUntil, setValidUntil] = useState(() => {
39
+ // 30 days from now, formatted as YYYY-MM-DD for <input type="date">
40
+ const d = new Date();
41
+ d.setDate(d.getDate() + 30);
42
+ return d.toISOString().slice(0, 10);
43
+ });
44
+ const [notes, setNotes] = useState('');
45
+ const [lines, setLines] = useState<DraftLine[]>([NEW_LINE()]);
46
+
47
+ const totals = useMemo(() => {
48
+ const subtotal = lines.reduce((acc, li) => acc + li.qty * li.unitPrice, 0);
49
+ const totalCost = lines.reduce((acc, li) => acc + li.qty * (li.unitCost ?? 0), 0);
50
+ const margin = subtotal - totalCost;
51
+ const marginPct = subtotal > 0 ? Math.round((margin / subtotal) * 100) : 0;
52
+ return { subtotal, totalCost, margin, marginPct };
53
+ }, [lines]);
54
+
55
+ function updateLine(key: string, patch: Partial<EstimateLineItem>) {
56
+ setLines((prev) => prev.map((li) => (li.key === key ? { ...li, ...patch } : li)));
57
+ }
58
+ function removeLine(key: string) {
59
+ setLines((prev) => (prev.length === 1 ? prev : prev.filter((li) => li.key !== key)));
60
+ }
61
+ function addLine() {
62
+ setLines((prev) => [...prev, NEW_LINE()]);
63
+ }
64
+
65
+ function handleSubmit(e: React.FormEvent) {
66
+ e.preventDefault();
67
+ setError(null);
68
+ if (!customerId) {
69
+ setError('Pick a customer (create one first if none exist).');
70
+ return;
71
+ }
72
+ start(() =>
73
+ createEstimate({
74
+ customerId,
75
+ status,
76
+ validUntil: validUntil ? new Date(validUntil + 'T23:59:59').toISOString() : null,
77
+ notes: notes || null,
78
+ lineItems: lines
79
+ .filter((li) => li.description.trim() !== '')
80
+ .map(({ key: _key, ...rest }) => rest),
81
+ }),
82
+ );
83
+ }
84
+
85
+ const marginColor =
86
+ totals.marginPct < 0
87
+ ? 'text-destructive'
88
+ : totals.marginPct < 25
89
+ ? 'text-yellow-600'
90
+ : 'text-emerald-600';
91
+
92
+ return (
93
+ <form onSubmit={handleSubmit} className="space-y-6">
94
+ <Card>
95
+ <CardHeader>
96
+ <CardTitle>Customer & options</CardTitle>
97
+ <CardDescription>Pick a customer and set the estimate-wide options.</CardDescription>
98
+ </CardHeader>
99
+ <CardContent className="space-y-4">
100
+ <div className="space-y-2">
101
+ <Label htmlFor="customerId">Customer *</Label>
102
+ {customers.length === 0 ? (
103
+ <p className="text-destructive text-sm">
104
+ No customers yet. <Link href="/customers/new" className="underline">Create one</Link> first.
105
+ </p>
106
+ ) : (
107
+ <select
108
+ id="customerId"
109
+ required
110
+ value={customerId}
111
+ onChange={(e) => setCustomerId(e.target.value)}
112
+ className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm"
113
+ >
114
+ {customers.map((c) => (
115
+ <option key={c.id} value={c.id}>{c.name}</option>
116
+ ))}
117
+ </select>
118
+ )}
119
+ </div>
120
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
121
+ <div className="space-y-2">
122
+ <Label htmlFor="status">Initial status</Label>
123
+ <select
124
+ id="status"
125
+ value={status}
126
+ onChange={(e) => setStatus(e.target.value as EstimateStatus)}
127
+ className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm"
128
+ >
129
+ {ESTIMATE_STATUSES.filter((s) => !['converted', 'expired'].includes(s)).map((s) => (
130
+ <option key={s} value={s}>{ESTIMATE_STATUS_LABEL[s]}</option>
131
+ ))}
132
+ </select>
133
+ </div>
134
+ <div className="space-y-2">
135
+ <Label htmlFor="validUntil">Valid until</Label>
136
+ <Input
137
+ id="validUntil"
138
+ type="date"
139
+ value={validUntil}
140
+ onChange={(e) => setValidUntil(e.target.value)}
141
+ />
142
+ </div>
143
+ </div>
144
+ </CardContent>
145
+ </Card>
146
+
147
+ <Card>
148
+ <CardHeader className="flex flex-row items-start justify-between space-y-0">
149
+ <div>
150
+ <CardTitle>Line items</CardTitle>
151
+ <CardDescription>
152
+ Prices + costs in cents. e.g. <code>12500</code> = $125.00.
153
+ Unit cost drives margin tracking — leave 0 to skip.
154
+ </CardDescription>
155
+ </div>
156
+ <div className="text-right">
157
+ <div className="text-muted-foreground text-xs uppercase">Total</div>
158
+ <div className="text-2xl font-bold">{formatCurrency(totals.subtotal)}</div>
159
+ <div className={`text-xs font-medium ${marginColor}`}>
160
+ Margin: {formatCurrency(totals.margin)} ({totals.marginPct}%)
161
+ </div>
162
+ </div>
163
+ </CardHeader>
164
+ <CardContent className="space-y-3">
165
+ {lines.map((li) => (
166
+ <div key={li.key} className="grid grid-cols-[1fr_3.5rem_5.5rem_5.5rem_2.5rem] items-end gap-2">
167
+ <div className="space-y-1">
168
+ <Label htmlFor={`desc-${li.key}`} className="text-xs">Description</Label>
169
+ <PriceBookItemPicker
170
+ id={`desc-${li.key}`}
171
+ value={li.description}
172
+ onTextChange={(v) => updateLine(li.key, { description: v })}
173
+ onPick={(item) =>
174
+ updateLine(li.key, {
175
+ description: item.name,
176
+ qty: item.defaultQty,
177
+ unitPrice: item.unitPrice,
178
+ unitCost: item.unitCost,
179
+ taxable: item.taxable,
180
+ })
181
+ }
182
+ placeholder="Search Price Book or type free-text…"
183
+ />
184
+ </div>
185
+ <div className="space-y-1">
186
+ <Label htmlFor={`qty-${li.key}`} className="text-xs">Qty</Label>
187
+ <Input
188
+ id={`qty-${li.key}`}
189
+ type="number"
190
+ min={0}
191
+ step={1}
192
+ value={li.qty}
193
+ onChange={(e) => updateLine(li.key, { qty: Number(e.target.value) || 0 })}
194
+ />
195
+ </div>
196
+ <div className="space-y-1">
197
+ <Label htmlFor={`cost-${li.key}`} className="text-xs">Unit cost (¢)</Label>
198
+ <Input
199
+ id={`cost-${li.key}`}
200
+ type="number"
201
+ min={0}
202
+ step={1}
203
+ value={li.unitCost ?? 0}
204
+ onChange={(e) => updateLine(li.key, { unitCost: Number(e.target.value) || 0 })}
205
+ />
206
+ </div>
207
+ <div className="space-y-1">
208
+ <Label htmlFor={`price-${li.key}`} className="text-xs">Unit price (¢)</Label>
209
+ <Input
210
+ id={`price-${li.key}`}
211
+ type="number"
212
+ min={0}
213
+ step={1}
214
+ value={li.unitPrice}
215
+ onChange={(e) => updateLine(li.key, { unitPrice: Number(e.target.value) || 0 })}
216
+ />
217
+ </div>
218
+ <Button
219
+ type="button"
220
+ variant="ghost"
221
+ size="icon"
222
+ onClick={() => removeLine(li.key)}
223
+ disabled={lines.length === 1}
224
+ aria-label="Remove line"
225
+ >
226
+
227
+ </Button>
228
+ </div>
229
+ ))}
230
+ <Button type="button" variant="outline" size="sm" onClick={addLine}>
231
+ + Add line item
232
+ </Button>
233
+ </CardContent>
234
+ </Card>
235
+
236
+ <Card>
237
+ <CardHeader><CardTitle>Customer-facing notes</CardTitle></CardHeader>
238
+ <CardContent>
239
+ <textarea
240
+ rows={3}
241
+ value={notes}
242
+ onChange={(e) => setNotes(e.target.value)}
243
+ placeholder="Anything the customer should see — scope, exclusions, warranty terms..."
244
+ className="border-input bg-background focus-visible:ring-ring flex w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2"
245
+ />
246
+ </CardContent>
247
+ </Card>
248
+
249
+ {error && <p className="text-destructive text-sm">{error}</p>}
250
+
251
+ <div className="flex justify-end gap-3">
252
+ <Button type="button" variant="outline" asChild>
253
+ <Link href="/estimates">Cancel</Link>
254
+ </Button>
255
+ <Button type="submit" disabled={pending || customers.length === 0}>
256
+ {pending ? 'Creating...' : 'Create estimate'}
257
+ </Button>
258
+ </div>
259
+ </form>
260
+ );
261
+ }
@@ -0,0 +1,19 @@
1
+ 'use client';
2
+
3
+ import { useTransition } from 'react';
4
+ import { Button } from '@/components/ui/button';
5
+ import { createCheckoutForInvoice } from '@/lib/invoices/actions';
6
+ import { formatCurrency } from '@/lib/utils';
7
+
8
+ export function PayInvoiceButton({ invoiceId, amountDue }: { invoiceId: string; amountDue: number }) {
9
+ const [pending, start] = useTransition();
10
+ return (
11
+ <Button
12
+ size="sm"
13
+ disabled={pending || amountDue <= 0}
14
+ onClick={() => start(() => createCheckoutForInvoice(invoiceId))}
15
+ >
16
+ {pending ? 'Redirecting...' : `Pay ${formatCurrency(amountDue)}`}
17
+ </Button>
18
+ );
19
+ }
@@ -0,0 +1,20 @@
1
+ 'use client';
2
+
3
+ import { useTransition } from 'react';
4
+ import { Button } from '@/components/ui/button';
5
+ import { payByPublicToken } from '@/lib/invoices/actions';
6
+ import { formatCurrency } from '@/lib/utils';
7
+
8
+ export function PublicPayButton({ token, amountDue }: { token: string; amountDue: number }) {
9
+ const [pending, start] = useTransition();
10
+ return (
11
+ <Button
12
+ size="lg"
13
+ className="w-full"
14
+ disabled={pending || amountDue <= 0}
15
+ onClick={() => start(() => payByPublicToken(token))}
16
+ >
17
+ {pending ? 'Redirecting to Stripe...' : `Pay ${formatCurrency(amountDue)}`}
18
+ </Button>
19
+ );
20
+ }
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+
3
+ import { useState, useTransition } from 'react';
4
+ import { Button } from '@/components/ui/button';
5
+ import { sendInvoiceEmail } from '@/lib/invoices/email-actions';
6
+
7
+ export function SendInvoiceEmailButton({ invoiceId }: { invoiceId: string }) {
8
+ const [pending, start] = useTransition();
9
+ const [status, setStatus] = useState<'idle' | 'sent' | 'error'>('idle');
10
+ const [errorMsg, setErrorMsg] = useState<string | null>(null);
11
+
12
+ return (
13
+ <div className="flex flex-col items-end gap-1">
14
+ <Button
15
+ size="sm"
16
+ variant="outline"
17
+ disabled={pending}
18
+ onClick={() =>
19
+ start(async () => {
20
+ setStatus('idle');
21
+ setErrorMsg(null);
22
+ try {
23
+ await sendInvoiceEmail(invoiceId);
24
+ setStatus('sent');
25
+ } catch (err) {
26
+ setStatus('error');
27
+ setErrorMsg((err as Error).message);
28
+ }
29
+ })
30
+ }
31
+ >
32
+ {pending ? 'Sending…' : status === 'sent' ? '✓ Email sent' : 'Email invoice'}
33
+ </Button>
34
+ {errorMsg && <p className="text-destructive text-xs max-w-xs text-right">{errorMsg}</p>}
35
+ </div>
36
+ );
37
+ }
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+
3
+ import { useTransition } from 'react';
4
+ import { Button } from '@/components/ui/button';
5
+ import { createInvoiceFromJob } from '@/lib/invoices/actions';
6
+
7
+ /**
8
+ * Renders only when the job is in a finishable state. Server action
9
+ * generates an invoice from the job's line items and redirects to it.
10
+ */
11
+ export function GenerateInvoiceButton({ jobId }: { jobId: string }) {
12
+ const [pending, start] = useTransition();
13
+ return (
14
+ <Button
15
+ size="sm"
16
+ variant="outline"
17
+ disabled={pending}
18
+ onClick={() => start(() => createInvoiceFromJob({ jobId }))}
19
+ >
20
+ {pending ? 'Generating...' : 'Generate invoice'}
21
+ </Button>
22
+ );
23
+ }
@@ -0,0 +1,41 @@
1
+ import { integer, jsonb, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
2
+ import { customers } from './customers';
3
+
4
+ export const estimateStatus = pgEnum('estimate_status', [
5
+ 'draft', 'sent', 'viewed', 'approved', 'declined', 'expired', 'converted',
6
+ ]);
7
+
8
+ interface LineItemJson {
9
+ description: string;
10
+ qty: number;
11
+ unitPrice: number; // cents
12
+ unitCost?: number; // cents (for margin)
13
+ taxable?: boolean;
14
+ }
15
+
16
+ export const estimates = pgTable('estimates', {
17
+ id: uuid('id').primaryKey().defaultRandom(),
18
+ customerId: uuid('customer_id').notNull().references(() => customers.id, { onDelete: 'restrict' }),
19
+ status: estimateStatus('status').notNull().default('draft'),
20
+ lineItems: jsonb('line_items').$type<LineItemJson[]>().notNull().default([]),
21
+ subtotal: integer('subtotal').notNull().default(0), // cents
22
+ total: integer('total').notNull().default(0), // cents (subtotal +/- adjustments)
23
+ totalCost: integer('total_cost').notNull().default(0), // cents
24
+ notes: text('notes'),
25
+ validUntil: timestamp('valid_until'),
26
+ sentAt: timestamp('sent_at'),
27
+ approvedAt: timestamp('approved_at'),
28
+ /** Customer-typed full name captured at approval as legal acknowledgment. */
29
+ approvedSignerName: text('approved_signer_name'),
30
+ /** Drawn signature as data: URL (PNG). For real production, swap to R2 storage. */
31
+ approvedSignatureDataUrl: text('approved_signature_data_url'),
32
+ /** Optional decline reason supplied by the customer. */
33
+ declinedReason: text('declined_reason'),
34
+ declinedAt: timestamp('declined_at'),
35
+ convertedJobId: uuid('converted_job_id'),
36
+ createdAt: timestamp('created_at').notNull().defaultNow(),
37
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
38
+ });
39
+
40
+ export type EstimateRow = typeof estimates.$inferSelect;
41
+ export type NewEstimate = typeof estimates.$inferInsert;