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.
- package/README.md +53 -0
- package/dist/index.js +2341 -0
- package/package.json +51 -0
- package/template/base/_dot_gitignore +11 -0
- package/template/base/eslint.config.mjs +7 -0
- package/template/base/next.config.ts +17 -0
- package/template/base/package.json +35 -0
- package/template/base/postcss.config.mjs +7 -0
- package/template/base/scripts/setup.mjs +146 -0
- package/template/base/src/app/globals.css +68 -0
- package/template/base/src/app/layout.tsx +19 -0
- package/template/base/src/app/page.tsx +37 -0
- package/template/base/src/components/ui/badge.tsx +29 -0
- package/template/base/src/components/ui/button.tsx +49 -0
- package/template/base/src/components/ui/card.tsx +54 -0
- package/template/base/src/components/ui/input.tsx +19 -0
- package/template/base/src/components/ui/label.tsx +19 -0
- package/template/base/src/components/ui/separator.tsx +25 -0
- package/template/base/src/components/ui/skeleton.tsx +7 -0
- package/template/base/src/lib/utils.ts +17 -0
- package/template/base/tsconfig.json +21 -0
- package/template/extras/auth-better-auth/src/app/(auth)/layout.tsx +7 -0
- package/template/extras/auth-better-auth/src/app/(auth)/sign-in/page.tsx +65 -0
- package/template/extras/auth-better-auth/src/app/(auth)/sign-up/page.tsx +70 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/admin/page.tsx +68 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/dashboard/page.tsx +42 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/layout.tsx +41 -0
- package/template/extras/auth-better-auth/src/app/api/auth/[...all]/route.ts +4 -0
- package/template/extras/auth-better-auth/src/components/sign-out-button.tsx +19 -0
- package/template/extras/auth-better-auth/src/lib/auth-client.ts +7 -0
- package/template/extras/auth-better-auth/src/lib/auth-server.ts +51 -0
- package/template/extras/auth-better-auth/src/lib/auth.ts +76 -0
- package/template/extras/auth-better-auth/src/middleware.ts +25 -0
- package/template/extras/auth-clerk/src/app/(auth)/layout.tsx +7 -0
- package/template/extras/auth-clerk/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +5 -0
- package/template/extras/auth-clerk/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx +5 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/admin/page.tsx +37 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/dashboard/page.tsx +42 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/layout.tsx +57 -0
- package/template/extras/auth-clerk/src/lib/auth.ts +55 -0
- package/template/extras/auth-clerk/src/middleware.ts +17 -0
- package/template/extras/calendar-dispatch/_shared/src/app/(dashboard)/calendar/page.tsx +39 -0
- package/template/extras/calendar-dispatch/drizzle/src/app/(dashboard)/calendar/page.tsx +21 -0
- package/template/extras/calendar-dispatch/drizzle/src/components/calendar/calendar-board.tsx +195 -0
- package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/actions.ts +35 -0
- package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/data.ts +74 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/[id]/page.tsx +48 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/new/page.tsx +15 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/page.tsx +83 -0
- package/template/extras/checklists/_shared/src/components/jobs/job-checklists-section.tsx +18 -0
- package/template/extras/checklists/_shared/src/lib/checklists/data.ts +17 -0
- package/template/extras/checklists/_shared/src/lib/checklists/sample-data.ts +56 -0
- package/template/extras/checklists/_shared/src/lib/checklists/types.ts +47 -0
- package/template/extras/checklists/drizzle/src/app/(dashboard)/checklists/new/page.tsx +10 -0
- package/template/extras/checklists/drizzle/src/components/checklists/new-template-form.tsx +158 -0
- package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-client.tsx +202 -0
- package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-section.tsx +24 -0
- package/template/extras/checklists/drizzle/src/db/schema/checklists.ts +52 -0
- package/template/extras/checklists/drizzle/src/lib/checklists/actions.ts +112 -0
- package/template/extras/checklists/drizzle/src/lib/checklists/data.ts +80 -0
- package/template/extras/comms-email/src/app/(dashboard)/email/page.tsx +32 -0
- package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/new/page.tsx +35 -0
- package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/page.tsx +55 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/[customerId]/page.tsx +102 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/new/page.tsx +120 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/page.tsx +70 -0
- package/template/extras/comms-sms/drizzle/src/app/api/twilio/sms/route.ts +69 -0
- package/template/extras/comms-sms/drizzle/src/db/schema/sms-messages.ts +27 -0
- package/template/extras/comms-sms/drizzle/src/lib/sms/actions.ts +107 -0
- package/template/extras/comms-sms/drizzle/src/lib/sms/data.ts +111 -0
- package/template/extras/customer-portal/_shared/src/app/portal/[token]/layout.tsx +51 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/appointments/page.tsx +78 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/estimates/page.tsx +94 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/invoices/page.tsx +71 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/page.tsx +118 -0
- package/template/extras/customer-portal/drizzle/src/components/portal/estimate-approval.tsx +141 -0
- package/template/extras/customer-portal/drizzle/src/components/portal/signature-pad.tsx +126 -0
- package/template/extras/customer-portal/drizzle/src/lib/portal/actions.ts +107 -0
- package/template/extras/customer-portal/drizzle/src/lib/portal/data.ts +158 -0
- package/template/extras/customers/_fragments/convex.txt +28 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/[id]/page.tsx +81 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/new/page.tsx +16 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/page.tsx +73 -0
- package/template/extras/customers/_shared/src/lib/customers/data.ts +15 -0
- package/template/extras/customers/_shared/src/lib/customers/sample-data.ts +67 -0
- package/template/extras/customers/_shared/src/lib/customers/types.ts +31 -0
- package/template/extras/customers/convex/convex/customers.ts +52 -0
- package/template/extras/customers/convex/src/lib/customers/data.ts +64 -0
- package/template/extras/customers/drizzle/src/app/(dashboard)/customers/new/page.tsx +82 -0
- package/template/extras/customers/drizzle/src/db/schema/customers.ts +34 -0
- package/template/extras/customers/drizzle/src/lib/customers/actions.ts +67 -0
- package/template/extras/customers/drizzle/src/lib/customers/data.ts +34 -0
- package/template/extras/db-convex/convex/_generated/README.md +13 -0
- package/template/extras/db-convex/convex/_generated/api.d.ts +8 -0
- package/template/extras/db-convex/convex/_generated/api.js +12 -0
- package/template/extras/db-convex/convex/_generated/dataModel.d.ts +9 -0
- package/template/extras/db-convex/convex/_generated/server.d.ts +18 -0
- package/template/extras/db-convex/convex/_generated/server.js +12 -0
- package/template/extras/db-convex/convex/auth.config.ts +17 -0
- package/template/extras/db-convex/convex/schema.ts +28 -0
- package/template/extras/db-convex/src/app/layout.tsx +20 -0
- package/template/extras/db-convex/src/components/providers.tsx +28 -0
- package/template/extras/db-convex/src/lib/convex.ts +6 -0
- package/template/extras/db-drizzle-pg/docker-compose.yml +21 -0
- package/template/extras/db-drizzle-pg/drizzle.config.ts +24 -0
- package/template/extras/db-drizzle-pg/src/app/layout.tsx +20 -0
- package/template/extras/db-drizzle-pg/src/components/providers.tsx +16 -0
- package/template/extras/db-drizzle-pg/src/db/client.ts +14 -0
- package/template/extras/db-drizzle-pg/src/db/schema/auth.ts +62 -0
- package/template/extras/db-drizzle-pg/src/db/schema/index.ts +3 -0
- package/template/extras/emergency-dispatch/src/app/(dashboard)/emergency/page.tsx +43 -0
- package/template/extras/equipment-tracking/src/app/(dashboard)/equipment/page.tsx +61 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/[id]/page.tsx +123 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/new/page.tsx +22 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/page.tsx +102 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/[id]/page.tsx +168 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/page.tsx +100 -0
- package/template/extras/estimates-invoices/_shared/src/components/estimates/convert-to-job-button.tsx +14 -0
- package/template/extras/estimates-invoices/_shared/src/components/invoices/pay-invoice-button.tsx +15 -0
- package/template/extras/estimates-invoices/_shared/src/components/invoices/send-invoice-email-button.tsx +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/data.ts +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/sample-data.ts +74 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/types.ts +60 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/data.ts +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/sample-data.ts +83 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/types.ts +78 -0
- package/template/extras/estimates-invoices/drizzle/src/app/(dashboard)/estimates/new/page.tsx +18 -0
- package/template/extras/estimates-invoices/drizzle/src/app/api/stripe/webhook/route.ts +87 -0
- package/template/extras/estimates-invoices/drizzle/src/app/i/[token]/page.tsx +148 -0
- package/template/extras/estimates-invoices/drizzle/src/components/estimates/convert-to-job-button.tsx +18 -0
- package/template/extras/estimates-invoices/drizzle/src/components/estimates/new-estimate-form.tsx +261 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/pay-invoice-button.tsx +19 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/public-pay-button.tsx +20 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/send-invoice-email-button.tsx +37 -0
- package/template/extras/estimates-invoices/drizzle/src/components/jobs/generate-invoice-button.tsx +23 -0
- package/template/extras/estimates-invoices/drizzle/src/db/schema/estimates.ts +41 -0
- package/template/extras/estimates-invoices/drizzle/src/db/schema/invoices.ts +59 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/estimates/actions.ts +110 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/estimates/data.ts +57 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/actions.ts +199 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/data.ts +99 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/email-actions.ts +102 -0
- package/template/extras/inspection-checklists/src/app/(dashboard)/inspections/page.tsx +60 -0
- package/template/extras/jobs/_fragments/convex.txt +21 -0
- package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/[id]/page.tsx +102 -0
- package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/page.tsx +72 -0
- package/template/extras/jobs/_shared/src/components/jobs/advance-status-button.tsx +21 -0
- package/template/extras/jobs/_shared/src/components/jobs/generate-invoice-button.tsx +15 -0
- package/template/extras/jobs/_shared/src/components/jobs/photo-gallery.tsx +17 -0
- package/template/extras/jobs/_shared/src/components/jobs/photos-section.tsx +18 -0
- package/template/extras/jobs/_shared/src/lib/jobs/data.ts +14 -0
- package/template/extras/jobs/_shared/src/lib/jobs/sample-data.ts +50 -0
- package/template/extras/jobs/_shared/src/lib/jobs/types.ts +62 -0
- package/template/extras/jobs/convex/convex/jobs.ts +46 -0
- package/template/extras/jobs/convex/src/lib/jobs/data.ts +65 -0
- package/template/extras/jobs/drizzle/src/app/(dashboard)/jobs/new/page.tsx +18 -0
- package/template/extras/jobs/drizzle/src/components/jobs/advance-status-button.tsx +34 -0
- package/template/extras/jobs/drizzle/src/components/jobs/new-job-form.tsx +275 -0
- package/template/extras/jobs/drizzle/src/components/jobs/photo-gallery.tsx +130 -0
- package/template/extras/jobs/drizzle/src/components/jobs/photos-section.tsx +7 -0
- package/template/extras/jobs/drizzle/src/db/schema/job-attachments.ts +26 -0
- package/template/extras/jobs/drizzle/src/db/schema/jobs.ts +29 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/actions.ts +71 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/data.ts +48 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/photos-actions.ts +121 -0
- package/template/extras/jobs/drizzle/src/lib/r2.ts +45 -0
- package/template/extras/landing-page/_shared/src/app/book/page.tsx +43 -0
- package/template/extras/landing-page/_shared/src/app/book/thanks/page.tsx +31 -0
- package/template/extras/landing-page/_shared/src/app/page.tsx +81 -0
- package/template/extras/landing-page/drizzle/src/app/book/page.tsx +105 -0
- package/template/extras/landing-page/drizzle/src/lib/booking/actions.ts +97 -0
- package/template/extras/maintenance-plans/src/app/(dashboard)/maintenance-plans/page.tsx +72 -0
- package/template/extras/mobile/mobile/README.md +67 -0
- package/template/extras/mobile/mobile/_dot_env.example +5 -0
- package/template/extras/mobile/mobile/_dot_gitignore +26 -0
- package/template/extras/mobile/mobile/app/(app)/_layout.tsx +37 -0
- package/template/extras/mobile/mobile/app/(app)/estimate.tsx +135 -0
- package/template/extras/mobile/mobile/app/(app)/inbox/[customerId].tsx +103 -0
- package/template/extras/mobile/mobile/app/(app)/inbox/index.tsx +70 -0
- package/template/extras/mobile/mobile/app/(app)/index.tsx +111 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id]/checklist.tsx +99 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id]/invoice.tsx +143 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id].tsx +259 -0
- package/template/extras/mobile/mobile/app/_layout.tsx +14 -0
- package/template/extras/mobile/mobile/app/index.tsx +23 -0
- package/template/extras/mobile/mobile/app/sign-in.tsx +101 -0
- package/template/extras/mobile/mobile/app.brand.ts +14 -0
- package/template/extras/mobile/mobile/app.config.ts +40 -0
- package/template/extras/mobile/mobile/app.features.ts +11 -0
- package/template/extras/mobile/mobile/components/SignaturePad.tsx +60 -0
- package/template/extras/mobile/mobile/eas.json +22 -0
- package/template/extras/mobile/mobile/lib/api.ts +253 -0
- package/template/extras/mobile/mobile/lib/auth.ts +51 -0
- package/template/extras/mobile/mobile/lib/format.ts +23 -0
- package/template/extras/mobile/mobile/lib/push.ts +24 -0
- package/template/extras/mobile/mobile/lib/theme.ts +16 -0
- package/template/extras/mobile/mobile/package.json +34 -0
- package/template/extras/mobile/mobile/tsconfig.json +11 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/customers/[id]/route.ts +18 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/devices/route.ts +40 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/attachments/route.ts +59 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/item/route.ts +34 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/route.ts +15 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/route.ts +35 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/status/route.ts +28 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-in/route.ts +35 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-out/route.ts +27 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/route.ts +36 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/route.ts +30 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/me/route.ts +26 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/uploads/sign/route.ts +46 -0
- package/template/extras/mobile-api/drizzle/src/db/schema/push-tokens.ts +21 -0
- package/template/extras/mobile-api/drizzle/src/db/schema/time-entries.ts +23 -0
- package/template/extras/mobile-api/drizzle/src/lib/mobile/cors.ts +30 -0
- package/template/extras/mobile-api/drizzle/src/lib/mobile/guard.ts +49 -0
- package/template/extras/mobile-api/drizzle/src/lib/push/send.ts +56 -0
- package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/estimates/route.ts +52 -0
- package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/price-book/route.ts +14 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/payments/route.ts +32 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/route.ts +28 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/jobs/[id]/invoice/route.ts +82 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/send/route.ts +32 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/[customerId]/route.ts +15 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/route.ts +14 -0
- package/template/extras/payments-stripe/src/app/(dashboard)/payments/page.tsx +56 -0
- package/template/extras/payments-stripe/src/app/api/stripe/webhook/route.ts +56 -0
- package/template/extras/payments-stripe/src/components/payments/demo-checkout-button.tsx +18 -0
- package/template/extras/payments-stripe/src/lib/payments/actions.ts +63 -0
- package/template/extras/payments-stripe/src/lib/stripe.ts +25 -0
- package/template/extras/permit-tracking/src/app/(dashboard)/permits/page.tsx +56 -0
- package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/new/page.tsx +20 -0
- package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/page.tsx +79 -0
- package/template/extras/price-book/_shared/src/components/price-book/item-picker.tsx +145 -0
- package/template/extras/price-book/_shared/src/lib/price-book/actions.ts +14 -0
- package/template/extras/price-book/_shared/src/lib/price-book/data.ts +28 -0
- package/template/extras/price-book/_shared/src/lib/price-book/sample-data.ts +62 -0
- package/template/extras/price-book/_shared/src/lib/price-book/types.ts +35 -0
- package/template/extras/price-book/drizzle/src/app/(dashboard)/price-book/new/page.tsx +18 -0
- package/template/extras/price-book/drizzle/src/components/price-book/new-item-form.tsx +254 -0
- package/template/extras/price-book/drizzle/src/db/schema/price-book.ts +33 -0
- package/template/extras/price-book/drizzle/src/lib/price-book/actions.ts +72 -0
- package/template/extras/price-book/drizzle/src/lib/price-book/data.ts +81 -0
- package/template/extras/reporting/src/app/(dashboard)/reports/page.tsx +66 -0
- package/template/extras/reviews/src/app/(dashboard)/reviews/page.tsx +58 -0
- package/template/extras/seed/_shared/scripts/seed.ts +35 -0
- package/template/extras/seed/drizzle/scripts/seed.ts +314 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/[id]/page.tsx +114 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/new/page.tsx +18 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/page.tsx +92 -0
- package/template/extras/service-plans/_shared/src/components/service-plans/subscribe-customer-section.tsx +17 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/data.ts +14 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/sample-data.ts +83 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/types.ts +57 -0
- package/template/extras/service-plans/drizzle/src/app/(dashboard)/service-plans/new/page.tsx +10 -0
- package/template/extras/service-plans/drizzle/src/app/api/stripe/webhook/route.ts +143 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/new-plan-form.tsx +126 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-form.tsx +88 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-section.tsx +12 -0
- package/template/extras/service-plans/drizzle/src/db/schema/service-plans.ts +46 -0
- package/template/extras/service-plans/drizzle/src/lib/service-plans/actions.ts +196 -0
- package/template/extras/service-plans/drizzle/src/lib/service-plans/data.ts +124 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cli": {
|
|
3
|
+
"version": ">= 5.0.0",
|
|
4
|
+
"appVersionSource": "remote"
|
|
5
|
+
},
|
|
6
|
+
"build": {
|
|
7
|
+
"development": {
|
|
8
|
+
"developmentClient": true,
|
|
9
|
+
"distribution": "internal"
|
|
10
|
+
},
|
|
11
|
+
"preview": {
|
|
12
|
+
"distribution": "internal",
|
|
13
|
+
"ios": { "simulator": false }
|
|
14
|
+
},
|
|
15
|
+
"production": {
|
|
16
|
+
"autoIncrement": true
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"submit": {
|
|
20
|
+
"production": {}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { apiBaseUrl, getToken } from './auth';
|
|
2
|
+
|
|
3
|
+
/** Shapes returned by the /api/mobile/v1 endpoints (kept in sync by hand). */
|
|
4
|
+
export interface ApiJob {
|
|
5
|
+
id: string;
|
|
6
|
+
customerId: string;
|
|
7
|
+
customerName: string;
|
|
8
|
+
serviceType: string;
|
|
9
|
+
status: string;
|
|
10
|
+
priority: string;
|
|
11
|
+
scheduledAt?: string;
|
|
12
|
+
arrivalWindow?: string;
|
|
13
|
+
assigneeIds: string[];
|
|
14
|
+
lineItems: { description: string; qty: number; unitPrice: number }[];
|
|
15
|
+
total: number;
|
|
16
|
+
notes?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ApiAddress {
|
|
20
|
+
line1: string;
|
|
21
|
+
line2?: string;
|
|
22
|
+
city: string;
|
|
23
|
+
state: string;
|
|
24
|
+
postalCode: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ApiCustomer {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
phones: string[];
|
|
31
|
+
emails: string[];
|
|
32
|
+
serviceAddresses: ApiAddress[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ApiAttachment {
|
|
36
|
+
id: string;
|
|
37
|
+
url: string;
|
|
38
|
+
kind: string;
|
|
39
|
+
contentType: string;
|
|
40
|
+
sizeBytes: number;
|
|
41
|
+
uploadedAt: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ApiChecklistItem {
|
|
45
|
+
id: string;
|
|
46
|
+
label: string;
|
|
47
|
+
kind: string;
|
|
48
|
+
required: boolean;
|
|
49
|
+
completed: boolean;
|
|
50
|
+
value?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ApiChecklist {
|
|
54
|
+
id: string;
|
|
55
|
+
templateName: string;
|
|
56
|
+
items: ApiChecklistItem[];
|
|
57
|
+
allRequiredComplete: boolean;
|
|
58
|
+
progressPct: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ApiTimeState {
|
|
62
|
+
entries: { id: string; clockIn: string; clockOut: string | null }[];
|
|
63
|
+
openEntryId: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ApiInvoiceLine {
|
|
67
|
+
description: string;
|
|
68
|
+
qty: number;
|
|
69
|
+
unitPrice: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ApiInvoice {
|
|
73
|
+
id: string;
|
|
74
|
+
invoiceNumber: string;
|
|
75
|
+
status: string;
|
|
76
|
+
lineItems: ApiInvoiceLine[];
|
|
77
|
+
total: number;
|
|
78
|
+
amountPaid: number;
|
|
79
|
+
amountDue: number;
|
|
80
|
+
payments: { id: string; amount: number; method: string; recordedAt: string }[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ApiPriceBookItem {
|
|
84
|
+
id: string;
|
|
85
|
+
name: string;
|
|
86
|
+
unitPrice: number;
|
|
87
|
+
unitCost: number;
|
|
88
|
+
defaultQty: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ApiPriceBookCategory {
|
|
92
|
+
id: string;
|
|
93
|
+
name: string;
|
|
94
|
+
items: ApiPriceBookItem[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ApiThread {
|
|
98
|
+
customerId: string;
|
|
99
|
+
customerName: string;
|
|
100
|
+
customerPhone: string;
|
|
101
|
+
lastBody: string;
|
|
102
|
+
lastAt: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ApiMessage {
|
|
106
|
+
id: string;
|
|
107
|
+
direction: 'inbound' | 'outbound';
|
|
108
|
+
body: string;
|
|
109
|
+
createdAt: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ApiError extends Error {
|
|
113
|
+
status?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
117
|
+
const token = await getToken();
|
|
118
|
+
const res = await fetch(`${apiBaseUrl}${path}`, {
|
|
119
|
+
...init,
|
|
120
|
+
headers: {
|
|
121
|
+
Accept: 'application/json',
|
|
122
|
+
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
|
|
123
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
124
|
+
...(init?.headers ?? {}),
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
const err: ApiError = new Error(`Request failed (${res.status})`);
|
|
129
|
+
err.status = res.status;
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
return (await res.json()) as T;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const api = {
|
|
136
|
+
me: () =>
|
|
137
|
+
request<{ user: { id: string; email: string; name: string | null; role: string }; business: { name: string; phone?: string; brand: { primary: string; accent: string } } }>(
|
|
138
|
+
'/api/mobile/v1/me',
|
|
139
|
+
),
|
|
140
|
+
jobs: (scope: 'today' | 'mine' | 'all' = 'today') =>
|
|
141
|
+
request<{ jobs: ApiJob[] }>(`/api/mobile/v1/jobs?scope=${scope}`),
|
|
142
|
+
job: (id: string) =>
|
|
143
|
+
request<{ job: ApiJob; attachments: ApiAttachment[]; customer: ApiCustomer | null }>(
|
|
144
|
+
`/api/mobile/v1/jobs/${id}`,
|
|
145
|
+
),
|
|
146
|
+
setStatus: (id: string, status: string) =>
|
|
147
|
+
request<{ ok: true; status: string }>(`/api/mobile/v1/jobs/${id}/status`, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
body: JSON.stringify({ status }),
|
|
150
|
+
}),
|
|
151
|
+
signUpload: (jobId: string, contentType: string, ext?: string) =>
|
|
152
|
+
request<{ uploadUrl: string; key: string; publicUrl: string }>(
|
|
153
|
+
'/api/mobile/v1/uploads/sign',
|
|
154
|
+
{ method: 'POST', body: JSON.stringify({ jobId, contentType, ext }) },
|
|
155
|
+
),
|
|
156
|
+
recordAttachment: (
|
|
157
|
+
jobId: string,
|
|
158
|
+
body: { key: string; url: string; kind: string; contentType: string; sizeBytes: number },
|
|
159
|
+
) =>
|
|
160
|
+
request<{ attachment: { id: string; url: string; kind: string } }>(
|
|
161
|
+
`/api/mobile/v1/jobs/${jobId}/attachments`,
|
|
162
|
+
{ method: 'POST', body: JSON.stringify(body) },
|
|
163
|
+
),
|
|
164
|
+
registerDevice: (token: string, platform = 'ios') =>
|
|
165
|
+
request<{ ok: true }>('/api/mobile/v1/devices', {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
body: JSON.stringify({ token, platform }),
|
|
168
|
+
}),
|
|
169
|
+
|
|
170
|
+
// --- Checklists + time tracking ---
|
|
171
|
+
checklists: (jobId: string) =>
|
|
172
|
+
request<{ checklists: ApiChecklist[] }>(`/api/mobile/v1/jobs/${jobId}/checklists`),
|
|
173
|
+
setChecklistItem: (
|
|
174
|
+
jobId: string,
|
|
175
|
+
body: { jobChecklistId: string; itemId: string; completed?: boolean; value?: string },
|
|
176
|
+
) =>
|
|
177
|
+
request<{ ok: true }>(`/api/mobile/v1/jobs/${jobId}/checklists/item`, {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
body: JSON.stringify(body),
|
|
180
|
+
}),
|
|
181
|
+
time: (jobId: string) => request<ApiTimeState>(`/api/mobile/v1/jobs/${jobId}/time`),
|
|
182
|
+
clockIn: (jobId: string) =>
|
|
183
|
+
request<{ entryId: string }>(`/api/mobile/v1/jobs/${jobId}/time/clock-in`, { method: 'POST', body: '{}' }),
|
|
184
|
+
clockOut: (jobId: string) =>
|
|
185
|
+
request<{ ok: true }>(`/api/mobile/v1/jobs/${jobId}/time/clock-out`, { method: 'POST', body: '{}' }),
|
|
186
|
+
|
|
187
|
+
// --- Invoices + payment ---
|
|
188
|
+
jobInvoice: (jobId: string) =>
|
|
189
|
+
request<{ invoice: ApiInvoice | null; payPath?: string }>(`/api/mobile/v1/jobs/${jobId}/invoice`),
|
|
190
|
+
createInvoiceFromJob: (jobId: string) =>
|
|
191
|
+
request<{ invoiceId: string; publicToken: string; payPath: string }>(
|
|
192
|
+
`/api/mobile/v1/jobs/${jobId}/invoice`,
|
|
193
|
+
{ method: 'POST', body: '{}' },
|
|
194
|
+
),
|
|
195
|
+
invoice: (id: string) =>
|
|
196
|
+
request<{ invoice: ApiInvoice; payPath: string | null }>(`/api/mobile/v1/invoices/${id}`),
|
|
197
|
+
recordPayment: (invoiceId: string, body: { amount: number; method: string; note?: string }) =>
|
|
198
|
+
request<{ ok: true }>(`/api/mobile/v1/invoices/${invoiceId}/payments`, {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
body: JSON.stringify(body),
|
|
201
|
+
}),
|
|
202
|
+
|
|
203
|
+
// --- Estimate builder ---
|
|
204
|
+
priceBook: () => request<{ categories: ApiPriceBookCategory[] }>('/api/mobile/v1/price-book'),
|
|
205
|
+
createEstimate: (body: {
|
|
206
|
+
customerId: string;
|
|
207
|
+
lineItems: { description: string; qty: number; unitPrice: number; unitCost?: number }[];
|
|
208
|
+
notes?: string;
|
|
209
|
+
}) =>
|
|
210
|
+
request<{ estimateId: string }>('/api/mobile/v1/estimates', {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
body: JSON.stringify(body),
|
|
213
|
+
}),
|
|
214
|
+
|
|
215
|
+
// --- SMS inbox ---
|
|
216
|
+
smsThreads: () => request<{ threads: ApiThread[] }>('/api/mobile/v1/sms/threads'),
|
|
217
|
+
smsMessages: (customerId: string) =>
|
|
218
|
+
request<{ messages: ApiMessage[] }>(`/api/mobile/v1/sms/threads/${customerId}`),
|
|
219
|
+
sendSms: (body: { to: string; body: string; customerId?: string }) =>
|
|
220
|
+
request<{ ok: true }>('/api/mobile/v1/sms/send', { method: 'POST', body: JSON.stringify(body) }),
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Uploads a local file URI to R2 via a presigned URL, then records the
|
|
225
|
+
* attachment row. Returns the public URL.
|
|
226
|
+
*/
|
|
227
|
+
export async function uploadJobPhoto(
|
|
228
|
+
jobId: string,
|
|
229
|
+
fileUri: string,
|
|
230
|
+
contentType: string,
|
|
231
|
+
kind: 'photo' | 'signature' = 'photo',
|
|
232
|
+
): Promise<string> {
|
|
233
|
+
const ext = contentType.split('/')[1] ?? 'jpg';
|
|
234
|
+
const { uploadUrl, key, publicUrl } = await api.signUpload(jobId, contentType, ext);
|
|
235
|
+
|
|
236
|
+
const fileRes = await fetch(fileUri);
|
|
237
|
+
const blob = await fileRes.blob();
|
|
238
|
+
const put = await fetch(uploadUrl, {
|
|
239
|
+
method: 'PUT',
|
|
240
|
+
headers: { 'Content-Type': contentType },
|
|
241
|
+
body: blob,
|
|
242
|
+
});
|
|
243
|
+
if (!put.ok) throw new Error(`Upload failed (${put.status})`);
|
|
244
|
+
|
|
245
|
+
await api.recordAttachment(jobId, {
|
|
246
|
+
key,
|
|
247
|
+
url: publicUrl,
|
|
248
|
+
kind,
|
|
249
|
+
contentType,
|
|
250
|
+
sizeBytes: (blob as { size?: number }).size ?? 0,
|
|
251
|
+
});
|
|
252
|
+
return publicUrl;
|
|
253
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as SecureStore from 'expo-secure-store';
|
|
2
|
+
|
|
3
|
+
const TOKEN_KEY = 'crm.session.token';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal bearer-token auth against the CRM's Better-Auth endpoints. The
|
|
7
|
+
* server has the bearer() plugin enabled (added by the mobile-api installer),
|
|
8
|
+
* so sign-in returns a session token in the `set-auth-token` response header.
|
|
9
|
+
* We stash it in the device keychain and send it as `Authorization: Bearer
|
|
10
|
+
* <token>` on every API call.
|
|
11
|
+
*/
|
|
12
|
+
export const apiBaseUrl = (process.env.EXPO_PUBLIC_API_URL ?? '').replace(/\/+$/, '');
|
|
13
|
+
|
|
14
|
+
export async function getToken(): Promise<string | null> {
|
|
15
|
+
return SecureStore.getItemAsync(TOKEN_KEY);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function setToken(token: string): Promise<void> {
|
|
19
|
+
await SecureStore.setItemAsync(TOKEN_KEY, token);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function clearToken(): Promise<void> {
|
|
23
|
+
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function signIn(email: string, password: string): Promise<void> {
|
|
27
|
+
const res = await fetch(`${apiBaseUrl}/api/auth/sign-in/email`, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { 'Content-Type': 'application/json' },
|
|
30
|
+
body: JSON.stringify({ email, password }),
|
|
31
|
+
});
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const msg = await res.text().catch(() => '');
|
|
34
|
+
throw new Error(msg || `Sign-in failed (${res.status})`);
|
|
35
|
+
}
|
|
36
|
+
// Better-Auth bearer plugin returns the token in this header.
|
|
37
|
+
const token = res.headers.get('set-auth-token');
|
|
38
|
+
if (!token) throw new Error('No session token returned. Is the mobile-api enabled on the server?');
|
|
39
|
+
await setToken(token);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function signOut(): Promise<void> {
|
|
43
|
+
const token = await getToken();
|
|
44
|
+
if (token) {
|
|
45
|
+
await fetch(`${apiBaseUrl}/api/auth/sign-out`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
48
|
+
}).catch(() => {});
|
|
49
|
+
}
|
|
50
|
+
await clearToken();
|
|
51
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
import type { ApiAddress } from './api';
|
|
3
|
+
|
|
4
|
+
export function money(cents: number): string {
|
|
5
|
+
return `$${(cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function formatAddress(a?: ApiAddress | null): string {
|
|
9
|
+
if (!a) return '';
|
|
10
|
+
return [a.line1, a.line2, `${a.city}, ${a.state} ${a.postalCode}`].filter(Boolean).join(', ');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Deep link that opens the platform's Maps app with directions. */
|
|
14
|
+
export function mapsUrl(a?: ApiAddress | null): string {
|
|
15
|
+
const q = encodeURIComponent(formatAddress(a));
|
|
16
|
+
return Platform.OS === 'ios'
|
|
17
|
+
? `http://maps.apple.com/?daddr=${q}`
|
|
18
|
+
: `https://www.google.com/maps/dir/?api=1&destination=${q}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function statusLabel(status: string): string {
|
|
22
|
+
return status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
23
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as Notifications from 'expo-notifications';
|
|
2
|
+
import { Platform } from 'react-native';
|
|
3
|
+
import { api } from './api';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Asks for push permission, gets the Expo push token, and registers it with
|
|
7
|
+
* the CRM so the server can notify this tech of new job assignments. Safe to
|
|
8
|
+
* call on every login — best-effort, never throws.
|
|
9
|
+
*/
|
|
10
|
+
export async function registerForPush(): Promise<void> {
|
|
11
|
+
try {
|
|
12
|
+
const { status: existing } = await Notifications.getPermissionsAsync();
|
|
13
|
+
let status = existing;
|
|
14
|
+
if (status !== 'granted') {
|
|
15
|
+
status = (await Notifications.requestPermissionsAsync()).status;
|
|
16
|
+
}
|
|
17
|
+
if (status !== 'granted') return;
|
|
18
|
+
|
|
19
|
+
const tokenData = await Notifications.getExpoPushTokenAsync();
|
|
20
|
+
await api.registerDevice(tokenData.data, Platform.OS);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.warn('[push] registration skipped:', err);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { brand } from '../app.brand';
|
|
2
|
+
|
|
3
|
+
/** App color palette, driven by the per-client brand colors. */
|
|
4
|
+
export const theme = {
|
|
5
|
+
primary: brand.primaryColor,
|
|
6
|
+
accent: brand.accentColor,
|
|
7
|
+
bg: '#f8fafc',
|
|
8
|
+
card: '#ffffff',
|
|
9
|
+
text: '#0f172a',
|
|
10
|
+
muted: '#64748b',
|
|
11
|
+
border: '#e2e8f0',
|
|
12
|
+
danger: '#dc2626',
|
|
13
|
+
success: '#16a34a',
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export const appName = brand.name;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "crm-mobile",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"main": "expo-router/entry",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "expo start",
|
|
8
|
+
"ios": "expo start --ios",
|
|
9
|
+
"android": "expo start --android",
|
|
10
|
+
"typecheck": "tsc --noEmit"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"expo": "~53.0.0",
|
|
14
|
+
"expo-constants": "~17.1.3",
|
|
15
|
+
"expo-image-picker": "~16.1.4",
|
|
16
|
+
"expo-linking": "~7.1.4",
|
|
17
|
+
"expo-notifications": "~0.31.2",
|
|
18
|
+
"expo-router": "~5.0.6",
|
|
19
|
+
"expo-secure-store": "~14.2.3",
|
|
20
|
+
"expo-status-bar": "~2.2.3",
|
|
21
|
+
"expo-web-browser": "~14.1.6",
|
|
22
|
+
"react": "19.0.0",
|
|
23
|
+
"react-native": "0.79.2",
|
|
24
|
+
"react-native-safe-area-context": "5.4.0",
|
|
25
|
+
"react-native-screens": "~4.10.0",
|
|
26
|
+
"react-native-signature-canvas": "^4.7.2",
|
|
27
|
+
"react-native-webview": "13.13.5",
|
|
28
|
+
"@tanstack/react-query": "^5.62.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/react": "~19.0.10",
|
|
32
|
+
"typescript": "~5.8.3"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server';
|
|
2
|
+
import { getCustomer } from '@/lib/customers/data';
|
|
3
|
+
import { json, jsonError, preflight } from '@/lib/mobile/cors';
|
|
4
|
+
import { requireApiUser } from '@/lib/mobile/guard';
|
|
5
|
+
|
|
6
|
+
export const dynamic = 'force-dynamic';
|
|
7
|
+
export const OPTIONS = preflight;
|
|
8
|
+
|
|
9
|
+
/** GET /api/mobile/v1/customers/:id — quick lookup for a job's customer. */
|
|
10
|
+
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
|
11
|
+
const user = await requireApiUser(req, ['technician', 'dispatcher']);
|
|
12
|
+
if (user instanceof Response) return user;
|
|
13
|
+
|
|
14
|
+
const { id } = await params;
|
|
15
|
+
const customer = await getCustomer(id);
|
|
16
|
+
if (!customer) return jsonError(404, 'Customer not found');
|
|
17
|
+
return json({ customer });
|
|
18
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server';
|
|
2
|
+
import { eq } from 'drizzle-orm';
|
|
3
|
+
import { db } from '@/db/client';
|
|
4
|
+
import { pushTokens } from '@/db/schema';
|
|
5
|
+
import { json, jsonError, preflight } from '@/lib/mobile/cors';
|
|
6
|
+
import { requireApiUser } from '@/lib/mobile/guard';
|
|
7
|
+
|
|
8
|
+
export const dynamic = 'force-dynamic';
|
|
9
|
+
export const OPTIONS = preflight;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* POST /api/mobile/v1/devices body: { token, platform? }
|
|
13
|
+
* Registers (or re-owns) an Expo push token for the signed-in user. Called
|
|
14
|
+
* by the app on login after it gets a push permission grant.
|
|
15
|
+
*/
|
|
16
|
+
export async function POST(req: NextRequest) {
|
|
17
|
+
const user = await requireApiUser(req);
|
|
18
|
+
if (user instanceof Response) return user;
|
|
19
|
+
|
|
20
|
+
const body = (await req.json().catch(() => ({}))) as { token?: string; platform?: string };
|
|
21
|
+
if (!body.token) return jsonError(400, 'token is required');
|
|
22
|
+
const platform = body.platform ?? 'ios';
|
|
23
|
+
|
|
24
|
+
const existing = await db
|
|
25
|
+
.select({ id: pushTokens.id })
|
|
26
|
+
.from(pushTokens)
|
|
27
|
+
.where(eq(pushTokens.expoToken, body.token))
|
|
28
|
+
.limit(1);
|
|
29
|
+
|
|
30
|
+
if (existing.length > 0) {
|
|
31
|
+
await db
|
|
32
|
+
.update(pushTokens)
|
|
33
|
+
.set({ userId: user.id, platform })
|
|
34
|
+
.where(eq(pushTokens.expoToken, body.token));
|
|
35
|
+
} else {
|
|
36
|
+
await db.insert(pushTokens).values({ userId: user.id, expoToken: body.token, platform });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return json({ ok: true });
|
|
40
|
+
}
|
package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/attachments/route.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server';
|
|
2
|
+
import { revalidatePath } from 'next/cache';
|
|
3
|
+
import { db } from '@/db/client';
|
|
4
|
+
import { jobAttachments } from '@/db/schema';
|
|
5
|
+
import { getJobAttachments } from '@/lib/jobs/photos-actions';
|
|
6
|
+
import { json, jsonError, preflight } from '@/lib/mobile/cors';
|
|
7
|
+
import { requireApiUser } from '@/lib/mobile/guard';
|
|
8
|
+
|
|
9
|
+
export const dynamic = 'force-dynamic';
|
|
10
|
+
export const OPTIONS = preflight;
|
|
11
|
+
|
|
12
|
+
/** GET /api/mobile/v1/jobs/:id/attachments — list photos/signatures. */
|
|
13
|
+
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
|
14
|
+
const user = await requireApiUser(req, ['technician', 'dispatcher']);
|
|
15
|
+
if (user instanceof Response) return user;
|
|
16
|
+
const { id } = await params;
|
|
17
|
+
return json({ attachments: await getJobAttachments(id) });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* POST /api/mobile/v1/jobs/:id/attachments
|
|
22
|
+
* body: { key, url, kind?, contentType, sizeBytes?, caption? }
|
|
23
|
+
* Records an attachment AFTER the app has uploaded the bytes straight to R2
|
|
24
|
+
* via the presigned URL from POST /uploads/sign. `kind` is 'photo' or
|
|
25
|
+
* 'signature'.
|
|
26
|
+
*/
|
|
27
|
+
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
|
28
|
+
const user = await requireApiUser(req, ['technician', 'dispatcher']);
|
|
29
|
+
if (user instanceof Response) return user;
|
|
30
|
+
|
|
31
|
+
const { id } = await params;
|
|
32
|
+
const body = (await req.json().catch(() => ({}))) as {
|
|
33
|
+
key?: string;
|
|
34
|
+
url?: string;
|
|
35
|
+
kind?: string;
|
|
36
|
+
contentType?: string;
|
|
37
|
+
sizeBytes?: number;
|
|
38
|
+
caption?: string;
|
|
39
|
+
};
|
|
40
|
+
if (!body.key || !body.url || !body.contentType) {
|
|
41
|
+
return jsonError(400, 'key, url and contentType are required');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const [row] = await db
|
|
45
|
+
.insert(jobAttachments)
|
|
46
|
+
.values({
|
|
47
|
+
jobId: id,
|
|
48
|
+
key: body.key,
|
|
49
|
+
url: body.url,
|
|
50
|
+
kind: body.kind ?? 'photo',
|
|
51
|
+
contentType: body.contentType,
|
|
52
|
+
sizeBytes: body.sizeBytes ?? 0,
|
|
53
|
+
caption: body.caption ?? null,
|
|
54
|
+
})
|
|
55
|
+
.returning();
|
|
56
|
+
|
|
57
|
+
revalidatePath(`/jobs/${id}`);
|
|
58
|
+
return json({ attachment: { id: row.id, url: row.url, kind: row.kind } });
|
|
59
|
+
}
|
package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/item/route.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server';
|
|
2
|
+
import { setJobChecklistItem } from '@/lib/checklists/actions';
|
|
3
|
+
import { json, jsonError, preflight } from '@/lib/mobile/cors';
|
|
4
|
+
import { requireApiUser } from '@/lib/mobile/guard';
|
|
5
|
+
|
|
6
|
+
export const dynamic = 'force-dynamic';
|
|
7
|
+
export const OPTIONS = preflight;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* POST /api/mobile/v1/jobs/:id/checklists/item
|
|
11
|
+
* body: { jobChecklistId, itemId, completed?, value? }
|
|
12
|
+
* Toggles a checklist item (or records a text answer) from the field.
|
|
13
|
+
*/
|
|
14
|
+
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
|
15
|
+
const user = await requireApiUser(req, ['technician', 'dispatcher']);
|
|
16
|
+
if (user instanceof Response) return user;
|
|
17
|
+
await params; // jobId is implicit in the checklist; validated server-side
|
|
18
|
+
|
|
19
|
+
const body = (await req.json().catch(() => ({}))) as {
|
|
20
|
+
jobChecklistId?: string;
|
|
21
|
+
itemId?: string;
|
|
22
|
+
completed?: boolean;
|
|
23
|
+
value?: string;
|
|
24
|
+
};
|
|
25
|
+
if (!body.jobChecklistId || !body.itemId) {
|
|
26
|
+
return jsonError(400, 'jobChecklistId and itemId are required');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
await setJobChecklistItem(body.jobChecklistId, body.itemId, {
|
|
30
|
+
completed: body.completed,
|
|
31
|
+
value: body.value,
|
|
32
|
+
});
|
|
33
|
+
return json({ ok: true });
|
|
34
|
+
}
|
package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/route.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server';
|
|
2
|
+
import { getJobChecklists } from '@/lib/checklists/data';
|
|
3
|
+
import { json, preflight } from '@/lib/mobile/cors';
|
|
4
|
+
import { requireApiUser } from '@/lib/mobile/guard';
|
|
5
|
+
|
|
6
|
+
export const dynamic = 'force-dynamic';
|
|
7
|
+
export const OPTIONS = preflight;
|
|
8
|
+
|
|
9
|
+
/** GET /api/mobile/v1/jobs/:id/checklists — checklists attached to a job. */
|
|
10
|
+
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
|
11
|
+
const user = await requireApiUser(req, ['technician', 'dispatcher']);
|
|
12
|
+
if (user instanceof Response) return user;
|
|
13
|
+
const { id } = await params;
|
|
14
|
+
return json({ checklists: await getJobChecklists(id) });
|
|
15
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server';
|
|
2
|
+
import { getCustomer } from '@/lib/customers/data';
|
|
3
|
+
import { getJob } from '@/lib/jobs/data';
|
|
4
|
+
import { getJobAttachments } from '@/lib/jobs/photos-actions';
|
|
5
|
+
import { json, jsonError, preflight } from '@/lib/mobile/cors';
|
|
6
|
+
import { requireApiUser } from '@/lib/mobile/guard';
|
|
7
|
+
|
|
8
|
+
export const dynamic = 'force-dynamic';
|
|
9
|
+
export const OPTIONS = preflight;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GET /api/mobile/v1/jobs/:id
|
|
13
|
+
* Full job detail for the field app: the job, its customer (address +
|
|
14
|
+
* phone for Navigate / Call), and its photo/signature attachments.
|
|
15
|
+
*/
|
|
16
|
+
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
|
17
|
+
const user = await requireApiUser(req, ['technician', 'dispatcher']);
|
|
18
|
+
if (user instanceof Response) return user;
|
|
19
|
+
|
|
20
|
+
const { id } = await params;
|
|
21
|
+
const job = await getJob(id);
|
|
22
|
+
if (!job) return jsonError(404, 'Job not found');
|
|
23
|
+
|
|
24
|
+
// Techs can only open jobs assigned to them.
|
|
25
|
+
if (user.role === 'technician' && !job.assigneeIds.includes(user.id)) {
|
|
26
|
+
return jsonError(403, 'Forbidden');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const [attachments, customer] = await Promise.all([
|
|
30
|
+
getJobAttachments(id),
|
|
31
|
+
getCustomer(job.customerId),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
return json({ job, attachments, customer });
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server';
|
|
2
|
+
import { setJobStatus } from '@/lib/jobs/actions';
|
|
3
|
+
import { JOB_STATUSES, type JobStatus } from '@/lib/jobs/types';
|
|
4
|
+
import { json, jsonError, preflight } from '@/lib/mobile/cors';
|
|
5
|
+
import { requireApiUser } from '@/lib/mobile/guard';
|
|
6
|
+
|
|
7
|
+
export const dynamic = 'force-dynamic';
|
|
8
|
+
export const OPTIONS = preflight;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* POST /api/mobile/v1/jobs/:id/status body: { status }
|
|
12
|
+
* Sets a job's status from the field (Start → in_progress, Complete →
|
|
13
|
+
* completed). Reuses the same `setJobStatus` action the web app uses.
|
|
14
|
+
*/
|
|
15
|
+
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
|
16
|
+
const user = await requireApiUser(req, ['technician', 'dispatcher']);
|
|
17
|
+
if (user instanceof Response) return user;
|
|
18
|
+
|
|
19
|
+
const { id } = await params;
|
|
20
|
+
const body = (await req.json().catch(() => ({}))) as { status?: string };
|
|
21
|
+
const status = body.status as JobStatus | undefined;
|
|
22
|
+
if (!status || !JOB_STATUSES.includes(status)) {
|
|
23
|
+
return jsonError(400, 'Invalid or missing status');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
await setJobStatus(id, status);
|
|
27
|
+
return json({ ok: true, status });
|
|
28
|
+
}
|