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,275 @@
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 { createJob, type CreateJobInput } from '@/lib/jobs/actions';
10
+ import { JOB_STATUSES, JOB_STATUS_LABEL, type JobStatus, type LineItem } from '@/lib/jobs/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 DraftLineItem extends LineItem {
20
+ key: string;
21
+ }
22
+
23
+ const NEW_LINE_ITEM = (): DraftLineItem => ({
24
+ key: crypto.randomUUID(),
25
+ description: '',
26
+ qty: 1,
27
+ unitPrice: 0,
28
+ });
29
+
30
+ export function NewJobForm({ customers }: { customers: CustomerOption[] }) {
31
+ const [pending, start] = useTransition();
32
+ const [error, setError] = useState<string | null>(null);
33
+
34
+ const [customerId, setCustomerId] = useState(customers[0]?.id ?? '');
35
+ const [serviceType, setServiceType] = useState('Service Call');
36
+ const [status, setStatus] = useState<JobStatus>('lead');
37
+ const [priority, setPriority] = useState<CreateJobInput['priority']>('normal');
38
+ const [scheduledAt, setScheduledAt] = useState('');
39
+ const [arrivalWindow, setArrivalWindow] = useState('');
40
+ const [notes, setNotes] = useState('');
41
+ const [lineItems, setLineItems] = useState<DraftLineItem[]>([NEW_LINE_ITEM()]);
42
+
43
+ const total = useMemo(
44
+ () => lineItems.reduce((acc, li) => acc + li.qty * li.unitPrice, 0),
45
+ [lineItems],
46
+ );
47
+
48
+ function updateLine(key: string, patch: Partial<LineItem>) {
49
+ setLineItems((prev) => prev.map((li) => (li.key === key ? { ...li, ...patch } : li)));
50
+ }
51
+
52
+ function removeLine(key: string) {
53
+ setLineItems((prev) => (prev.length === 1 ? prev : prev.filter((li) => li.key !== key)));
54
+ }
55
+
56
+ function addLine() {
57
+ setLineItems((prev) => [...prev, NEW_LINE_ITEM()]);
58
+ }
59
+
60
+ function handleSubmit(e: React.FormEvent) {
61
+ e.preventDefault();
62
+ setError(null);
63
+ if (!customerId) {
64
+ setError('Pick a customer (create one first if none exist).');
65
+ return;
66
+ }
67
+ start(() =>
68
+ createJob({
69
+ customerId,
70
+ serviceType,
71
+ status,
72
+ priority,
73
+ scheduledAt: scheduledAt ? new Date(scheduledAt).toISOString() : null,
74
+ arrivalWindow: arrivalWindow || null,
75
+ notes: notes || null,
76
+ lineItems: lineItems
77
+ .filter((li) => li.description.trim() !== '')
78
+ .map(({ key: _key, ...rest }) => rest),
79
+ }),
80
+ );
81
+ }
82
+
83
+ return (
84
+ <form onSubmit={handleSubmit} className="space-y-6">
85
+ <Card>
86
+ <CardHeader>
87
+ <CardTitle>Customer & service</CardTitle>
88
+ <CardDescription>What and for whom.</CardDescription>
89
+ </CardHeader>
90
+ <CardContent className="space-y-4">
91
+ <div className="space-y-2">
92
+ <Label htmlFor="customerId">Customer *</Label>
93
+ {customers.length === 0 ? (
94
+ <p className="text-destructive text-sm">
95
+ No customers yet. <Link href="/customers/new" className="underline">Create one</Link> first.
96
+ </p>
97
+ ) : (
98
+ <select
99
+ id="customerId"
100
+ name="customerId"
101
+ required
102
+ value={customerId}
103
+ onChange={(e) => setCustomerId(e.target.value)}
104
+ className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm"
105
+ >
106
+ {customers.map((c) => (
107
+ <option key={c.id} value={c.id}>{c.name}</option>
108
+ ))}
109
+ </select>
110
+ )}
111
+ </div>
112
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
113
+ <div className="space-y-2">
114
+ <Label htmlFor="serviceType">Service type *</Label>
115
+ <Input
116
+ id="serviceType"
117
+ required
118
+ value={serviceType}
119
+ onChange={(e) => setServiceType(e.target.value)}
120
+ />
121
+ </div>
122
+ <div className="space-y-2">
123
+ <Label htmlFor="priority">Priority</Label>
124
+ <select
125
+ id="priority"
126
+ value={priority}
127
+ onChange={(e) => setPriority(e.target.value as typeof priority)}
128
+ className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm"
129
+ >
130
+ <option value="low">Low</option>
131
+ <option value="normal">Normal</option>
132
+ <option value="high">High</option>
133
+ <option value="emergency">Emergency</option>
134
+ </select>
135
+ </div>
136
+ </div>
137
+ <div className="space-y-2">
138
+ <Label htmlFor="status">Status</Label>
139
+ <select
140
+ id="status"
141
+ value={status}
142
+ onChange={(e) => setStatus(e.target.value as JobStatus)}
143
+ className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm"
144
+ >
145
+ {JOB_STATUSES.filter((s) => s !== 'cancelled').map((s) => (
146
+ <option key={s} value={s}>{JOB_STATUS_LABEL[s]}</option>
147
+ ))}
148
+ </select>
149
+ </div>
150
+ </CardContent>
151
+ </Card>
152
+
153
+ <Card>
154
+ <CardHeader>
155
+ <CardTitle>Schedule</CardTitle>
156
+ <CardDescription>Optional — leave blank to schedule later.</CardDescription>
157
+ </CardHeader>
158
+ <CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2">
159
+ <div className="space-y-2">
160
+ <Label htmlFor="scheduledAt">Date</Label>
161
+ <Input
162
+ id="scheduledAt"
163
+ type="datetime-local"
164
+ value={scheduledAt}
165
+ onChange={(e) => setScheduledAt(e.target.value)}
166
+ />
167
+ </div>
168
+ <div className="space-y-2">
169
+ <Label htmlFor="arrivalWindow">Arrival window</Label>
170
+ <Input
171
+ id="arrivalWindow"
172
+ placeholder="e.g. 8-10am"
173
+ value={arrivalWindow}
174
+ onChange={(e) => setArrivalWindow(e.target.value)}
175
+ />
176
+ </div>
177
+ </CardContent>
178
+ </Card>
179
+
180
+ <Card>
181
+ <CardHeader className="flex flex-row items-center justify-between space-y-0">
182
+ <div>
183
+ <CardTitle>Line items</CardTitle>
184
+ <CardDescription>Prices in cents. e.g. 12500 = $125.00</CardDescription>
185
+ </div>
186
+ <div className="text-right">
187
+ <div className="text-muted-foreground text-xs uppercase">Total</div>
188
+ <div className="text-2xl font-bold">{formatCurrency(total)}</div>
189
+ </div>
190
+ </CardHeader>
191
+ <CardContent className="space-y-3">
192
+ {lineItems.map((li) => (
193
+ <div key={li.key} className="grid grid-cols-[1fr_4rem_6rem_2.5rem] items-end gap-2">
194
+ <div className="space-y-1">
195
+ <Label htmlFor={`desc-${li.key}`} className="text-xs">Description</Label>
196
+ <PriceBookItemPicker
197
+ id={`desc-${li.key}`}
198
+ value={li.description}
199
+ onTextChange={(v) => updateLine(li.key, { description: v })}
200
+ onPick={(item) =>
201
+ updateLine(li.key, {
202
+ description: item.name,
203
+ qty: item.defaultQty,
204
+ unitPrice: item.unitPrice,
205
+ })
206
+ }
207
+ placeholder="Search Price Book or type free-text…"
208
+ />
209
+ </div>
210
+ <div className="space-y-1">
211
+ <Label htmlFor={`qty-${li.key}`} className="text-xs">Qty</Label>
212
+ <Input
213
+ id={`qty-${li.key}`}
214
+ type="number"
215
+ min={0}
216
+ step={1}
217
+ value={li.qty}
218
+ onChange={(e) => updateLine(li.key, { qty: Number(e.target.value) || 0 })}
219
+ />
220
+ </div>
221
+ <div className="space-y-1">
222
+ <Label htmlFor={`price-${li.key}`} className="text-xs">Unit (¢)</Label>
223
+ <Input
224
+ id={`price-${li.key}`}
225
+ type="number"
226
+ min={0}
227
+ step={1}
228
+ value={li.unitPrice}
229
+ onChange={(e) => updateLine(li.key, { unitPrice: Number(e.target.value) || 0 })}
230
+ />
231
+ </div>
232
+ <Button
233
+ type="button"
234
+ variant="ghost"
235
+ size="icon"
236
+ onClick={() => removeLine(li.key)}
237
+ disabled={lineItems.length === 1}
238
+ aria-label="Remove line item"
239
+ >
240
+
241
+ </Button>
242
+ </div>
243
+ ))}
244
+ <Button type="button" variant="outline" size="sm" onClick={addLine}>
245
+ + Add line item
246
+ </Button>
247
+ </CardContent>
248
+ </Card>
249
+
250
+ <Card>
251
+ <CardHeader><CardTitle>Notes</CardTitle></CardHeader>
252
+ <CardContent>
253
+ <textarea
254
+ id="notes"
255
+ rows={3}
256
+ value={notes}
257
+ onChange={(e) => setNotes(e.target.value)}
258
+ 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"
259
+ />
260
+ </CardContent>
261
+ </Card>
262
+
263
+ {error && <p className="text-destructive text-sm">{error}</p>}
264
+
265
+ <div className="flex justify-end gap-3">
266
+ <Button type="button" variant="outline" asChild>
267
+ <Link href="/jobs">Cancel</Link>
268
+ </Button>
269
+ <Button type="submit" disabled={pending || customers.length === 0}>
270
+ {pending ? 'Creating...' : 'Create job'}
271
+ </Button>
272
+ </div>
273
+ </form>
274
+ );
275
+ }
@@ -0,0 +1,130 @@
1
+ 'use client';
2
+
3
+ import { useRef, useState, useTransition } from 'react';
4
+ import { Button } from '@/components/ui/button';
5
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
6
+ import { uploadJobAttachment, deleteJobAttachment, type JobAttachmentView } from '@/lib/jobs/photos-actions';
7
+
8
+ interface PhotoGalleryProps {
9
+ jobId: string;
10
+ initialAttachments: JobAttachmentView[];
11
+ }
12
+
13
+ export function PhotoGallery({ jobId, initialAttachments }: PhotoGalleryProps) {
14
+ const [attachments, setAttachments] = useState(initialAttachments);
15
+ const [pending, start] = useTransition();
16
+ const [error, setError] = useState<string | null>(null);
17
+ const fileInputRef = useRef<HTMLInputElement>(null);
18
+ const captionRef = useRef<HTMLInputElement>(null);
19
+
20
+ function handleUpload(e: React.FormEvent<HTMLFormElement>) {
21
+ e.preventDefault();
22
+ setError(null);
23
+ const formData = new FormData(e.currentTarget);
24
+ formData.set('jobId', jobId);
25
+ start(async () => {
26
+ try {
27
+ await uploadJobAttachment(formData);
28
+ // Refresh client state from the server's revalidated cache. The
29
+ // simplest path: reset the inputs and hope the server-rendered
30
+ // page is refreshed by the revalidatePath — but the optimistic
31
+ // UX is better. We append a placeholder until the page reflows.
32
+ if (fileInputRef.current) fileInputRef.current.value = '';
33
+ if (captionRef.current) captionRef.current.value = '';
34
+ } catch (err) {
35
+ setError((err as Error).message);
36
+ }
37
+ });
38
+ }
39
+
40
+ function handleDelete(id: string) {
41
+ if (!confirm('Delete this attachment? This cannot be undone.')) return;
42
+ start(async () => {
43
+ try {
44
+ await deleteJobAttachment(id, jobId);
45
+ setAttachments((prev) => prev.filter((a) => a.id !== id));
46
+ } catch (err) {
47
+ setError((err as Error).message);
48
+ }
49
+ });
50
+ }
51
+
52
+ return (
53
+ <Card>
54
+ <CardHeader className="flex flex-row items-center justify-between space-y-0">
55
+ <CardTitle>Photos & attachments ({attachments.length})</CardTitle>
56
+ </CardHeader>
57
+ <CardContent className="space-y-4">
58
+ <form onSubmit={handleUpload} className="space-y-3">
59
+ <div className="border-input grid grid-cols-1 gap-2 rounded-md border-2 border-dashed p-4 sm:grid-cols-[1fr_1fr_auto]">
60
+ <input
61
+ ref={fileInputRef}
62
+ type="file"
63
+ name="file"
64
+ accept="image/*,application/pdf"
65
+ required
66
+ className="text-sm"
67
+ />
68
+ <input
69
+ ref={captionRef}
70
+ type="text"
71
+ name="caption"
72
+ placeholder="Caption (optional)"
73
+ className="border-input bg-background h-10 rounded-md border px-3 text-sm"
74
+ />
75
+ <Button type="submit" disabled={pending} size="sm">
76
+ {pending ? 'Uploading…' : 'Upload'}
77
+ </Button>
78
+ </div>
79
+ <p className="text-muted-foreground text-xs">
80
+ Max 8MB · JPEG / PNG / WebP / HEIC / PDF · Stored in Cloudflare R2
81
+ </p>
82
+ {error && <p className="text-destructive text-sm">{error}</p>}
83
+ </form>
84
+
85
+ {attachments.length === 0 ? (
86
+ <p className="text-muted-foreground py-6 text-center text-sm">
87
+ No photos yet. Upload before/after shots, equipment damage, completion proof, etc.
88
+ </p>
89
+ ) : (
90
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
91
+ {attachments.map((a) => (
92
+ <div key={a.id} className="group relative">
93
+ {a.kind === 'photo' && a.url ? (
94
+ <a href={a.url} target="_blank" rel="noopener noreferrer">
95
+ {/* eslint-disable-next-line @next/next/no-img-element */}
96
+ <img
97
+ src={a.url}
98
+ alt={a.caption ?? ''}
99
+ className="aspect-square w-full rounded-md border object-cover"
100
+ />
101
+ </a>
102
+ ) : (
103
+ <a
104
+ href={a.url}
105
+ target="_blank"
106
+ rel="noopener noreferrer"
107
+ className="bg-muted text-muted-foreground hover:bg-muted/80 flex aspect-square w-full items-center justify-center rounded-md border text-xs"
108
+ >
109
+ 📄 {a.contentType.split('/')[1].toUpperCase()}
110
+ </a>
111
+ )}
112
+ <button
113
+ type="button"
114
+ onClick={() => handleDelete(a.id)}
115
+ className="bg-background absolute top-1 right-1 rounded-full border p-1 text-xs opacity-0 transition group-hover:opacity-100"
116
+ aria-label="Delete"
117
+ >
118
+
119
+ </button>
120
+ {a.caption && (
121
+ <p className="text-muted-foreground mt-1 truncate text-xs">{a.caption}</p>
122
+ )}
123
+ </div>
124
+ ))}
125
+ </div>
126
+ )}
127
+ </CardContent>
128
+ </Card>
129
+ );
130
+ }
@@ -0,0 +1,7 @@
1
+ import { getJobAttachments } from '@/lib/jobs/photos-actions';
2
+ import { PhotoGallery } from '@/components/jobs/photo-gallery';
3
+
4
+ export async function JobPhotosSection({ jobId }: { jobId: string }) {
5
+ const attachments = await getJobAttachments(jobId);
6
+ return <PhotoGallery jobId={jobId} initialAttachments={attachments} />;
7
+ }
@@ -0,0 +1,26 @@
1
+ import { integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
2
+ import { jobs } from './jobs';
3
+
4
+ /**
5
+ * Files attached to jobs — typically photos uploaded by techs in the
6
+ * field, but can also be PDFs (warranty docs, before/after reports),
7
+ * signature SVGs, etc.
8
+ *
9
+ * Files live in Cloudflare R2 (S3-compatible). The `key` is the R2 object
10
+ * key; `url` is the public URL (set if R2_PUBLIC_URL is configured) for
11
+ * cheap front-end rendering. `kind` lets the UI filter / group later.
12
+ */
13
+ export const jobAttachments = pgTable('job_attachments', {
14
+ id: uuid('id').primaryKey().defaultRandom(),
15
+ jobId: uuid('job_id').notNull().references(() => jobs.id, { onDelete: 'cascade' }),
16
+ key: text('key').notNull(), // R2 object key
17
+ url: text('url').notNull(), // public URL (or signed GET for non-public buckets)
18
+ kind: text('kind').notNull().default('photo'),
19
+ contentType: text('content_type').notNull(),
20
+ sizeBytes: integer('size_bytes').notNull(),
21
+ caption: text('caption'),
22
+ uploadedAt: timestamp('uploaded_at').notNull().defaultNow(),
23
+ });
24
+
25
+ export type JobAttachmentRow = typeof jobAttachments.$inferSelect;
26
+ export type NewJobAttachment = typeof jobAttachments.$inferInsert;
@@ -0,0 +1,29 @@
1
+ import { integer, jsonb, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
2
+ import { customers } from './customers';
3
+
4
+ export const jobStatus = pgEnum('job_status', [
5
+ 'lead', 'estimate', 'scheduled', 'dispatched', 'in_progress',
6
+ 'completed', 'invoiced', 'paid', 'closed', 'cancelled',
7
+ ]);
8
+ export const jobPriority = pgEnum('job_priority', ['low', 'normal', 'high', 'emergency']);
9
+
10
+ interface LineItemJson { description: string; qty: number; unitPrice: number; }
11
+
12
+ export const jobs = pgTable('jobs', {
13
+ id: uuid('id').primaryKey().defaultRandom(),
14
+ customerId: uuid('customer_id').notNull().references(() => customers.id, { onDelete: 'restrict' }),
15
+ serviceType: text('service_type').notNull(),
16
+ status: jobStatus('status').notNull().default('lead'),
17
+ priority: jobPriority('priority').notNull().default('normal'),
18
+ scheduledAt: timestamp('scheduled_at'),
19
+ arrivalWindow: text('arrival_window'),
20
+ assigneeIds: jsonb('assignee_ids').$type<string[]>().notNull().default([]),
21
+ lineItems: jsonb('line_items').$type<LineItemJson[]>().notNull().default([]),
22
+ total: integer('total').notNull().default(0), // cents
23
+ notes: text('notes'),
24
+ createdAt: timestamp('created_at').notNull().defaultNow(),
25
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
26
+ });
27
+
28
+ export type JobRow = typeof jobs.$inferSelect;
29
+ export type NewJob = typeof jobs.$inferInsert;
@@ -0,0 +1,71 @@
1
+ 'use server';
2
+
3
+ import { eq } from 'drizzle-orm';
4
+ import { revalidatePath } from 'next/cache';
5
+ import { redirect } from 'next/navigation';
6
+ import { db } from '@/db/client';
7
+ import { jobs } from '@/db/schema';
8
+ import { JOB_STATUSES, type JobStatus, type LineItem } from './types';
9
+
10
+ export interface CreateJobInput {
11
+ customerId: string;
12
+ serviceType: string;
13
+ status: JobStatus;
14
+ priority: 'low' | 'normal' | 'high' | 'emergency';
15
+ scheduledAt: string | null; // ISO or null
16
+ arrivalWindow: string | null;
17
+ notes: string | null;
18
+ lineItems: LineItem[];
19
+ }
20
+
21
+ /**
22
+ * Server action — creates a new job and redirects to its detail page.
23
+ * The form lives at /jobs/new.
24
+ */
25
+ export async function createJob(input: CreateJobInput): Promise<void> {
26
+ if (!input.customerId) throw new Error('Customer is required');
27
+ if (!input.serviceType) throw new Error('Service type is required');
28
+
29
+ const total = input.lineItems.reduce((acc, li) => acc + li.qty * li.unitPrice, 0);
30
+
31
+ const [row] = await db
32
+ .insert(jobs)
33
+ .values({
34
+ customerId: input.customerId,
35
+ serviceType: input.serviceType,
36
+ status: input.status,
37
+ priority: input.priority,
38
+ scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : null,
39
+ arrivalWindow: input.arrivalWindow ?? null,
40
+ assigneeIds: [],
41
+ lineItems: input.lineItems,
42
+ total,
43
+ notes: input.notes ?? null,
44
+ })
45
+ .returning({ id: jobs.id });
46
+
47
+ revalidatePath('/jobs');
48
+ revalidatePath('/calendar');
49
+ redirect(`/jobs/${row.id}`);
50
+ }
51
+
52
+ /**
53
+ * Server action — advances a job to the next status in the pipeline.
54
+ * Used by the "Advance" button on the job detail page.
55
+ */
56
+ export async function advanceJobStatus(jobId: string, currentStatus: JobStatus): Promise<void> {
57
+ const idx = JOB_STATUSES.indexOf(currentStatus);
58
+ const nextIdx = Math.min(idx + 1, JOB_STATUSES.length - 2); // never auto-advance to "cancelled"
59
+ const nextStatus = JOB_STATUSES[nextIdx] as JobStatus;
60
+ if (nextStatus === currentStatus) return;
61
+
62
+ await db.update(jobs).set({ status: nextStatus, updatedAt: new Date() }).where(eq(jobs.id, jobId));
63
+ revalidatePath(`/jobs/${jobId}`);
64
+ revalidatePath('/jobs');
65
+ }
66
+
67
+ export async function setJobStatus(jobId: string, status: JobStatus): Promise<void> {
68
+ await db.update(jobs).set({ status, updatedAt: new Date() }).where(eq(jobs.id, jobId));
69
+ revalidatePath(`/jobs/${jobId}`);
70
+ revalidatePath('/jobs');
71
+ }
@@ -0,0 +1,48 @@
1
+ import { desc, eq } from 'drizzle-orm';
2
+ import { db } from '@/db/client';
3
+ import { jobs as jobsTable, customers as customersTable } from '@/db/schema';
4
+ import type { Job, JobStatus, LineItem } from './types';
5
+
6
+ interface JoinedRow {
7
+ job: typeof jobsTable.$inferSelect;
8
+ customer: typeof customersTable.$inferSelect | null;
9
+ }
10
+
11
+ function toJob(row: JoinedRow): Job {
12
+ const j = row.job;
13
+ return {
14
+ id: j.id,
15
+ customerId: j.customerId,
16
+ customerName: row.customer?.name ?? '(deleted)',
17
+ serviceType: j.serviceType,
18
+ status: j.status as JobStatus,
19
+ priority: j.priority as Job['priority'],
20
+ scheduledAt: j.scheduledAt?.toISOString(),
21
+ arrivalWindow: j.arrivalWindow ?? undefined,
22
+ assigneeIds: j.assigneeIds,
23
+ assigneeNames: [], // populated by a separate users join if/when users table exists
24
+ lineItems: j.lineItems as LineItem[],
25
+ total: j.total,
26
+ notes: j.notes ?? undefined,
27
+ createdAt: j.createdAt.toISOString(),
28
+ };
29
+ }
30
+
31
+ export async function getJobs(): Promise<Job[]> {
32
+ const rows = await db
33
+ .select({ job: jobsTable, customer: customersTable })
34
+ .from(jobsTable)
35
+ .leftJoin(customersTable, eq(jobsTable.customerId, customersTable.id))
36
+ .orderBy(desc(jobsTable.createdAt));
37
+ return rows.map(toJob);
38
+ }
39
+
40
+ export async function getJob(id: string): Promise<Job | null> {
41
+ const [row] = await db
42
+ .select({ job: jobsTable, customer: customersTable })
43
+ .from(jobsTable)
44
+ .leftJoin(customersTable, eq(jobsTable.customerId, customersTable.id))
45
+ .where(eq(jobsTable.id, id))
46
+ .limit(1);
47
+ return row ? toJob(row) : null;
48
+ }