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,18 @@
1
+ import { asc } from 'drizzle-orm';
2
+ import { db } from '@/db/client';
3
+ import { priceBookCategories } from '@/db/schema';
4
+ import { NewPriceBookItemForm } from '@/components/price-book/new-item-form';
5
+
6
+ export default async function NewPriceBookItemPage() {
7
+ const cats = await db
8
+ .select({ id: priceBookCategories.id, name: priceBookCategories.name })
9
+ .from(priceBookCategories)
10
+ .orderBy(asc(priceBookCategories.sortOrder), asc(priceBookCategories.name));
11
+
12
+ return (
13
+ <div className="mx-auto max-w-2xl space-y-6">
14
+ <h1 className="text-3xl font-bold tracking-tight">New price book item</h1>
15
+ <NewPriceBookItemForm categories={cats} />
16
+ </div>
17
+ );
18
+ }
@@ -0,0 +1,254 @@
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 { createPriceBookCategory, createPriceBookItem } from '@/lib/price-book/actions';
10
+ import { PRICE_BOOK_KINDS, PRICE_BOOK_KIND_LABEL, type PriceBookKind } from '@/lib/price-book/types';
11
+ import { formatCurrency } from '@/lib/utils';
12
+
13
+ interface CategoryOption { id: string; name: string; }
14
+
15
+ export function NewPriceBookItemForm({ categories }: { categories: CategoryOption[] }) {
16
+ const [pending, start] = useTransition();
17
+ const [error, setError] = useState<string | null>(null);
18
+
19
+ const [categoryId, setCategoryId] = useState(categories[0]?.id ?? '');
20
+ const [showNewCategory, setShowNewCategory] = useState(categories.length === 0);
21
+ const [newCategoryName, setNewCategoryName] = useState('');
22
+
23
+ const [kind, setKind] = useState<PriceBookKind>('service');
24
+ const [name, setName] = useState('');
25
+ const [description, setDescription] = useState('');
26
+ const [sku, setSku] = useState('');
27
+ const [defaultQty, setDefaultQty] = useState(1);
28
+ const [unitCost, setUnitCost] = useState(0);
29
+ const [unitPrice, setUnitPrice] = useState(0);
30
+ const [taxable, setTaxable] = useState(true);
31
+ const [durationMinutes, setDurationMinutes] = useState<number | ''>('');
32
+
33
+ const marginPct = useMemo(() => {
34
+ if (unitPrice <= 0) return 0;
35
+ return Math.round(((unitPrice - unitCost) / unitPrice) * 100);
36
+ }, [unitCost, unitPrice]);
37
+ const marginColor =
38
+ marginPct < 0 ? 'text-destructive'
39
+ : marginPct < 25 ? 'text-yellow-600'
40
+ : 'text-emerald-600';
41
+
42
+ function handleSubmit(e: React.FormEvent) {
43
+ e.preventDefault();
44
+ setError(null);
45
+ if (!name.trim()) { setError('Name is required.'); return; }
46
+
47
+ start(async () => {
48
+ try {
49
+ let resolvedCategoryId = categoryId;
50
+ if (showNewCategory) {
51
+ if (!newCategoryName.trim()) {
52
+ setError('Enter a category name or pick an existing category.');
53
+ return;
54
+ }
55
+ resolvedCategoryId = await createPriceBookCategory({ name: newCategoryName });
56
+ }
57
+ if (!resolvedCategoryId) {
58
+ setError('Pick a category.');
59
+ return;
60
+ }
61
+ await createPriceBookItem({
62
+ categoryId: resolvedCategoryId,
63
+ kind,
64
+ name,
65
+ description: description || undefined,
66
+ sku: sku || undefined,
67
+ defaultQty,
68
+ unitCost,
69
+ unitPrice,
70
+ taxable,
71
+ durationMinutes: durationMinutes === '' ? undefined : Number(durationMinutes),
72
+ });
73
+ } catch (err) {
74
+ setError((err as Error).message);
75
+ }
76
+ });
77
+ }
78
+
79
+ return (
80
+ <form onSubmit={handleSubmit} className="space-y-6">
81
+ <Card>
82
+ <CardHeader>
83
+ <CardTitle>Category</CardTitle>
84
+ <CardDescription>Pick an existing category or create a new one.</CardDescription>
85
+ </CardHeader>
86
+ <CardContent className="space-y-3">
87
+ {!showNewCategory && categories.length > 0 && (
88
+ <>
89
+ <select
90
+ value={categoryId}
91
+ onChange={(e) => setCategoryId(e.target.value)}
92
+ className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm"
93
+ >
94
+ {categories.map((c) => (
95
+ <option key={c.id} value={c.id}>{c.name}</option>
96
+ ))}
97
+ </select>
98
+ <button
99
+ type="button"
100
+ onClick={() => setShowNewCategory(true)}
101
+ className="text-brand text-xs underline"
102
+ >
103
+ + New category
104
+ </button>
105
+ </>
106
+ )}
107
+ {showNewCategory && (
108
+ <div className="space-y-2">
109
+ <Input
110
+ value={newCategoryName}
111
+ onChange={(e) => setNewCategoryName(e.target.value)}
112
+ placeholder="e.g. Plumbing — Drain"
113
+ autoFocus
114
+ />
115
+ {categories.length > 0 && (
116
+ <button
117
+ type="button"
118
+ onClick={() => setShowNewCategory(false)}
119
+ className="text-muted-foreground text-xs underline"
120
+ >
121
+ Pick an existing category instead
122
+ </button>
123
+ )}
124
+ </div>
125
+ )}
126
+ </CardContent>
127
+ </Card>
128
+
129
+ <Card>
130
+ <CardHeader><CardTitle>Item</CardTitle></CardHeader>
131
+ <CardContent className="space-y-4">
132
+ <div className="space-y-2">
133
+ <Label htmlFor="kind">Kind</Label>
134
+ <select
135
+ id="kind"
136
+ value={kind}
137
+ onChange={(e) => setKind(e.target.value as PriceBookKind)}
138
+ className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm"
139
+ >
140
+ {PRICE_BOOK_KINDS.map((k) => (
141
+ <option key={k} value={k}>{PRICE_BOOK_KIND_LABEL[k]}</option>
142
+ ))}
143
+ </select>
144
+ </div>
145
+ <div className="space-y-2">
146
+ <Label htmlFor="name">Name *</Label>
147
+ <Input id="name" required value={name} onChange={(e) => setName(e.target.value)} />
148
+ </div>
149
+ <div className="space-y-2">
150
+ <Label htmlFor="description">Description</Label>
151
+ <textarea
152
+ id="description"
153
+ rows={2}
154
+ value={description}
155
+ onChange={(e) => setDescription(e.target.value)}
156
+ 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"
157
+ />
158
+ </div>
159
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
160
+ <div className="space-y-2">
161
+ <Label htmlFor="sku">SKU</Label>
162
+ <Input id="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
163
+ </div>
164
+ <div className="space-y-2">
165
+ <Label htmlFor="defaultQty">Default qty</Label>
166
+ <Input
167
+ id="defaultQty"
168
+ type="number"
169
+ min={1}
170
+ value={defaultQty}
171
+ onChange={(e) => setDefaultQty(Number(e.target.value) || 1)}
172
+ />
173
+ </div>
174
+ </div>
175
+ </CardContent>
176
+ </Card>
177
+
178
+ <Card>
179
+ <CardHeader className="flex flex-row items-start justify-between space-y-0">
180
+ <div>
181
+ <CardTitle>Pricing</CardTitle>
182
+ <CardDescription>Values in cents. e.g. 12500 = $125.00</CardDescription>
183
+ </div>
184
+ <div className="text-right">
185
+ <div className="text-muted-foreground text-xs uppercase">Margin</div>
186
+ <div className={`text-2xl font-bold ${marginColor}`}>{marginPct}%</div>
187
+ <div className="text-muted-foreground text-xs">
188
+ {formatCurrency(unitPrice - unitCost)}
189
+ </div>
190
+ </div>
191
+ </CardHeader>
192
+ <CardContent className="space-y-4">
193
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
194
+ <div className="space-y-2">
195
+ <Label htmlFor="unitCost">Unit cost (¢)</Label>
196
+ <Input
197
+ id="unitCost"
198
+ type="number"
199
+ min={0}
200
+ value={unitCost}
201
+ onChange={(e) => setUnitCost(Number(e.target.value) || 0)}
202
+ />
203
+ </div>
204
+ <div className="space-y-2">
205
+ <Label htmlFor="unitPrice">Unit price (¢)</Label>
206
+ <Input
207
+ id="unitPrice"
208
+ type="number"
209
+ min={0}
210
+ value={unitPrice}
211
+ onChange={(e) => setUnitPrice(Number(e.target.value) || 0)}
212
+ />
213
+ </div>
214
+ </div>
215
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
216
+ <div className="space-y-2">
217
+ <Label htmlFor="durationMinutes">Duration (minutes) — optional</Label>
218
+ <Input
219
+ id="durationMinutes"
220
+ type="number"
221
+ min={0}
222
+ value={durationMinutes}
223
+ onChange={(e) => setDurationMinutes(e.target.value === '' ? '' : Number(e.target.value))}
224
+ placeholder="e.g. 60"
225
+ />
226
+ </div>
227
+ <div className="flex items-end space-y-2">
228
+ <label className="flex cursor-pointer items-center gap-2 text-sm">
229
+ <input
230
+ type="checkbox"
231
+ checked={taxable}
232
+ onChange={(e) => setTaxable(e.target.checked)}
233
+ className="h-4 w-4"
234
+ />
235
+ Taxable
236
+ </label>
237
+ </div>
238
+ </div>
239
+ </CardContent>
240
+ </Card>
241
+
242
+ {error && <p className="text-destructive text-sm">{error}</p>}
243
+
244
+ <div className="flex justify-end gap-3">
245
+ <Button type="button" variant="outline" asChild>
246
+ <Link href="/price-book">Cancel</Link>
247
+ </Button>
248
+ <Button type="submit" disabled={pending}>
249
+ {pending ? 'Creating...' : 'Create item'}
250
+ </Button>
251
+ </div>
252
+ </form>
253
+ );
254
+ }
@@ -0,0 +1,33 @@
1
+ import { boolean, integer, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
2
+
3
+ export const priceBookKind = pgEnum('price_book_kind', ['service', 'material', 'labor']);
4
+
5
+ export const priceBookCategories = pgTable('price_book_categories', {
6
+ id: uuid('id').primaryKey().defaultRandom(),
7
+ name: text('name').notNull(),
8
+ sortOrder: integer('sort_order').notNull().default(0),
9
+ createdAt: timestamp('created_at').notNull().defaultNow(),
10
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
11
+ });
12
+
13
+ export const priceBookItems = pgTable('price_book_items', {
14
+ id: uuid('id').primaryKey().defaultRandom(),
15
+ categoryId: uuid('category_id').notNull().references(() => priceBookCategories.id, { onDelete: 'restrict' }),
16
+ kind: priceBookKind('kind').notNull().default('service'),
17
+ name: text('name').notNull(),
18
+ description: text('description'),
19
+ sku: text('sku'),
20
+ defaultQty: integer('default_qty').notNull().default(1),
21
+ unitCost: integer('unit_cost').notNull().default(0), // cents
22
+ unitPrice: integer('unit_price').notNull().default(0), // cents
23
+ taxable: boolean('taxable').notNull().default(true),
24
+ durationMinutes: integer('duration_minutes'),
25
+ archivedAt: timestamp('archived_at'),
26
+ createdAt: timestamp('created_at').notNull().defaultNow(),
27
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
28
+ });
29
+
30
+ export type PriceBookCategoryRow = typeof priceBookCategories.$inferSelect;
31
+ export type NewPriceBookCategory = typeof priceBookCategories.$inferInsert;
32
+ export type PriceBookItemRow = typeof priceBookItems.$inferSelect;
33
+ export type NewPriceBookItem = typeof priceBookItems.$inferInsert;
@@ -0,0 +1,72 @@
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 { priceBookCategories, priceBookItems } from '@/db/schema';
8
+ import { searchPriceBookItems } from './data';
9
+ import type { PriceBookItem, PriceBookKind } from './types';
10
+
11
+ /**
12
+ * Server-callable search wrapper used by the autocomplete picker on the
13
+ * new-job and new-estimate forms. Imported by client components.
14
+ */
15
+ export async function searchPriceBookItemsAction(query: string): Promise<PriceBookItem[]> {
16
+ return searchPriceBookItems(query);
17
+ }
18
+
19
+ export interface CreateCategoryInput {
20
+ name: string;
21
+ sortOrder?: number;
22
+ }
23
+
24
+ export async function createPriceBookCategory(input: CreateCategoryInput): Promise<string> {
25
+ if (!input.name.trim()) throw new Error('Category name is required');
26
+ const [row] = await db
27
+ .insert(priceBookCategories)
28
+ .values({ name: input.name.trim(), sortOrder: input.sortOrder ?? 999 })
29
+ .returning({ id: priceBookCategories.id });
30
+ revalidatePath('/price-book');
31
+ return row.id;
32
+ }
33
+
34
+ export interface CreatePriceBookItemInput {
35
+ categoryId: string;
36
+ kind: PriceBookKind;
37
+ name: string;
38
+ description?: string;
39
+ sku?: string;
40
+ defaultQty: number;
41
+ unitCost: number; // cents
42
+ unitPrice: number; // cents
43
+ taxable: boolean;
44
+ durationMinutes?: number;
45
+ }
46
+
47
+ export async function createPriceBookItem(input: CreatePriceBookItemInput): Promise<void> {
48
+ if (!input.categoryId) throw new Error('Category is required');
49
+ if (!input.name.trim()) throw new Error('Item name is required');
50
+ await db.insert(priceBookItems).values({
51
+ categoryId: input.categoryId,
52
+ kind: input.kind,
53
+ name: input.name.trim(),
54
+ description: input.description?.trim() || null,
55
+ sku: input.sku?.trim() || null,
56
+ defaultQty: input.defaultQty,
57
+ unitCost: input.unitCost,
58
+ unitPrice: input.unitPrice,
59
+ taxable: input.taxable,
60
+ durationMinutes: input.durationMinutes ?? null,
61
+ });
62
+ revalidatePath('/price-book');
63
+ redirect('/price-book');
64
+ }
65
+
66
+ export async function archivePriceBookItem(id: string): Promise<void> {
67
+ await db
68
+ .update(priceBookItems)
69
+ .set({ archivedAt: new Date(), updatedAt: new Date() })
70
+ .where(eq(priceBookItems.id, id));
71
+ revalidatePath('/price-book');
72
+ }
@@ -0,0 +1,81 @@
1
+ import { and, asc, eq, ilike, isNull, or } from 'drizzle-orm';
2
+ import { db } from '@/db/client';
3
+ import { priceBookCategories, priceBookItems } from '@/db/schema';
4
+ import type { PriceBookCategoryWithItems, PriceBookItem, PriceBookKind } from './types';
5
+
6
+ function toItem(
7
+ row: typeof priceBookItems.$inferSelect,
8
+ categoryName: string,
9
+ ): PriceBookItem {
10
+ const marginPct = row.unitPrice > 0
11
+ ? Math.round(((row.unitPrice - row.unitCost) / row.unitPrice) * 100)
12
+ : 0;
13
+ return {
14
+ id: row.id,
15
+ categoryId: row.categoryId,
16
+ categoryName,
17
+ kind: row.kind as PriceBookKind,
18
+ name: row.name,
19
+ description: row.description ?? undefined,
20
+ sku: row.sku ?? undefined,
21
+ defaultQty: row.defaultQty,
22
+ unitCost: row.unitCost,
23
+ unitPrice: row.unitPrice,
24
+ taxable: row.taxable,
25
+ durationMinutes: row.durationMinutes ?? undefined,
26
+ marginPct,
27
+ };
28
+ }
29
+
30
+ export async function getPriceBook(): Promise<PriceBookCategoryWithItems[]> {
31
+ const cats = await db
32
+ .select()
33
+ .from(priceBookCategories)
34
+ .orderBy(asc(priceBookCategories.sortOrder), asc(priceBookCategories.name));
35
+
36
+ const items = await db
37
+ .select()
38
+ .from(priceBookItems)
39
+ .where(isNull(priceBookItems.archivedAt))
40
+ .orderBy(asc(priceBookItems.name));
41
+
42
+ return cats.map((c) => ({
43
+ id: c.id,
44
+ name: c.name,
45
+ sortOrder: c.sortOrder,
46
+ items: items.filter((i) => i.categoryId === c.id).map((i) => toItem(i, c.name)),
47
+ }));
48
+ }
49
+
50
+ export async function getPriceBookItems(): Promise<PriceBookItem[]> {
51
+ const rows = await db
52
+ .select({ item: priceBookItems, categoryName: priceBookCategories.name })
53
+ .from(priceBookItems)
54
+ .leftJoin(priceBookCategories, eq(priceBookItems.categoryId, priceBookCategories.id))
55
+ .where(isNull(priceBookItems.archivedAt))
56
+ .orderBy(asc(priceBookItems.name));
57
+ return rows.map((r) => toItem(r.item, r.categoryName ?? ''));
58
+ }
59
+
60
+ export async function searchPriceBookItems(query: string): Promise<PriceBookItem[]> {
61
+ const q = query.trim();
62
+ if (!q) return [];
63
+ const pattern = `%${q}%`;
64
+ const rows = await db
65
+ .select({ item: priceBookItems, categoryName: priceBookCategories.name })
66
+ .from(priceBookItems)
67
+ .leftJoin(priceBookCategories, eq(priceBookItems.categoryId, priceBookCategories.id))
68
+ .where(
69
+ and(
70
+ isNull(priceBookItems.archivedAt),
71
+ or(
72
+ ilike(priceBookItems.name, pattern),
73
+ ilike(priceBookItems.description, pattern),
74
+ ilike(priceBookItems.sku, pattern),
75
+ ),
76
+ ),
77
+ )
78
+ .orderBy(asc(priceBookItems.name))
79
+ .limit(20);
80
+ return rows.map((r) => toItem(r.item, r.categoryName ?? ''));
81
+ }
@@ -0,0 +1,66 @@
1
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
2
+ import { formatCurrency } from '@/lib/utils';
3
+
4
+ const KPI = [
5
+ { label: 'Revenue this month', value: 1287400, trend: '+12%' },
6
+ { label: 'Open A/R', value: 248500, trend: '-4%' },
7
+ { label: 'Jobs completed', value: null, raw: 47, trend: '+8%' },
8
+ { label: 'Avg ticket', value: 27400, trend: '+3%' },
9
+ ];
10
+
11
+ const TECH = [
12
+ { name: 'Carlos M.', jobs: 18, revenue: 412000 },
13
+ { name: 'Sarah T.', jobs: 15, revenue: 387000 },
14
+ { name: 'Alex K.', jobs: 14, revenue: 488400 },
15
+ ];
16
+
17
+ export default function ReportsPage() {
18
+ return (
19
+ <div className="space-y-6">
20
+ <div>
21
+ <h1 className="text-3xl font-bold tracking-tight">Reports</h1>
22
+ <p className="text-muted-foreground mt-1 text-sm">
23
+ KPIs at a glance. Add Recharts visualizations in <code>src/components/charts/</code>.
24
+ </p>
25
+ </div>
26
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
27
+ {KPI.map((k) => (
28
+ <Card key={k.label}>
29
+ <CardHeader>
30
+ <CardDescription>{k.label}</CardDescription>
31
+ <CardTitle className="text-3xl">
32
+ {k.value !== null ? formatCurrency(k.value) : k.raw}
33
+ </CardTitle>
34
+ </CardHeader>
35
+ <CardContent className="text-muted-foreground text-xs">{k.trend} vs last month</CardContent>
36
+ </Card>
37
+ ))}
38
+ </div>
39
+ <Card>
40
+ <CardHeader><CardTitle>Technician performance</CardTitle></CardHeader>
41
+ <CardContent className="p-0">
42
+ <table className="w-full text-sm">
43
+ <thead className="bg-muted/50 text-muted-foreground text-xs uppercase">
44
+ <tr>
45
+ <th className="px-4 py-3 text-left font-medium">Technician</th>
46
+ <th className="px-4 py-3 text-right font-medium">Jobs</th>
47
+ <th className="px-4 py-3 text-right font-medium">Revenue</th>
48
+ <th className="px-4 py-3 text-right font-medium">Avg ticket</th>
49
+ </tr>
50
+ </thead>
51
+ <tbody className="divide-border divide-y">
52
+ {TECH.map((t) => (
53
+ <tr key={t.name}>
54
+ <td className="px-4 py-3 font-medium">{t.name}</td>
55
+ <td className="px-4 py-3 text-right">{t.jobs}</td>
56
+ <td className="px-4 py-3 text-right">{formatCurrency(t.revenue)}</td>
57
+ <td className="px-4 py-3 text-right">{formatCurrency(Math.round(t.revenue / t.jobs))}</td>
58
+ </tr>
59
+ ))}
60
+ </tbody>
61
+ </table>
62
+ </CardContent>
63
+ </Card>
64
+ </div>
65
+ );
66
+ }
@@ -0,0 +1,58 @@
1
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
2
+ import { Badge } from '@/components/ui/badge';
3
+ import { formatDate } from '@/lib/utils';
4
+
5
+ const REVIEWS = [
6
+ { id: 'r1', customer: 'Jamie Rodriguez', stars: 5, text: 'Showed up on time, fixed the AC in 30 minutes.', source: 'Google', at: '2026-05-18' },
7
+ { id: 'r2', customer: 'Acme Property Mgmt', stars: 5, text: 'Reliable maintenance, great communication.', source: 'Internal', at: '2026-05-10' },
8
+ { id: 'r3', customer: 'Priya Patel', stars: 4, text: 'Honest estimate, professional.', source: 'Yelp', at: '2026-05-02' },
9
+ ];
10
+
11
+ const REQUESTS = [
12
+ { customer: 'Mason Hardware Co.', status: 'sent', at: '2026-05-22' },
13
+ { customer: 'Acme Property Mgmt', status: 'opened', at: '2026-05-20' },
14
+ { customer: 'Jamie Rodriguez', status: 'submitted', at: '2026-05-18' },
15
+ ];
16
+
17
+ export default function ReviewsPage() {
18
+ return (
19
+ <div className="space-y-6">
20
+ <div>
21
+ <h1 className="text-3xl font-bold tracking-tight">Reviews</h1>
22
+ <p className="text-muted-foreground mt-1 text-sm">Auto-request after job completion. Aggregate across Google, Yelp, Facebook.</p>
23
+ </div>
24
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
25
+ <Card>
26
+ <CardHeader><CardTitle>Recent reviews</CardTitle></CardHeader>
27
+ <CardContent className="space-y-3">
28
+ {REVIEWS.map((r) => (
29
+ <div key={r.id} className="border-b pb-3 last:border-b-0">
30
+ <div className="flex items-center justify-between">
31
+ <div className="font-medium">{r.customer}</div>
32
+ <Badge variant="outline">{r.source}</Badge>
33
+ </div>
34
+ <div className="text-brand mt-1 text-sm">{'★'.repeat(r.stars)}{'☆'.repeat(5 - r.stars)}</div>
35
+ <p className="mt-1 text-sm">{r.text}</p>
36
+ <p className="text-muted-foreground mt-1 text-xs">{formatDate(r.at)}</p>
37
+ </div>
38
+ ))}
39
+ </CardContent>
40
+ </Card>
41
+ <Card>
42
+ <CardHeader><CardTitle>Pending requests</CardTitle></CardHeader>
43
+ <CardContent className="space-y-2 text-sm">
44
+ {REQUESTS.map((r, i) => (
45
+ <div key={i} className="flex items-center justify-between border-b pb-2 last:border-b-0">
46
+ <div>{r.customer}</div>
47
+ <div className="flex items-center gap-2">
48
+ <Badge variant="secondary">{r.status}</Badge>
49
+ <span className="text-muted-foreground text-xs">{formatDate(r.at)}</span>
50
+ </div>
51
+ </div>
52
+ ))}
53
+ </CardContent>
54
+ </Card>
55
+ </div>
56
+ </div>
57
+ );
58
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Seed script — populates demo data into your DB.
3
+ *
4
+ * For Convex stack: this file is a placeholder. Run `npx convex import` with
5
+ * the JSON fixtures in `convex/_fixtures/`, or write a Convex mutation that
6
+ * imports faker data.
7
+ *
8
+ * For Drizzle stack: imports the db client and inserts directly. Run with
9
+ * pnpm tsx scripts/seed.ts
10
+ */
11
+ import { faker } from '@faker-js/faker';
12
+
13
+ faker.seed(42); // deterministic so screenshots stay stable
14
+
15
+ async function main() {
16
+ console.log('[seed] Generating demo data with faker.seed(42)...');
17
+ // TODO: wire to your DB client here.
18
+ // Example (Drizzle):
19
+ // import { db } from '../src/db/client';
20
+ // import { customers } from '../src/db/schema';
21
+ // for (let i = 0; i < 20; i++) {
22
+ // await db.insert(customers).values({
23
+ // name: faker.company.name(),
24
+ // phones: [faker.phone.number()],
25
+ // emails: [faker.internet.email()],
26
+ // ...
27
+ // });
28
+ // }
29
+ console.log('[seed] Done. (This is a stub — fill in inserts for your stack.)');
30
+ }
31
+
32
+ main().catch((err) => {
33
+ console.error(err);
34
+ process.exit(1);
35
+ });