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,143 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
+ import { useLocalSearchParams } from 'expo-router';
3
+ import * as WebBrowser from 'expo-web-browser';
4
+ import {
5
+ ActivityIndicator,
6
+ Alert,
7
+ Pressable,
8
+ ScrollView,
9
+ StyleSheet,
10
+ Text,
11
+ View,
12
+ } from 'react-native';
13
+ import { api } from '@/lib/api';
14
+ import { apiBaseUrl } from '@/lib/auth';
15
+ import { money, statusLabel } from '@/lib/format';
16
+ import { theme } from '@/lib/theme';
17
+
18
+ export default function InvoiceScreen() {
19
+ const { id } = useLocalSearchParams<{ id: string }>();
20
+ const jobId = String(id);
21
+ const qc = useQueryClient();
22
+
23
+ const { data, isLoading } = useQuery({
24
+ queryKey: ['job-invoice', jobId],
25
+ queryFn: () => api.jobInvoice(jobId),
26
+ });
27
+
28
+ const create = useMutation({
29
+ mutationFn: () => api.createInvoiceFromJob(jobId),
30
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['job-invoice', jobId] }),
31
+ onError: (e) => Alert.alert('Could not invoice', e instanceof Error ? e.message : 'Error'),
32
+ });
33
+
34
+ const pay = useMutation({
35
+ mutationFn: (amount: number) =>
36
+ api.recordPayment(data!.invoice!.id, { amount, method: 'cash' }),
37
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['job-invoice', jobId] }),
38
+ onError: () => Alert.alert('Error', 'Could not record payment.'),
39
+ });
40
+
41
+ if (isLoading) {
42
+ return (
43
+ <View style={styles.center}>
44
+ <ActivityIndicator color={theme.primary} />
45
+ </View>
46
+ );
47
+ }
48
+
49
+ const invoice = data?.invoice ?? null;
50
+
51
+ if (!invoice) {
52
+ return (
53
+ <View style={styles.center}>
54
+ <Text style={styles.muted}>No invoice for this job yet.</Text>
55
+ <Pressable style={styles.primaryBtn} onPress={() => create.mutate()} disabled={create.isPending}>
56
+ <Text style={styles.primaryText}>{create.isPending ? 'Creating…' : 'Create invoice from job'}</Text>
57
+ </Pressable>
58
+ </View>
59
+ );
60
+ }
61
+
62
+ async function openCardPayment() {
63
+ if (!data?.payPath) return;
64
+ await WebBrowser.openBrowserAsync(`${apiBaseUrl}${data.payPath}`);
65
+ qc.invalidateQueries({ queryKey: ['job-invoice', jobId] });
66
+ }
67
+
68
+ function recordCash() {
69
+ Alert.alert('Record cash payment', `Mark ${money(invoice!.amountDue)} as paid in cash?`, [
70
+ { text: 'Cancel', style: 'cancel' },
71
+ { text: 'Confirm', onPress: () => pay.mutate(invoice!.amountDue) },
72
+ ]);
73
+ }
74
+
75
+ return (
76
+ <ScrollView style={styles.screen} contentContainerStyle={styles.content}>
77
+ <View style={styles.card}>
78
+ <View style={styles.row}>
79
+ <Text style={styles.num}>{invoice.invoiceNumber}</Text>
80
+ <View style={styles.badge}>
81
+ <Text style={styles.badgeText}>{statusLabel(invoice.status)}</Text>
82
+ </View>
83
+ </View>
84
+ {invoice.lineItems.map((li, i) => (
85
+ <View key={i} style={styles.line}>
86
+ <Text style={styles.body}>
87
+ {li.qty} × {li.description}
88
+ </Text>
89
+ <Text style={styles.body}>{money(li.qty * li.unitPrice)}</Text>
90
+ </View>
91
+ ))}
92
+ <View style={styles.divider} />
93
+ <View style={styles.line}>
94
+ <Text style={styles.body}>Total</Text>
95
+ <Text style={styles.body}>{money(invoice.total)}</Text>
96
+ </View>
97
+ <View style={styles.line}>
98
+ <Text style={styles.muted}>Paid</Text>
99
+ <Text style={styles.muted}>{money(invoice.amountPaid)}</Text>
100
+ </View>
101
+ <View style={styles.line}>
102
+ <Text style={styles.due}>Amount due</Text>
103
+ <Text style={styles.due}>{money(invoice.amountDue)}</Text>
104
+ </View>
105
+ </View>
106
+
107
+ {invoice.amountDue > 0 ? (
108
+ <View style={styles.actions}>
109
+ <Pressable style={styles.primaryBtn} onPress={openCardPayment}>
110
+ <Text style={styles.primaryText}>Take card payment</Text>
111
+ </Pressable>
112
+ <Pressable style={styles.secondaryBtn} onPress={recordCash} disabled={pay.isPending}>
113
+ <Text style={styles.secondaryText}>{pay.isPending ? 'Saving…' : 'Record cash payment'}</Text>
114
+ </Pressable>
115
+ </View>
116
+ ) : (
117
+ <Text style={styles.paidNote}>Paid in full ✓</Text>
118
+ )}
119
+ </ScrollView>
120
+ );
121
+ }
122
+
123
+ const styles = StyleSheet.create({
124
+ screen: { flex: 1, backgroundColor: theme.bg },
125
+ content: { padding: 16, gap: 12 },
126
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: theme.bg, gap: 16, padding: 24 },
127
+ muted: { color: theme.muted },
128
+ card: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border, borderRadius: 12, padding: 16, gap: 6 },
129
+ row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 },
130
+ num: { fontSize: 18, fontWeight: '700', color: theme.text },
131
+ badge: { backgroundColor: theme.bg, borderRadius: 999, paddingHorizontal: 10, paddingVertical: 3 },
132
+ badgeText: { fontSize: 12, color: theme.muted, fontWeight: '600' },
133
+ line: { flexDirection: 'row', justifyContent: 'space-between' },
134
+ body: { fontSize: 15, color: theme.text },
135
+ divider: { height: 1, backgroundColor: theme.border, marginVertical: 6 },
136
+ due: { fontSize: 16, fontWeight: '700', color: theme.primary },
137
+ actions: { gap: 10 },
138
+ primaryBtn: { backgroundColor: theme.primary, borderRadius: 10, paddingVertical: 14, alignItems: 'center' },
139
+ primaryText: { color: '#fff', fontWeight: '700', fontSize: 16 },
140
+ secondaryBtn: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border, borderRadius: 10, paddingVertical: 14, alignItems: 'center' },
141
+ secondaryText: { color: theme.text, fontWeight: '600', fontSize: 16 },
142
+ paidNote: { textAlign: 'center', color: theme.success, fontWeight: '700', fontSize: 16, marginTop: 8 },
143
+ });
@@ -0,0 +1,259 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
+ import * as ImagePicker from 'expo-image-picker';
3
+ import * as Linking from 'expo-linking';
4
+ import { useLocalSearchParams, useRouter } from 'expo-router';
5
+ import { useState } from 'react';
6
+ import { features } from '@/app.features';
7
+ import {
8
+ ActivityIndicator,
9
+ Alert,
10
+ Image,
11
+ Pressable,
12
+ ScrollView,
13
+ StyleSheet,
14
+ Text,
15
+ View,
16
+ } from 'react-native';
17
+ import { api, uploadJobPhoto } from '@/lib/api';
18
+ import { formatAddress, mapsUrl, money, statusLabel } from '@/lib/format';
19
+ import { theme } from '@/lib/theme';
20
+ import { SignaturePad } from '@/components/SignaturePad';
21
+
22
+ export default function JobDetail() {
23
+ const { id } = useLocalSearchParams<{ id: string }>();
24
+ const jobId = String(id);
25
+ const qc = useQueryClient();
26
+ const router = useRouter();
27
+ const [signing, setSigning] = useState(false);
28
+ const [uploading, setUploading] = useState(false);
29
+
30
+ const { data, isLoading, isError } = useQuery({
31
+ queryKey: ['job', jobId],
32
+ queryFn: () => api.job(jobId),
33
+ });
34
+
35
+ const statusMutation = useMutation({
36
+ mutationFn: (status: string) => api.setStatus(jobId, status),
37
+ onSuccess: () => {
38
+ qc.invalidateQueries({ queryKey: ['job', jobId] });
39
+ qc.invalidateQueries({ queryKey: ['jobs'] });
40
+ },
41
+ onError: () => Alert.alert('Error', 'Could not update status.'),
42
+ });
43
+
44
+ const { data: timeState } = useQuery({
45
+ queryKey: ['job-time', jobId],
46
+ queryFn: () => api.time(jobId),
47
+ });
48
+ const clockedIn = Boolean(timeState?.openEntryId);
49
+ const clock = useMutation({
50
+ mutationFn: async () => {
51
+ if (clockedIn) await api.clockOut(jobId);
52
+ else await api.clockIn(jobId);
53
+ },
54
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['job-time', jobId] }),
55
+ });
56
+
57
+ async function addPhoto() {
58
+ const perm = await ImagePicker.requestCameraPermissionsAsync();
59
+ const result = perm.granted
60
+ ? await ImagePicker.launchCameraAsync({ quality: 0.6 })
61
+ : await ImagePicker.launchImageLibraryAsync({ quality: 0.6, mediaTypes: ['images'] });
62
+ if (result.canceled || !result.assets[0]) return;
63
+ setUploading(true);
64
+ try {
65
+ await uploadJobPhoto(jobId, result.assets[0].uri, 'image/jpeg', 'photo');
66
+ qc.invalidateQueries({ queryKey: ['job', jobId] });
67
+ } catch {
68
+ Alert.alert('Upload failed', 'Check the connection and try again.');
69
+ } finally {
70
+ setUploading(false);
71
+ }
72
+ }
73
+
74
+ async function onSignature(dataUri: string) {
75
+ setSigning(false);
76
+ setUploading(true);
77
+ try {
78
+ await uploadJobPhoto(jobId, dataUri, 'image/png', 'signature');
79
+ qc.invalidateQueries({ queryKey: ['job', jobId] });
80
+ } catch {
81
+ Alert.alert('Upload failed', 'Could not save the signature.');
82
+ } finally {
83
+ setUploading(false);
84
+ }
85
+ }
86
+
87
+ if (isLoading) {
88
+ return (
89
+ <View style={styles.center}>
90
+ <ActivityIndicator color={theme.primary} />
91
+ </View>
92
+ );
93
+ }
94
+ if (isError || !data) {
95
+ return (
96
+ <View style={styles.center}>
97
+ <Text style={styles.muted}>Could not load this job.</Text>
98
+ </View>
99
+ );
100
+ }
101
+
102
+ const { job, customer, attachments } = data;
103
+ const addr = customer?.serviceAddresses?.[0];
104
+ const phone = customer?.phones?.[0];
105
+
106
+ return (
107
+ <ScrollView style={styles.screen} contentContainerStyle={styles.content}>
108
+ <Text style={styles.customer}>{job.customerName}</Text>
109
+ <View style={styles.badge}>
110
+ <Text style={styles.badgeText}>{statusLabel(job.status)}</Text>
111
+ </View>
112
+ <Text style={styles.service}>{job.serviceType}</Text>
113
+
114
+ <View style={styles.actionsRow}>
115
+ {addr ? (
116
+ <Pressable style={styles.action} onPress={() => Linking.openURL(mapsUrl(addr))}>
117
+ <Text style={styles.actionText}>Navigate</Text>
118
+ </Pressable>
119
+ ) : null}
120
+ {phone ? (
121
+ <Pressable style={styles.action} onPress={() => Linking.openURL(`tel:${phone}`)}>
122
+ <Text style={styles.actionText}>Call</Text>
123
+ </Pressable>
124
+ ) : null}
125
+ </View>
126
+
127
+ {addr ? (
128
+ <View style={styles.section}>
129
+ <Text style={styles.sectionTitle}>Address</Text>
130
+ <Text style={styles.body}>{formatAddress(addr)}</Text>
131
+ </View>
132
+ ) : null}
133
+
134
+ <View style={styles.section}>
135
+ <Text style={styles.sectionTitle}>Line items</Text>
136
+ {job.lineItems.length === 0 ? (
137
+ <Text style={styles.muted}>No line items.</Text>
138
+ ) : (
139
+ job.lineItems.map((li, i) => (
140
+ <View key={i} style={styles.lineItem}>
141
+ <Text style={styles.body}>
142
+ {li.qty} × {li.description}
143
+ </Text>
144
+ <Text style={styles.body}>{money(li.qty * li.unitPrice)}</Text>
145
+ </View>
146
+ ))
147
+ )}
148
+ <View style={styles.totalRow}>
149
+ <Text style={styles.totalLabel}>Total</Text>
150
+ <Text style={styles.totalValue}>{money(job.total)}</Text>
151
+ </View>
152
+ </View>
153
+
154
+ {job.notes ? (
155
+ <View style={styles.section}>
156
+ <Text style={styles.sectionTitle}>Notes</Text>
157
+ <Text style={styles.body}>{job.notes}</Text>
158
+ </View>
159
+ ) : null}
160
+
161
+ <View style={styles.section}>
162
+ <Text style={styles.sectionTitle}>Photos &amp; signatures</Text>
163
+ <View style={styles.photoGrid}>
164
+ {attachments.map((a) => (
165
+ <Image key={a.id} source={{ uri: a.url }} style={styles.thumb} />
166
+ ))}
167
+ </View>
168
+ <View style={styles.actionsRow}>
169
+ <Pressable style={styles.action} onPress={addPhoto} disabled={uploading}>
170
+ <Text style={styles.actionText}>{uploading ? 'Uploading…' : 'Add photo'}</Text>
171
+ </Pressable>
172
+ <Pressable style={styles.action} onPress={() => setSigning(true)} disabled={uploading}>
173
+ <Text style={styles.actionText}>Signature</Text>
174
+ </Pressable>
175
+ </View>
176
+ </View>
177
+
178
+ <View style={styles.section}>
179
+ <Text style={styles.sectionTitle}>Work</Text>
180
+ <View style={styles.actionsRow}>
181
+ <Pressable style={[styles.action, clockedIn && styles.clockOn]} onPress={() => clock.mutate()}>
182
+ <Text style={styles.actionText}>{clockedIn ? 'Clock out' : 'Clock in'}</Text>
183
+ </Pressable>
184
+ <Pressable style={styles.action} onPress={() => router.push(`/(app)/job/${jobId}/checklist`)}>
185
+ <Text style={styles.actionText}>Checklist</Text>
186
+ </Pressable>
187
+ </View>
188
+ {features.invoices || features.estimates ? (
189
+ <View style={styles.actionsRow}>
190
+ {features.invoices ? (
191
+ <Pressable style={styles.action} onPress={() => router.push(`/(app)/job/${jobId}/invoice`)}>
192
+ <Text style={styles.actionText}>Invoice</Text>
193
+ </Pressable>
194
+ ) : null}
195
+ {features.estimates ? (
196
+ <Pressable
197
+ style={styles.action}
198
+ onPress={() =>
199
+ router.push({
200
+ pathname: '/(app)/estimate',
201
+ params: { customerId: job.customerId, customerName: job.customerName },
202
+ })
203
+ }
204
+ >
205
+ <Text style={styles.actionText}>New estimate</Text>
206
+ </Pressable>
207
+ ) : null}
208
+ </View>
209
+ ) : null}
210
+ </View>
211
+
212
+ <View style={styles.statusRow}>
213
+ <Pressable
214
+ style={[styles.statusBtn, styles.start]}
215
+ onPress={() => statusMutation.mutate('in_progress')}
216
+ >
217
+ <Text style={styles.statusText}>Start job</Text>
218
+ </Pressable>
219
+ <Pressable
220
+ style={[styles.statusBtn, styles.complete]}
221
+ onPress={() => statusMutation.mutate('completed')}
222
+ >
223
+ <Text style={styles.statusText}>Complete</Text>
224
+ </Pressable>
225
+ </View>
226
+
227
+ <SignaturePad visible={signing} onOK={onSignature} onCancel={() => setSigning(false)} />
228
+ </ScrollView>
229
+ );
230
+ }
231
+
232
+ const styles = StyleSheet.create({
233
+ screen: { flex: 1, backgroundColor: theme.bg },
234
+ content: { padding: 16, gap: 8, paddingBottom: 40 },
235
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: theme.bg },
236
+ customer: { fontSize: 24, fontWeight: '700', color: theme.text },
237
+ badge: { alignSelf: 'flex-start', backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border, borderRadius: 999, paddingHorizontal: 10, paddingVertical: 3 },
238
+ badgeText: { fontSize: 12, color: theme.muted, fontWeight: '600' },
239
+ service: { fontSize: 16, color: theme.muted, marginBottom: 4 },
240
+ actionsRow: { flexDirection: 'row', gap: 10, marginVertical: 6 },
241
+ action: { flex: 1, backgroundColor: theme.primary, borderRadius: 10, paddingVertical: 12, alignItems: 'center' },
242
+ actionText: { color: '#fff', fontWeight: '600' },
243
+ section: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border, borderRadius: 12, padding: 14, gap: 6, marginTop: 6 },
244
+ sectionTitle: { fontSize: 13, fontWeight: '700', color: theme.muted, textTransform: 'uppercase' },
245
+ body: { fontSize: 15, color: theme.text },
246
+ muted: { fontSize: 14, color: theme.muted },
247
+ lineItem: { flexDirection: 'row', justifyContent: 'space-between' },
248
+ totalRow: { flexDirection: 'row', justifyContent: 'space-between', borderTopWidth: 1, borderTopColor: theme.border, paddingTop: 6, marginTop: 2 },
249
+ totalLabel: { fontSize: 15, fontWeight: '700', color: theme.text },
250
+ totalValue: { fontSize: 15, fontWeight: '700', color: theme.primary },
251
+ photoGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
252
+ thumb: { width: 72, height: 72, borderRadius: 8, backgroundColor: theme.bg },
253
+ statusRow: { flexDirection: 'row', gap: 10, marginTop: 12 },
254
+ statusBtn: { flex: 1, borderRadius: 10, paddingVertical: 15, alignItems: 'center' },
255
+ start: { backgroundColor: theme.accent },
256
+ complete: { backgroundColor: theme.success },
257
+ clockOn: { backgroundColor: theme.success },
258
+ statusText: { color: '#fff', fontWeight: '700', fontSize: 16 },
259
+ });
@@ -0,0 +1,14 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { Stack } from 'expo-router';
3
+ import { StatusBar } from 'expo-status-bar';
4
+ import { useRef } from 'react';
5
+
6
+ export default function RootLayout() {
7
+ const client = useRef(new QueryClient()).current;
8
+ return (
9
+ <QueryClientProvider client={client}>
10
+ <StatusBar style="dark" />
11
+ <Stack screenOptions={{ headerShown: false }} />
12
+ </QueryClientProvider>
13
+ );
14
+ }
@@ -0,0 +1,23 @@
1
+ import { Redirect } from 'expo-router';
2
+ import { useEffect, useState } from 'react';
3
+ import { ActivityIndicator, View } from 'react-native';
4
+ import { getToken } from '@/lib/auth';
5
+ import { theme } from '@/lib/theme';
6
+
7
+ /** Entry point — route to the app or the sign-in screen based on a stored token. */
8
+ export default function Index() {
9
+ const [state, setState] = useState<'loading' | 'in' | 'out'>('loading');
10
+
11
+ useEffect(() => {
12
+ getToken().then((t) => setState(t ? 'in' : 'out'));
13
+ }, []);
14
+
15
+ if (state === 'loading') {
16
+ return (
17
+ <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: theme.bg }}>
18
+ <ActivityIndicator color={theme.primary} />
19
+ </View>
20
+ );
21
+ }
22
+ return state === 'in' ? <Redirect href="/(app)" /> : <Redirect href="/sign-in" />;
23
+ }
@@ -0,0 +1,101 @@
1
+ import { useRouter } from 'expo-router';
2
+ import { useState } from 'react';
3
+ import {
4
+ ActivityIndicator,
5
+ KeyboardAvoidingView,
6
+ Platform,
7
+ Pressable,
8
+ StyleSheet,
9
+ Text,
10
+ TextInput,
11
+ View,
12
+ } from 'react-native';
13
+ import { SafeAreaView } from 'react-native-safe-area-context';
14
+ import { signIn } from '@/lib/auth';
15
+ import { registerForPush } from '@/lib/push';
16
+ import { appName, theme } from '@/lib/theme';
17
+
18
+ export default function SignIn() {
19
+ const router = useRouter();
20
+ const [email, setEmail] = useState('');
21
+ const [password, setPassword] = useState('');
22
+ const [error, setError] = useState<string | null>(null);
23
+ const [busy, setBusy] = useState(false);
24
+
25
+ async function onSubmit() {
26
+ setError(null);
27
+ setBusy(true);
28
+ try {
29
+ await signIn(email.trim(), password);
30
+ void registerForPush();
31
+ router.replace('/(app)');
32
+ } catch (e) {
33
+ setError(e instanceof Error ? e.message : 'Sign-in failed');
34
+ } finally {
35
+ setBusy(false);
36
+ }
37
+ }
38
+
39
+ return (
40
+ <SafeAreaView style={styles.safe}>
41
+ <KeyboardAvoidingView
42
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
43
+ style={styles.container}
44
+ >
45
+ <Text style={styles.title}>{appName}</Text>
46
+ <Text style={styles.subtitle}>Field technician sign-in</Text>
47
+
48
+ <TextInput
49
+ style={styles.input}
50
+ placeholder="Email"
51
+ autoCapitalize="none"
52
+ keyboardType="email-address"
53
+ value={email}
54
+ onChangeText={setEmail}
55
+ placeholderTextColor={theme.muted}
56
+ />
57
+ <TextInput
58
+ style={styles.input}
59
+ placeholder="Password"
60
+ secureTextEntry
61
+ value={password}
62
+ onChangeText={setPassword}
63
+ placeholderTextColor={theme.muted}
64
+ />
65
+
66
+ {error ? <Text style={styles.error}>{error}</Text> : null}
67
+
68
+ <Pressable style={[styles.button, busy && styles.buttonBusy]} onPress={onSubmit} disabled={busy}>
69
+ {busy ? <ActivityIndicator color="#fff" /> : <Text style={styles.buttonText}>Sign in</Text>}
70
+ </Pressable>
71
+ </KeyboardAvoidingView>
72
+ </SafeAreaView>
73
+ );
74
+ }
75
+
76
+ const styles = StyleSheet.create({
77
+ safe: { flex: 1, backgroundColor: theme.bg },
78
+ container: { flex: 1, justifyContent: 'center', padding: 24, gap: 12 },
79
+ title: { fontSize: 28, fontWeight: '700', color: theme.text, textAlign: 'center' },
80
+ subtitle: { fontSize: 15, color: theme.muted, textAlign: 'center', marginBottom: 16 },
81
+ input: {
82
+ backgroundColor: theme.card,
83
+ borderWidth: 1,
84
+ borderColor: theme.border,
85
+ borderRadius: 10,
86
+ paddingHorizontal: 14,
87
+ paddingVertical: 14,
88
+ fontSize: 16,
89
+ color: theme.text,
90
+ },
91
+ error: { color: theme.danger, fontSize: 14 },
92
+ button: {
93
+ backgroundColor: theme.primary,
94
+ borderRadius: 10,
95
+ paddingVertical: 15,
96
+ alignItems: 'center',
97
+ marginTop: 8,
98
+ },
99
+ buttonBusy: { opacity: 0.7 },
100
+ buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
101
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Per-client branding for the field-tech app. This file is GENERATED by the
3
+ * create-crm-starter `mobile` installer from the business name + colors you
4
+ * entered at scaffold time, then overwritten on each `create-crm-starter add
5
+ * mobile`. Edit the scaffold inputs (not this file) to rebrand.
6
+ */
7
+ export const brand = {
8
+ name: 'Field App',
9
+ slug: 'crm-mobile',
10
+ scheme: 'crmmobile',
11
+ bundleId: 'com.example.crmmobile',
12
+ primaryColor: '#0ea5e9',
13
+ accentColor: '#f59e0b',
14
+ } as const;
@@ -0,0 +1,40 @@
1
+ import type { ExpoConfig } from 'expo/config';
2
+ import { brand } from './app.brand';
3
+
4
+ /**
5
+ * Expo app config. Branding (name, scheme, bundle id, colors) comes from
6
+ * the generated app.brand.ts. The API URL the app talks to is read at
7
+ * runtime from EXPO_PUBLIC_API_URL (set in mobile/.env to your deployed
8
+ * CRM URL — see mobile/README.md).
9
+ */
10
+ const config: ExpoConfig = {
11
+ name: brand.name,
12
+ slug: brand.slug,
13
+ scheme: brand.scheme,
14
+ version: '1.0.0',
15
+ orientation: 'portrait',
16
+ userInterfaceStyle: 'light',
17
+ newArchEnabled: true,
18
+ splash: {
19
+ backgroundColor: brand.primaryColor,
20
+ resizeMode: 'contain',
21
+ },
22
+ ios: {
23
+ bundleIdentifier: brand.bundleId,
24
+ supportsTablet: true,
25
+ infoPlist: {
26
+ NSCameraUsageDescription: 'Take photos of job sites and equipment.',
27
+ NSPhotoLibraryUsageDescription: 'Attach photos to a job.',
28
+ },
29
+ },
30
+ android: {
31
+ package: brand.bundleId,
32
+ },
33
+ plugins: ['expo-router', 'expo-secure-store', 'expo-image-picker', 'expo-notifications'],
34
+ extra: {
35
+ primaryColor: brand.primaryColor,
36
+ accentColor: brand.accentColor,
37
+ },
38
+ };
39
+
40
+ export default config;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Which optional features this client's CRM exposes — GENERATED by the
3
+ * create-crm-starter `mobile` installer from your scaffold choices. The app
4
+ * hides buttons/tabs for features the backend doesn't have. (Checklists +
5
+ * time tracking are always available.)
6
+ */
7
+ export const features = {
8
+ invoices: true,
9
+ estimates: true,
10
+ sms: true,
11
+ } as const;
@@ -0,0 +1,60 @@
1
+ import { useRef } from 'react';
2
+ import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
3
+ import SignatureScreen, { type SignatureViewRef } from 'react-native-signature-canvas';
4
+ import { theme } from '@/lib/theme';
5
+
6
+ /**
7
+ * Full-screen signature capture. Calls onOK with a base64 data URI
8
+ * ("data:image/png;base64,..."). The parent converts + uploads it.
9
+ */
10
+ export function SignaturePad({
11
+ visible,
12
+ onOK,
13
+ onCancel,
14
+ }: {
15
+ visible: boolean;
16
+ onOK: (dataUri: string) => void;
17
+ onCancel: () => void;
18
+ }) {
19
+ const ref = useRef<SignatureViewRef>(null);
20
+
21
+ return (
22
+ <Modal visible={visible} animationType="slide" onRequestClose={onCancel}>
23
+ <View style={styles.container}>
24
+ <Text style={styles.title}>Customer signature</Text>
25
+ <View style={styles.canvas}>
26
+ <SignatureScreen
27
+ ref={ref}
28
+ onOK={onOK}
29
+ webStyle={`.m-signature-pad--footer { display: none; } body,html { height: 100%; }`}
30
+ backgroundColor="#ffffff"
31
+ penColor={theme.text}
32
+ />
33
+ </View>
34
+ <View style={styles.row}>
35
+ <Pressable style={[styles.btn, styles.secondary]} onPress={onCancel}>
36
+ <Text style={styles.secondaryText}>Cancel</Text>
37
+ </Pressable>
38
+ <Pressable style={[styles.btn, styles.secondary]} onPress={() => ref.current?.clearSignature()}>
39
+ <Text style={styles.secondaryText}>Clear</Text>
40
+ </Pressable>
41
+ <Pressable style={[styles.btn, styles.primary]} onPress={() => ref.current?.readSignature()}>
42
+ <Text style={styles.primaryText}>Save</Text>
43
+ </Pressable>
44
+ </View>
45
+ </View>
46
+ </Modal>
47
+ );
48
+ }
49
+
50
+ const styles = StyleSheet.create({
51
+ container: { flex: 1, backgroundColor: theme.bg, padding: 16, gap: 12 },
52
+ title: { fontSize: 18, fontWeight: '700', color: theme.text, marginTop: 32 },
53
+ canvas: { flex: 1, borderWidth: 1, borderColor: theme.border, borderRadius: 12, overflow: 'hidden' },
54
+ row: { flexDirection: 'row', gap: 10 },
55
+ btn: { flex: 1, borderRadius: 10, paddingVertical: 14, alignItems: 'center' },
56
+ primary: { backgroundColor: theme.primary },
57
+ primaryText: { color: '#fff', fontWeight: '600' },
58
+ secondary: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border },
59
+ secondaryText: { color: theme.text, fontWeight: '600' },
60
+ });