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