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