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,5 @@
1
+ # The deployed URL of THIS client's CRM (the Next.js app). The field app
2
+ # talks to its /api/mobile/v1/* endpoints. For local dev against the web app
3
+ # running on your machine, use your LAN IP (not localhost) so the phone can
4
+ # reach it, e.g. http://192.168.1.50:3000
5
+ EXPO_PUBLIC_API_URL=https://your-client-domain.com
@@ -0,0 +1,26 @@
1
+ # Expo
2
+ .expo/
3
+ dist/
4
+ web-build/
5
+ expo-env.d.ts
6
+
7
+ # Native
8
+ *.orig.*
9
+ *.jks
10
+ *.p8
11
+ *.p12
12
+ *.key
13
+ *.mobileprovision
14
+
15
+ # Metro
16
+ .metro-health-check*
17
+
18
+ # Dependencies
19
+ node_modules/
20
+
21
+ # Env
22
+ .env
23
+ .env.local
24
+
25
+ # macOS
26
+ .DS_Store
@@ -0,0 +1,37 @@
1
+ import { Stack, useRouter } from 'expo-router';
2
+ import { Pressable, Text } from 'react-native';
3
+ import { features } from '@/app.features';
4
+ import { theme } from '@/lib/theme';
5
+
6
+ export default function AppLayout() {
7
+ const router = useRouter();
8
+ return (
9
+ <Stack
10
+ screenOptions={{
11
+ headerStyle: { backgroundColor: theme.primary },
12
+ headerTintColor: '#fff',
13
+ headerTitleStyle: { fontWeight: '700' },
14
+ }}
15
+ >
16
+ <Stack.Screen
17
+ name="index"
18
+ options={{
19
+ title: 'Today',
20
+ headerRight: features.sms
21
+ ? () => (
22
+ <Pressable onPress={() => router.push('/(app)/inbox')} hitSlop={12}>
23
+ <Text style={{ color: '#fff', fontWeight: '600' }}>Inbox</Text>
24
+ </Pressable>
25
+ )
26
+ : undefined,
27
+ }}
28
+ />
29
+ <Stack.Screen name="job/[id]" options={{ title: 'Job' }} />
30
+ <Stack.Screen name="job/[id]/checklist" options={{ title: 'Checklist' }} />
31
+ <Stack.Screen name="job/[id]/invoice" options={{ title: 'Invoice' }} />
32
+ <Stack.Screen name="estimate" options={{ title: 'New estimate' }} />
33
+ <Stack.Screen name="inbox/index" options={{ title: 'Inbox' }} />
34
+ <Stack.Screen name="inbox/[customerId]" options={{ title: 'Conversation' }} />
35
+ </Stack>
36
+ );
37
+ }
@@ -0,0 +1,135 @@
1
+ import { useMutation, useQuery } from '@tanstack/react-query';
2
+ import { useLocalSearchParams, useRouter } from 'expo-router';
3
+ import { useState } from 'react';
4
+ import {
5
+ ActivityIndicator,
6
+ Alert,
7
+ Pressable,
8
+ ScrollView,
9
+ StyleSheet,
10
+ Text,
11
+ View,
12
+ } from 'react-native';
13
+ import { api, type ApiPriceBookItem } from '@/lib/api';
14
+ import { money } from '@/lib/format';
15
+ import { theme } from '@/lib/theme';
16
+
17
+ interface Line {
18
+ description: string;
19
+ qty: number;
20
+ unitPrice: number;
21
+ unitCost: number;
22
+ }
23
+
24
+ export default function EstimateBuilder() {
25
+ const router = useRouter();
26
+ const { customerId, customerName } = useLocalSearchParams<{ customerId: string; customerName?: string }>();
27
+ const [lines, setLines] = useState<Line[]>([]);
28
+
29
+ const { data, isLoading } = useQuery({ queryKey: ['price-book'], queryFn: () => api.priceBook() });
30
+
31
+ const submit = useMutation({
32
+ mutationFn: () =>
33
+ api.createEstimate({
34
+ customerId: String(customerId),
35
+ lineItems: lines.map((l) => ({ description: l.description, qty: l.qty, unitPrice: l.unitPrice, unitCost: l.unitCost })),
36
+ }),
37
+ onSuccess: () => {
38
+ Alert.alert('Estimate sent', 'The estimate was created.');
39
+ router.back();
40
+ },
41
+ onError: (e) => Alert.alert('Could not send', e instanceof Error ? e.message : 'Error'),
42
+ });
43
+
44
+ function addItem(it: ApiPriceBookItem) {
45
+ setLines((prev) => {
46
+ const found = prev.find((l) => l.description === it.name);
47
+ if (found) {
48
+ return prev.map((l) => (l.description === it.name ? { ...l, qty: l.qty + 1 } : l));
49
+ }
50
+ return [...prev, { description: it.name, qty: it.defaultQty || 1, unitPrice: it.unitPrice, unitCost: it.unitCost }];
51
+ });
52
+ }
53
+
54
+ function removeLine(desc: string) {
55
+ setLines((prev) => prev.filter((l) => l.description !== desc));
56
+ }
57
+
58
+ const total = lines.reduce((acc, l) => acc + l.qty * l.unitPrice, 0);
59
+
60
+ if (isLoading) {
61
+ return (
62
+ <View style={styles.center}>
63
+ <ActivityIndicator color={theme.primary} />
64
+ </View>
65
+ );
66
+ }
67
+
68
+ return (
69
+ <View style={styles.screen}>
70
+ <ScrollView contentContainerStyle={styles.content}>
71
+ <Text style={styles.heading}>Estimate for {customerName ?? 'customer'}</Text>
72
+
73
+ {lines.length > 0 ? (
74
+ <View style={styles.card}>
75
+ <Text style={styles.cardTitle}>Line items</Text>
76
+ {lines.map((l) => (
77
+ <Pressable key={l.description} style={styles.line} onLongPress={() => removeLine(l.description)}>
78
+ <Text style={styles.body}>
79
+ {l.qty} × {l.description}
80
+ </Text>
81
+ <Text style={styles.body}>{money(l.qty * l.unitPrice)}</Text>
82
+ </Pressable>
83
+ ))}
84
+ <Text style={styles.hint}>Long-press a line to remove.</Text>
85
+ </View>
86
+ ) : (
87
+ <Text style={styles.muted}>Tap price-book items below to add them.</Text>
88
+ )}
89
+
90
+ {(data?.categories ?? []).map((cat) => (
91
+ <View key={cat.id} style={styles.card}>
92
+ <Text style={styles.cardTitle}>{cat.name}</Text>
93
+ {cat.items.map((it) => (
94
+ <Pressable key={it.id} style={styles.pbItem} onPress={() => addItem(it)}>
95
+ <Text style={styles.body}>{it.name}</Text>
96
+ <Text style={styles.price}>{money(it.unitPrice)}</Text>
97
+ </Pressable>
98
+ ))}
99
+ </View>
100
+ ))}
101
+ </ScrollView>
102
+
103
+ <View style={styles.footer}>
104
+ <Text style={styles.total}>Total {money(total)}</Text>
105
+ <Pressable
106
+ style={[styles.sendBtn, lines.length === 0 && styles.disabled]}
107
+ disabled={lines.length === 0 || submit.isPending}
108
+ onPress={() => submit.mutate()}
109
+ >
110
+ <Text style={styles.sendText}>{submit.isPending ? 'Sending…' : 'Send estimate'}</Text>
111
+ </Pressable>
112
+ </View>
113
+ </View>
114
+ );
115
+ }
116
+
117
+ const styles = StyleSheet.create({
118
+ screen: { flex: 1, backgroundColor: theme.bg },
119
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: theme.bg },
120
+ content: { padding: 16, gap: 12, paddingBottom: 24 },
121
+ heading: { fontSize: 20, fontWeight: '700', color: theme.text },
122
+ muted: { color: theme.muted },
123
+ card: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border, borderRadius: 12, padding: 14 },
124
+ cardTitle: { fontSize: 13, fontWeight: '700', color: theme.muted, textTransform: 'uppercase', marginBottom: 8 },
125
+ line: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6 },
126
+ body: { fontSize: 15, color: theme.text },
127
+ hint: { fontSize: 12, color: theme.muted, marginTop: 6 },
128
+ pbItem: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 10, borderTopWidth: 1, borderTopColor: theme.border },
129
+ price: { fontSize: 15, color: theme.primary, fontWeight: '600' },
130
+ footer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 16, borderTopWidth: 1, borderTopColor: theme.border, backgroundColor: theme.card },
131
+ total: { fontSize: 18, fontWeight: '700', color: theme.text },
132
+ sendBtn: { backgroundColor: theme.primary, borderRadius: 10, paddingVertical: 12, paddingHorizontal: 24 },
133
+ disabled: { opacity: 0.4 },
134
+ sendText: { color: '#fff', fontWeight: '700', fontSize: 16 },
135
+ });
@@ -0,0 +1,103 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
+ import { useLocalSearchParams } from 'expo-router';
3
+ import { useState } from 'react';
4
+ import {
5
+ ActivityIndicator,
6
+ FlatList,
7
+ KeyboardAvoidingView,
8
+ Platform,
9
+ Pressable,
10
+ StyleSheet,
11
+ Text,
12
+ TextInput,
13
+ View,
14
+ } from 'react-native';
15
+ import { SafeAreaView } from 'react-native-safe-area-context';
16
+ import { api, type ApiMessage } from '@/lib/api';
17
+ import { theme } from '@/lib/theme';
18
+
19
+ export default function Thread() {
20
+ const { customerId, phone } = useLocalSearchParams<{ customerId: string; name?: string; phone?: string }>();
21
+ const cid = String(customerId);
22
+ const qc = useQueryClient();
23
+ const [text, setText] = useState('');
24
+
25
+ const { data, isLoading } = useQuery({
26
+ queryKey: ['sms-thread', cid],
27
+ queryFn: () => api.smsMessages(cid),
28
+ });
29
+
30
+ const send = useMutation({
31
+ mutationFn: (body: string) => api.sendSms({ to: phone ? String(phone) : '', body, customerId: cid }),
32
+ onSuccess: () => {
33
+ setText('');
34
+ qc.invalidateQueries({ queryKey: ['sms-thread', cid] });
35
+ qc.invalidateQueries({ queryKey: ['sms-threads'] });
36
+ },
37
+ });
38
+
39
+ if (isLoading) {
40
+ return (
41
+ <View style={styles.center}>
42
+ <ActivityIndicator color={theme.primary} />
43
+ </View>
44
+ );
45
+ }
46
+
47
+ const messages = data?.messages ?? [];
48
+
49
+ return (
50
+ <SafeAreaView style={styles.safe} edges={['bottom']}>
51
+ <KeyboardAvoidingView
52
+ style={styles.flex}
53
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
54
+ keyboardVerticalOffset={90}
55
+ >
56
+ <FlatList
57
+ data={messages}
58
+ keyExtractor={(m) => m.id}
59
+ contentContainerStyle={styles.list}
60
+ renderItem={({ item }: { item: ApiMessage }) => (
61
+ <View style={[styles.bubble, item.direction === 'outbound' ? styles.out : styles.in]}>
62
+ <Text style={item.direction === 'outbound' ? styles.outText : styles.inText}>{item.body}</Text>
63
+ </View>
64
+ )}
65
+ />
66
+ <View style={styles.composer}>
67
+ <TextInput
68
+ style={styles.input}
69
+ placeholder="Message…"
70
+ placeholderTextColor={theme.muted}
71
+ value={text}
72
+ onChangeText={setText}
73
+ multiline
74
+ />
75
+ <Pressable
76
+ style={[styles.sendBtn, (!text.trim() || send.isPending) && styles.disabled]}
77
+ disabled={!text.trim() || send.isPending}
78
+ onPress={() => send.mutate(text.trim())}
79
+ >
80
+ <Text style={styles.sendText}>Send</Text>
81
+ </Pressable>
82
+ </View>
83
+ </KeyboardAvoidingView>
84
+ </SafeAreaView>
85
+ );
86
+ }
87
+
88
+ const styles = StyleSheet.create({
89
+ safe: { flex: 1, backgroundColor: theme.bg },
90
+ flex: { flex: 1 },
91
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: theme.bg },
92
+ list: { padding: 16, gap: 8 },
93
+ bubble: { maxWidth: '80%', borderRadius: 14, paddingHorizontal: 12, paddingVertical: 8 },
94
+ out: { backgroundColor: theme.primary, alignSelf: 'flex-end' },
95
+ in: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border, alignSelf: 'flex-start' },
96
+ outText: { color: '#fff', fontSize: 15 },
97
+ inText: { color: theme.text, fontSize: 15 },
98
+ composer: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, padding: 12, borderTopWidth: 1, borderTopColor: theme.border, backgroundColor: theme.card },
99
+ input: { flex: 1, maxHeight: 120, backgroundColor: theme.bg, borderWidth: 1, borderColor: theme.border, borderRadius: 18, paddingHorizontal: 14, paddingVertical: 10, fontSize: 15, color: theme.text },
100
+ sendBtn: { backgroundColor: theme.primary, borderRadius: 18, paddingHorizontal: 18, paddingVertical: 10 },
101
+ disabled: { opacity: 0.4 },
102
+ sendText: { color: '#fff', fontWeight: '700' },
103
+ });
@@ -0,0 +1,70 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { Link } from 'expo-router';
3
+ import {
4
+ ActivityIndicator,
5
+ FlatList,
6
+ Pressable,
7
+ StyleSheet,
8
+ Text,
9
+ View,
10
+ } from 'react-native';
11
+ import { SafeAreaView } from 'react-native-safe-area-context';
12
+ import { api } from '@/lib/api';
13
+ import { theme } from '@/lib/theme';
14
+
15
+ export default function Inbox() {
16
+ const { data, isLoading, refetch, isRefetching } = useQuery({
17
+ queryKey: ['sms-threads'],
18
+ queryFn: () => api.smsThreads(),
19
+ });
20
+
21
+ if (isLoading) {
22
+ return (
23
+ <View style={styles.center}>
24
+ <ActivityIndicator color={theme.primary} />
25
+ </View>
26
+ );
27
+ }
28
+
29
+ const threads = data?.threads ?? [];
30
+
31
+ return (
32
+ <SafeAreaView style={styles.safe} edges={['bottom']}>
33
+ <FlatList
34
+ data={threads}
35
+ keyExtractor={(t) => t.customerId}
36
+ contentContainerStyle={threads.length === 0 ? styles.emptyWrap : styles.list}
37
+ onRefresh={refetch}
38
+ refreshing={isRefetching}
39
+ ListEmptyComponent={<Text style={styles.empty}>No conversations yet.</Text>}
40
+ renderItem={({ item }) => (
41
+ <Link
42
+ href={{
43
+ pathname: '/(app)/inbox/[customerId]',
44
+ params: { customerId: item.customerId, name: item.customerName, phone: item.customerPhone },
45
+ }}
46
+ asChild
47
+ >
48
+ <Pressable style={styles.row}>
49
+ <Text style={styles.name}>{item.customerName}</Text>
50
+ <Text style={styles.preview} numberOfLines={1}>
51
+ {item.lastBody}
52
+ </Text>
53
+ </Pressable>
54
+ </Link>
55
+ )}
56
+ />
57
+ </SafeAreaView>
58
+ );
59
+ }
60
+
61
+ const styles = StyleSheet.create({
62
+ safe: { flex: 1, backgroundColor: theme.bg },
63
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: theme.bg },
64
+ list: { padding: 16, gap: 10 },
65
+ emptyWrap: { flexGrow: 1, padding: 16, alignItems: 'center', justifyContent: 'center' },
66
+ empty: { color: theme.muted },
67
+ row: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border, borderRadius: 12, padding: 14, gap: 4 },
68
+ name: { fontSize: 16, fontWeight: '600', color: theme.text },
69
+ preview: { fontSize: 14, color: theme.muted },
70
+ });
@@ -0,0 +1,111 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { Link, useRouter } from 'expo-router';
3
+ import {
4
+ ActivityIndicator,
5
+ FlatList,
6
+ Pressable,
7
+ RefreshControl,
8
+ StyleSheet,
9
+ Text,
10
+ View,
11
+ } from 'react-native';
12
+ import { SafeAreaView } from 'react-native-safe-area-context';
13
+ import { api, type ApiJob } from '@/lib/api';
14
+ import { signOut } from '@/lib/auth';
15
+ import { money, statusLabel } from '@/lib/format';
16
+ import { theme } from '@/lib/theme';
17
+
18
+ export default function Today() {
19
+ const router = useRouter();
20
+ const { data, isLoading, isError, refetch, isRefetching } = useQuery({
21
+ queryKey: ['jobs', 'today'],
22
+ queryFn: () => api.jobs('today'),
23
+ });
24
+
25
+ async function onSignOut() {
26
+ await signOut();
27
+ router.replace('/sign-in');
28
+ }
29
+
30
+ if (isLoading) {
31
+ return (
32
+ <View style={styles.center}>
33
+ <ActivityIndicator color={theme.primary} />
34
+ </View>
35
+ );
36
+ }
37
+
38
+ const jobs = data?.jobs ?? [];
39
+
40
+ return (
41
+ <SafeAreaView style={styles.safe} edges={['bottom']}>
42
+ <FlatList
43
+ data={jobs}
44
+ keyExtractor={(j) => j.id}
45
+ contentContainerStyle={jobs.length === 0 ? styles.emptyWrap : styles.list}
46
+ refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor={theme.primary} />}
47
+ ListHeaderComponent={
48
+ <View style={styles.header}>
49
+ <Text style={styles.heading}>Today&apos;s jobs</Text>
50
+ <Pressable onPress={onSignOut}>
51
+ <Text style={styles.signOut}>Sign out</Text>
52
+ </Pressable>
53
+ </View>
54
+ }
55
+ ListEmptyComponent={
56
+ <Text style={styles.empty}>
57
+ {isError ? 'Could not load jobs. Pull to retry.' : 'No jobs scheduled for today.'}
58
+ </Text>
59
+ }
60
+ renderItem={({ item }) => <JobRow job={item} />}
61
+ />
62
+ </SafeAreaView>
63
+ );
64
+ }
65
+
66
+ function JobRow({ job }: { job: ApiJob }) {
67
+ return (
68
+ <Link href={{ pathname: '/(app)/job/[id]', params: { id: job.id } }} asChild>
69
+ <Pressable style={styles.card}>
70
+ <View style={styles.cardTop}>
71
+ <Text style={styles.customer}>{job.customerName}</Text>
72
+ <View style={styles.badge}>
73
+ <Text style={styles.badgeText}>{statusLabel(job.status)}</Text>
74
+ </View>
75
+ </View>
76
+ <Text style={styles.service}>{job.serviceType}</Text>
77
+ <View style={styles.cardBottom}>
78
+ <Text style={styles.window}>{job.arrivalWindow ?? 'Anytime'}</Text>
79
+ <Text style={styles.total}>{money(job.total)}</Text>
80
+ </View>
81
+ </Pressable>
82
+ </Link>
83
+ );
84
+ }
85
+
86
+ const styles = StyleSheet.create({
87
+ safe: { flex: 1, backgroundColor: theme.bg },
88
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: theme.bg },
89
+ list: { padding: 16, gap: 12 },
90
+ emptyWrap: { flexGrow: 1, padding: 16 },
91
+ header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 },
92
+ heading: { fontSize: 22, fontWeight: '700', color: theme.text },
93
+ signOut: { color: theme.muted, fontSize: 14 },
94
+ empty: { textAlign: 'center', color: theme.muted, marginTop: 48 },
95
+ card: {
96
+ backgroundColor: theme.card,
97
+ borderRadius: 12,
98
+ padding: 16,
99
+ borderWidth: 1,
100
+ borderColor: theme.border,
101
+ gap: 6,
102
+ },
103
+ cardTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
104
+ customer: { fontSize: 17, fontWeight: '600', color: theme.text, flex: 1 },
105
+ badge: { backgroundColor: theme.bg, borderRadius: 999, paddingHorizontal: 10, paddingVertical: 3 },
106
+ badgeText: { fontSize: 12, color: theme.muted, fontWeight: '600' },
107
+ service: { fontSize: 15, color: theme.muted },
108
+ cardBottom: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 },
109
+ window: { fontSize: 14, color: theme.text },
110
+ total: { fontSize: 14, fontWeight: '600', color: theme.primary },
111
+ });
@@ -0,0 +1,99 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
+ import { useLocalSearchParams } from 'expo-router';
3
+ import {
4
+ ActivityIndicator,
5
+ Pressable,
6
+ ScrollView,
7
+ StyleSheet,
8
+ Text,
9
+ View,
10
+ } from 'react-native';
11
+ import { api, type ApiChecklist } from '@/lib/api';
12
+ import { theme } from '@/lib/theme';
13
+
14
+ export default function ChecklistScreen() {
15
+ const { id } = useLocalSearchParams<{ id: string }>();
16
+ const jobId = String(id);
17
+ const qc = useQueryClient();
18
+
19
+ const { data, isLoading } = useQuery({
20
+ queryKey: ['checklists', jobId],
21
+ queryFn: () => api.checklists(jobId),
22
+ });
23
+
24
+ const toggle = useMutation({
25
+ mutationFn: (v: { jobChecklistId: string; itemId: string; completed: boolean }) =>
26
+ api.setChecklistItem(jobId, v),
27
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['checklists', jobId] }),
28
+ });
29
+
30
+ if (isLoading) {
31
+ return (
32
+ <View style={styles.center}>
33
+ <ActivityIndicator color={theme.primary} />
34
+ </View>
35
+ );
36
+ }
37
+
38
+ const checklists = data?.checklists ?? [];
39
+ if (checklists.length === 0) {
40
+ return (
41
+ <View style={styles.center}>
42
+ <Text style={styles.muted}>No checklist attached to this job.</Text>
43
+ </View>
44
+ );
45
+ }
46
+
47
+ return (
48
+ <ScrollView style={styles.screen} contentContainerStyle={styles.content}>
49
+ {checklists.map((cl: ApiChecklist) => (
50
+ <View key={cl.id} style={styles.card}>
51
+ <View style={styles.cardHead}>
52
+ <Text style={styles.title}>{cl.templateName}</Text>
53
+ <Text style={[styles.pct, cl.allRequiredComplete && styles.pctDone]}>{cl.progressPct}%</Text>
54
+ </View>
55
+ {cl.items.map((it) => (
56
+ <Pressable
57
+ key={it.id}
58
+ style={styles.item}
59
+ onPress={() =>
60
+ toggle.mutate({ jobChecklistId: cl.id, itemId: it.id, completed: !it.completed })
61
+ }
62
+ >
63
+ <View style={[styles.box, it.completed && styles.boxOn]}>
64
+ {it.completed ? <Text style={styles.check}>✓</Text> : null}
65
+ </View>
66
+ <Text style={[styles.label, it.completed && styles.labelDone]}>
67
+ {it.label}
68
+ {it.required ? <Text style={styles.req}> *</Text> : null}
69
+ </Text>
70
+ </Pressable>
71
+ ))}
72
+ {!cl.allRequiredComplete ? (
73
+ <Text style={styles.warn}>Complete all required (*) items before finishing the job.</Text>
74
+ ) : null}
75
+ </View>
76
+ ))}
77
+ </ScrollView>
78
+ );
79
+ }
80
+
81
+ const styles = StyleSheet.create({
82
+ screen: { flex: 1, backgroundColor: theme.bg },
83
+ content: { padding: 16, gap: 12 },
84
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: theme.bg },
85
+ muted: { color: theme.muted },
86
+ card: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border, borderRadius: 12, padding: 14 },
87
+ cardHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 },
88
+ title: { fontSize: 16, fontWeight: '700', color: theme.text },
89
+ pct: { fontSize: 14, fontWeight: '700', color: theme.muted },
90
+ pctDone: { color: theme.success },
91
+ item: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 10 },
92
+ box: { width: 24, height: 24, borderRadius: 6, borderWidth: 2, borderColor: theme.border, alignItems: 'center', justifyContent: 'center' },
93
+ boxOn: { backgroundColor: theme.success, borderColor: theme.success },
94
+ check: { color: '#fff', fontWeight: '800', fontSize: 14 },
95
+ label: { fontSize: 15, color: theme.text, flex: 1 },
96
+ labelDone: { color: theme.muted, textDecorationLine: 'line-through' },
97
+ req: { color: theme.danger },
98
+ warn: { marginTop: 8, fontSize: 13, color: theme.danger },
99
+ });