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,21 @@
|
|
|
1
|
+
jobs: defineTable({
|
|
2
|
+
customerId: v.id('customers'),
|
|
3
|
+
serviceType: v.string(),
|
|
4
|
+
status: v.union(
|
|
5
|
+
v.literal('lead'), v.literal('estimate'), v.literal('scheduled'),
|
|
6
|
+
v.literal('dispatched'), v.literal('in_progress'), v.literal('completed'),
|
|
7
|
+
v.literal('invoiced'), v.literal('paid'), v.literal('closed'), v.literal('cancelled'),
|
|
8
|
+
),
|
|
9
|
+
priority: v.union(v.literal('low'), v.literal('normal'), v.literal('high'), v.literal('emergency')),
|
|
10
|
+
scheduledAt: v.optional(v.number()),
|
|
11
|
+
arrivalWindow: v.optional(v.string()),
|
|
12
|
+
assigneeIds: v.array(v.id('users')),
|
|
13
|
+
lineItems: v.array(v.object({
|
|
14
|
+
description: v.string(),
|
|
15
|
+
qty: v.number(),
|
|
16
|
+
unitPrice: v.number(),
|
|
17
|
+
})),
|
|
18
|
+
total: v.number(),
|
|
19
|
+
notes: v.optional(v.string()),
|
|
20
|
+
createdAt: v.number(),
|
|
21
|
+
}).index('by_customer', ['customerId']).index('by_status', ['status']),
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
4
|
+
import { Badge } from '@/components/ui/badge';
|
|
5
|
+
import { getJob } from '@/lib/jobs/data';
|
|
6
|
+
import { JOB_STATUS_COLOR, JOB_STATUS_LABEL } from '@/lib/jobs/types';
|
|
7
|
+
import { AdvanceStatusButton } from '@/components/jobs/advance-status-button';
|
|
8
|
+
import { GenerateInvoiceButton } from '@/components/jobs/generate-invoice-button';
|
|
9
|
+
import { JobPhotosSection } from '@/components/jobs/photos-section';
|
|
10
|
+
import { JobChecklistsSection } from '@/components/jobs/job-checklists-section';
|
|
11
|
+
import { formatCurrency } from '@/lib/utils';
|
|
12
|
+
|
|
13
|
+
interface PageProps { params: Promise<{ id: string }>; }
|
|
14
|
+
|
|
15
|
+
export default async function JobDetailPage({ params }: PageProps) {
|
|
16
|
+
const { id } = await params;
|
|
17
|
+
const job = await getJob(id);
|
|
18
|
+
if (!job) notFound();
|
|
19
|
+
return (
|
|
20
|
+
<div className="space-y-6">
|
|
21
|
+
<div>
|
|
22
|
+
<Link href="/jobs" className="text-muted-foreground text-sm hover:underline">← Jobs</Link>
|
|
23
|
+
<div className="mt-1 flex items-center gap-3">
|
|
24
|
+
<h1 className="text-3xl font-bold tracking-tight">{job.serviceType}</h1>
|
|
25
|
+
<span className={`rounded-md px-2 py-1 text-xs font-medium ${JOB_STATUS_COLOR[job.status]}`}>
|
|
26
|
+
{JOB_STATUS_LABEL[job.status]}
|
|
27
|
+
</span>
|
|
28
|
+
{job.priority === 'emergency' && <Badge variant="destructive">EMERGENCY</Badge>}
|
|
29
|
+
<div className="ml-auto flex items-center gap-2">
|
|
30
|
+
<AdvanceStatusButton jobId={job.id} currentStatus={job.status} />
|
|
31
|
+
{(job.status === 'completed' || job.status === 'invoiced' || job.status === 'paid') && (
|
|
32
|
+
<GenerateInvoiceButton jobId={job.id} />
|
|
33
|
+
)}
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
<Link href={`/customers/${job.customerId}`} className="text-muted-foreground hover:underline">
|
|
37
|
+
{job.customerName}
|
|
38
|
+
</Link>
|
|
39
|
+
</div>
|
|
40
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
41
|
+
<Card>
|
|
42
|
+
<CardHeader><CardTitle className="text-base">Schedule</CardTitle></CardHeader>
|
|
43
|
+
<CardContent className="text-sm">
|
|
44
|
+
{job.scheduledAt ? (
|
|
45
|
+
<>
|
|
46
|
+
<div className="font-medium">{new Date(job.scheduledAt).toLocaleString()}</div>
|
|
47
|
+
{job.arrivalWindow && <div className="text-muted-foreground">{job.arrivalWindow}</div>}
|
|
48
|
+
</>
|
|
49
|
+
) : <span className="text-muted-foreground">Not scheduled</span>}
|
|
50
|
+
</CardContent>
|
|
51
|
+
</Card>
|
|
52
|
+
<Card>
|
|
53
|
+
<CardHeader><CardTitle className="text-base">Assigned</CardTitle></CardHeader>
|
|
54
|
+
<CardContent className="text-sm">
|
|
55
|
+
{job.assigneeNames.length > 0 ? job.assigneeNames.join(', ') : <span className="text-muted-foreground">Unassigned</span>}
|
|
56
|
+
</CardContent>
|
|
57
|
+
</Card>
|
|
58
|
+
<Card>
|
|
59
|
+
<CardHeader><CardTitle className="text-base">Total</CardTitle></CardHeader>
|
|
60
|
+
<CardContent className="text-2xl font-bold">{formatCurrency(job.total)}</CardContent>
|
|
61
|
+
</Card>
|
|
62
|
+
</div>
|
|
63
|
+
<Card>
|
|
64
|
+
<CardHeader><CardTitle>Line items</CardTitle></CardHeader>
|
|
65
|
+
<CardContent>
|
|
66
|
+
{job.lineItems.length === 0 ? (
|
|
67
|
+
<p className="text-muted-foreground text-sm">No line items yet.</p>
|
|
68
|
+
) : (
|
|
69
|
+
<table className="w-full text-sm">
|
|
70
|
+
<thead className="text-muted-foreground text-xs uppercase">
|
|
71
|
+
<tr>
|
|
72
|
+
<th className="py-2 text-left font-medium">Description</th>
|
|
73
|
+
<th className="py-2 text-right font-medium">Qty</th>
|
|
74
|
+
<th className="py-2 text-right font-medium">Unit</th>
|
|
75
|
+
<th className="py-2 text-right font-medium">Subtotal</th>
|
|
76
|
+
</tr>
|
|
77
|
+
</thead>
|
|
78
|
+
<tbody className="divide-border divide-y">
|
|
79
|
+
{job.lineItems.map((li, i) => (
|
|
80
|
+
<tr key={i}>
|
|
81
|
+
<td className="py-2">{li.description}</td>
|
|
82
|
+
<td className="py-2 text-right">{li.qty}</td>
|
|
83
|
+
<td className="py-2 text-right">{formatCurrency(li.unitPrice)}</td>
|
|
84
|
+
<td className="py-2 text-right font-medium">{formatCurrency(li.qty * li.unitPrice)}</td>
|
|
85
|
+
</tr>
|
|
86
|
+
))}
|
|
87
|
+
</tbody>
|
|
88
|
+
</table>
|
|
89
|
+
)}
|
|
90
|
+
</CardContent>
|
|
91
|
+
</Card>
|
|
92
|
+
{job.notes && (
|
|
93
|
+
<Card>
|
|
94
|
+
<CardHeader><CardTitle>Notes</CardTitle></CardHeader>
|
|
95
|
+
<CardContent className="text-sm">{job.notes}</CardContent>
|
|
96
|
+
</Card>
|
|
97
|
+
)}
|
|
98
|
+
<JobChecklistsSection jobId={job.id} />
|
|
99
|
+
<JobPhotosSection jobId={job.id} />
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { Card } from '@/components/ui/card';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { getJobs } from '@/lib/jobs/data';
|
|
5
|
+
import { JOB_STATUS_COLOR, JOB_STATUS_LABEL, JOB_STATUSES, type Job, type JobStatus } from '@/lib/jobs/types';
|
|
6
|
+
import { formatCurrency } from '@/lib/utils';
|
|
7
|
+
|
|
8
|
+
export default async function JobsPage() {
|
|
9
|
+
const jobs = await getJobs();
|
|
10
|
+
const byStatus = new Map<JobStatus, Job[]>(JOB_STATUSES.map((s) => [s, []]));
|
|
11
|
+
for (const j of jobs) byStatus.get(j.status)?.push(j);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="space-y-6">
|
|
15
|
+
<div className="flex items-center justify-between">
|
|
16
|
+
<div>
|
|
17
|
+
<h1 className="text-3xl font-bold tracking-tight">Jobs</h1>
|
|
18
|
+
<p className="text-muted-foreground mt-1 text-sm">{jobs.length} jobs across the pipeline</p>
|
|
19
|
+
</div>
|
|
20
|
+
<Button asChild>
|
|
21
|
+
<Link href="/jobs/new">New job</Link>
|
|
22
|
+
</Button>
|
|
23
|
+
</div>
|
|
24
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
|
25
|
+
{JOB_STATUSES.slice(0, 9).map((status) => {
|
|
26
|
+
const jobs = byStatus.get(status) ?? [];
|
|
27
|
+
return (
|
|
28
|
+
<Card key={status} className="flex flex-col gap-2 p-3">
|
|
29
|
+
<div className="flex items-center justify-between text-xs">
|
|
30
|
+
<span className={`rounded-md px-2 py-0.5 font-medium ${JOB_STATUS_COLOR[status]}`}>
|
|
31
|
+
{JOB_STATUS_LABEL[status]}
|
|
32
|
+
</span>
|
|
33
|
+
<span className="text-muted-foreground">{jobs.length}</span>
|
|
34
|
+
</div>
|
|
35
|
+
<div className="flex flex-col gap-2">
|
|
36
|
+
{jobs.length === 0 && (
|
|
37
|
+
<div className="text-muted-foreground py-4 text-center text-xs">No jobs</div>
|
|
38
|
+
)}
|
|
39
|
+
{jobs.map((j) => (
|
|
40
|
+
<Link
|
|
41
|
+
key={j.id}
|
|
42
|
+
href={`/jobs/${j.id}`}
|
|
43
|
+
className="hover:bg-muted/50 rounded-md border p-2 text-xs"
|
|
44
|
+
>
|
|
45
|
+
<div className="font-medium">{j.customerName}</div>
|
|
46
|
+
<div className="text-muted-foreground mt-0.5">{j.serviceType}</div>
|
|
47
|
+
{j.scheduledAt && (
|
|
48
|
+
<div className="text-muted-foreground mt-1 text-[10px]">
|
|
49
|
+
{new Date(j.scheduledAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric' })}
|
|
50
|
+
{j.arrivalWindow && ` · ${j.arrivalWindow}`}
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
{j.total > 0 && (
|
|
54
|
+
<div className="mt-1 text-right font-medium">{formatCurrency(j.total)}</div>
|
|
55
|
+
)}
|
|
56
|
+
{j.priority === 'emergency' && (
|
|
57
|
+
<div className="text-destructive mt-1 text-[10px] font-bold">⚠ EMERGENCY</div>
|
|
58
|
+
)}
|
|
59
|
+
</Link>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
</Card>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</div>
|
|
66
|
+
<p className="text-muted-foreground text-xs">
|
|
67
|
+
Showing placeholder data from <code>src/lib/jobs/sample-data.ts</code>. Drag-drop reassignment lands in the
|
|
68
|
+
calendar-dispatch module.
|
|
69
|
+
</p>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Button } from '@/components/ui/button';
|
|
2
|
+
import type { JobStatus } from '@/lib/jobs/types';
|
|
3
|
+
|
|
4
|
+
interface AdvanceStatusButtonProps {
|
|
5
|
+
jobId: string;
|
|
6
|
+
currentStatus: JobStatus;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default stub — Convex / sample-data version. Disabled, just indicates
|
|
11
|
+
* where the action lives. Drizzle's installer overwrites this with a
|
|
12
|
+
* working version that calls the advanceJobStatus server action.
|
|
13
|
+
*/
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
15
|
+
export function AdvanceStatusButton({ jobId, currentStatus }: AdvanceStatusButtonProps) {
|
|
16
|
+
return (
|
|
17
|
+
<Button size="sm" variant="outline" disabled title="Wire to your stack to enable">
|
|
18
|
+
Advance status
|
|
19
|
+
</Button>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Button } from '@/components/ui/button';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default stub — only really enabled on the Drizzle stack with the
|
|
5
|
+
* estimates-invoices module installed. estimates-invoices installer
|
|
6
|
+
* overwrites this file with a working version.
|
|
7
|
+
*/
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
9
|
+
export function GenerateInvoiceButton({ jobId }: { jobId: string }) {
|
|
10
|
+
return (
|
|
11
|
+
<Button size="sm" variant="outline" disabled title="Install estimates-invoices to enable">
|
|
12
|
+
Generate invoice
|
|
13
|
+
</Button>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default stub for Convex / sample-data. Drizzle's installer overwrites
|
|
5
|
+
* this with a working R2-backed upload + gallery.
|
|
6
|
+
*/
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
8
|
+
export function PhotoGallery({ jobId }: { jobId: string; initialAttachments?: unknown[] }) {
|
|
9
|
+
return (
|
|
10
|
+
<Card>
|
|
11
|
+
<CardHeader><CardTitle>Photos & attachments</CardTitle></CardHeader>
|
|
12
|
+
<CardContent className="text-muted-foreground text-sm">
|
|
13
|
+
File uploads require the Drizzle stack + Cloudflare R2 configured in <code>.env.local</code>.
|
|
14
|
+
</CardContent>
|
|
15
|
+
</Card>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default stub — Drizzle's installer overwrites this with a server
|
|
5
|
+
* component that fetches attachments from Postgres + R2 and renders the
|
|
6
|
+
* PhotoGallery client component.
|
|
7
|
+
*/
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
9
|
+
export function JobPhotosSection({ jobId }: { jobId: string }) {
|
|
10
|
+
return (
|
|
11
|
+
<Card>
|
|
12
|
+
<CardHeader><CardTitle>Photos & attachments</CardTitle></CardHeader>
|
|
13
|
+
<CardContent className="text-muted-foreground text-sm">
|
|
14
|
+
Job photo uploads ship on the Drizzle stack — configure R2 in <code>.env.local</code> to enable.
|
|
15
|
+
</CardContent>
|
|
16
|
+
</Card>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default data-source for jobs — returns hardcoded samples. Stack-specific
|
|
3
|
+
* installers OVERWRITE this file with real DB queries.
|
|
4
|
+
*/
|
|
5
|
+
import { sampleJobs } from './sample-data';
|
|
6
|
+
import type { Job } from './types';
|
|
7
|
+
|
|
8
|
+
export async function getJobs(): Promise<Job[]> {
|
|
9
|
+
return sampleJobs;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function getJob(id: string): Promise<Job | null> {
|
|
13
|
+
return sampleJobs.find((j) => j.id === id) ?? null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Job } from './types';
|
|
2
|
+
|
|
3
|
+
export const sampleJobs: Job[] = [
|
|
4
|
+
{
|
|
5
|
+
id: 'job_001', customerId: 'cus_001', customerName: 'Acme Property Management',
|
|
6
|
+
serviceType: 'Quarterly Maintenance', status: 'scheduled', priority: 'normal',
|
|
7
|
+
scheduledAt: '2026-05-28T14:00:00Z', arrivalWindow: '2-4pm',
|
|
8
|
+
assigneeIds: ['u_tech_1'], assigneeNames: ['Carlos M.'],
|
|
9
|
+
lineItems: [{ description: 'HVAC tune-up (4 units)', qty: 4, unitPrice: 12500 }],
|
|
10
|
+
total: 50000, createdAt: '2026-05-20T09:00:00Z',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
id: 'job_002', customerId: 'cus_002', customerName: 'Jamie Rodriguez',
|
|
14
|
+
serviceType: 'AC Repair', status: 'in_progress', priority: 'high',
|
|
15
|
+
scheduledAt: '2026-05-25T13:00:00Z', arrivalWindow: '1-3pm',
|
|
16
|
+
assigneeIds: ['u_tech_2'], assigneeNames: ['Sarah T.'],
|
|
17
|
+
lineItems: [
|
|
18
|
+
{ description: 'Diagnostic fee', qty: 1, unitPrice: 8900 },
|
|
19
|
+
{ description: 'Capacitor replacement', qty: 1, unitPrice: 18500 },
|
|
20
|
+
],
|
|
21
|
+
total: 27400, createdAt: '2026-05-25T08:15:00Z',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'job_003', customerId: 'cus_003', customerName: 'Mason Hardware Co.',
|
|
25
|
+
serviceType: 'Install — Walk-in Cooler', status: 'estimate', priority: 'normal',
|
|
26
|
+
assigneeIds: [], assigneeNames: [],
|
|
27
|
+
lineItems: [], total: 0, createdAt: '2026-05-22T11:30:00Z',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'job_004', customerId: 'cus_004', customerName: 'Priya Patel',
|
|
31
|
+
serviceType: 'Water Heater Install', status: 'lead', priority: 'normal',
|
|
32
|
+
assigneeIds: [], assigneeNames: [],
|
|
33
|
+
lineItems: [], total: 0, createdAt: '2026-05-23T16:00:00Z',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'job_005', customerId: 'cus_002', customerName: 'Jamie Rodriguez',
|
|
37
|
+
serviceType: 'Spring Tune-Up', status: 'completed', priority: 'normal',
|
|
38
|
+
scheduledAt: '2026-04-15T10:00:00Z', arrivalWindow: '10am-12pm',
|
|
39
|
+
assigneeIds: ['u_tech_1'], assigneeNames: ['Carlos M.'],
|
|
40
|
+
lineItems: [{ description: 'Tune-up', qty: 1, unitPrice: 14500 }],
|
|
41
|
+
total: 14500, createdAt: '2026-04-10T08:00:00Z',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'job_006', customerId: 'cus_001', customerName: 'Acme Property Management',
|
|
45
|
+
serviceType: 'Emergency — No heat', status: 'dispatched', priority: 'emergency',
|
|
46
|
+
scheduledAt: '2026-05-25T11:30:00Z', arrivalWindow: 'ASAP',
|
|
47
|
+
assigneeIds: ['u_tech_2'], assigneeNames: ['Sarah T.'],
|
|
48
|
+
lineItems: [], total: 0, createdAt: '2026-05-25T11:00:00Z',
|
|
49
|
+
},
|
|
50
|
+
];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export const JOB_STATUSES = [
|
|
2
|
+
'lead',
|
|
3
|
+
'estimate',
|
|
4
|
+
'scheduled',
|
|
5
|
+
'dispatched',
|
|
6
|
+
'in_progress',
|
|
7
|
+
'completed',
|
|
8
|
+
'invoiced',
|
|
9
|
+
'paid',
|
|
10
|
+
'closed',
|
|
11
|
+
'cancelled',
|
|
12
|
+
] as const;
|
|
13
|
+
export type JobStatus = (typeof JOB_STATUSES)[number];
|
|
14
|
+
|
|
15
|
+
export const JOB_STATUS_LABEL: Record<JobStatus, string> = {
|
|
16
|
+
lead: 'Lead',
|
|
17
|
+
estimate: 'Estimate',
|
|
18
|
+
scheduled: 'Scheduled',
|
|
19
|
+
dispatched: 'Dispatched',
|
|
20
|
+
in_progress: 'In progress',
|
|
21
|
+
completed: 'Completed',
|
|
22
|
+
invoiced: 'Invoiced',
|
|
23
|
+
paid: 'Paid',
|
|
24
|
+
closed: 'Closed',
|
|
25
|
+
cancelled: 'Cancelled',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const JOB_STATUS_COLOR: Record<JobStatus, string> = {
|
|
29
|
+
lead: 'bg-slate-100 text-slate-700',
|
|
30
|
+
estimate: 'bg-purple-100 text-purple-800',
|
|
31
|
+
scheduled: 'bg-blue-100 text-blue-800',
|
|
32
|
+
dispatched: 'bg-cyan-100 text-cyan-800',
|
|
33
|
+
in_progress: 'bg-amber-100 text-amber-800',
|
|
34
|
+
completed: 'bg-emerald-100 text-emerald-800',
|
|
35
|
+
invoiced: 'bg-indigo-100 text-indigo-800',
|
|
36
|
+
paid: 'bg-green-100 text-green-800',
|
|
37
|
+
closed: 'bg-gray-100 text-gray-700',
|
|
38
|
+
cancelled: 'bg-red-100 text-red-800',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface LineItem {
|
|
42
|
+
description: string;
|
|
43
|
+
qty: number;
|
|
44
|
+
unitPrice: number; // cents
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface Job {
|
|
48
|
+
id: string;
|
|
49
|
+
customerId: string;
|
|
50
|
+
customerName: string;
|
|
51
|
+
serviceType: string;
|
|
52
|
+
status: JobStatus;
|
|
53
|
+
priority: 'low' | 'normal' | 'high' | 'emergency';
|
|
54
|
+
scheduledAt?: string; // ISO
|
|
55
|
+
arrivalWindow?: string; // e.g. "8-10am"
|
|
56
|
+
assigneeIds: string[];
|
|
57
|
+
assigneeNames: string[];
|
|
58
|
+
lineItems: LineItem[];
|
|
59
|
+
notes?: string;
|
|
60
|
+
total: number; // cents
|
|
61
|
+
createdAt: string;
|
|
62
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { v } from 'convex/values';
|
|
2
|
+
import { mutation, query } from './_generated/server';
|
|
3
|
+
|
|
4
|
+
const statusValidator = v.union(
|
|
5
|
+
v.literal('lead'),
|
|
6
|
+
v.literal('estimate'),
|
|
7
|
+
v.literal('scheduled'),
|
|
8
|
+
v.literal('dispatched'),
|
|
9
|
+
v.literal('in_progress'),
|
|
10
|
+
v.literal('completed'),
|
|
11
|
+
v.literal('invoiced'),
|
|
12
|
+
v.literal('paid'),
|
|
13
|
+
v.literal('closed'),
|
|
14
|
+
v.literal('cancelled'),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export const list = query({
|
|
18
|
+
args: {},
|
|
19
|
+
handler: async (ctx: any) => {
|
|
20
|
+
const jobs = await ctx.db.query('jobs').order('desc').take(200);
|
|
21
|
+
return await Promise.all(
|
|
22
|
+
jobs.map(async (j: any) => {
|
|
23
|
+
const customer = await ctx.db.get(j.customerId);
|
|
24
|
+
return { ...j, customerName: customer?.name ?? '(deleted)' };
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const getById = query({
|
|
31
|
+
args: { id: v.id('jobs') },
|
|
32
|
+
handler: async (ctx: any, args: any) => {
|
|
33
|
+
const job = await ctx.db.get(args.id);
|
|
34
|
+
if (!job) return null;
|
|
35
|
+
const customer = await ctx.db.get(job.customerId);
|
|
36
|
+
return { ...job, customerName: customer?.name ?? '(deleted)' };
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const updateStatus = mutation({
|
|
41
|
+
args: { id: v.id('jobs'), status: statusValidator },
|
|
42
|
+
handler: async (ctx: any, args: any) => {
|
|
43
|
+
await ctx.db.patch(args.id, { status: args.status });
|
|
44
|
+
return args.id;
|
|
45
|
+
},
|
|
46
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { fetchQuery } from 'convex/nextjs';
|
|
3
|
+
import { api } from '../../../convex/_generated/api';
|
|
4
|
+
import { sampleJobs } from './sample-data';
|
|
5
|
+
import type { Job, JobStatus, LineItem } from './types';
|
|
6
|
+
|
|
7
|
+
const convexConfigured = (() => {
|
|
8
|
+
const url = process.env.NEXT_PUBLIC_CONVEX_URL;
|
|
9
|
+
return typeof url === 'string' && url.startsWith('http') && !url.includes('placeholder');
|
|
10
|
+
})();
|
|
11
|
+
|
|
12
|
+
interface RawJob {
|
|
13
|
+
_id: string;
|
|
14
|
+
customerId: string;
|
|
15
|
+
customerName: string;
|
|
16
|
+
serviceType: string;
|
|
17
|
+
status: JobStatus;
|
|
18
|
+
priority: Job['priority'];
|
|
19
|
+
scheduledAt?: number;
|
|
20
|
+
arrivalWindow?: string;
|
|
21
|
+
assigneeIds: string[];
|
|
22
|
+
lineItems: LineItem[];
|
|
23
|
+
total: number;
|
|
24
|
+
notes?: string;
|
|
25
|
+
createdAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toJob(row: RawJob): Job {
|
|
29
|
+
return {
|
|
30
|
+
id: row._id,
|
|
31
|
+
customerId: row.customerId,
|
|
32
|
+
customerName: row.customerName,
|
|
33
|
+
serviceType: row.serviceType,
|
|
34
|
+
status: row.status,
|
|
35
|
+
priority: row.priority,
|
|
36
|
+
scheduledAt: row.scheduledAt ? new Date(row.scheduledAt).toISOString() : undefined,
|
|
37
|
+
arrivalWindow: row.arrivalWindow,
|
|
38
|
+
assigneeIds: row.assigneeIds,
|
|
39
|
+
assigneeNames: [],
|
|
40
|
+
lineItems: row.lineItems,
|
|
41
|
+
total: row.total,
|
|
42
|
+
notes: row.notes,
|
|
43
|
+
createdAt: new Date(row.createdAt).toISOString(),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function getJobs(): Promise<Job[]> {
|
|
48
|
+
if (!convexConfigured) return sampleJobs;
|
|
49
|
+
try {
|
|
50
|
+
const rows = (await fetchQuery(api.jobs.list)) as RawJob[] | null;
|
|
51
|
+
return (rows ?? []).map(toJob);
|
|
52
|
+
} catch {
|
|
53
|
+
return sampleJobs;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function getJob(id: string): Promise<Job | null> {
|
|
58
|
+
if (!convexConfigured) return sampleJobs.find((j) => j.id === id) ?? null;
|
|
59
|
+
try {
|
|
60
|
+
const row = (await fetchQuery(api.jobs.getById, { id })) as RawJob | null;
|
|
61
|
+
return row ? toJob(row) : null;
|
|
62
|
+
} catch {
|
|
63
|
+
return sampleJobs.find((j) => j.id === id) ?? null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { asc } from 'drizzle-orm';
|
|
2
|
+
import { db } from '@/db/client';
|
|
3
|
+
import { customers } from '@/db/schema';
|
|
4
|
+
import { NewJobForm } from '@/components/jobs/new-job-form';
|
|
5
|
+
|
|
6
|
+
export default async function NewJobPage() {
|
|
7
|
+
const customerRows = await db
|
|
8
|
+
.select({ id: customers.id, name: customers.name })
|
|
9
|
+
.from(customers)
|
|
10
|
+
.orderBy(asc(customers.name));
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className="mx-auto max-w-3xl space-y-6">
|
|
14
|
+
<h1 className="text-3xl font-bold tracking-tight">New job</h1>
|
|
15
|
+
<NewJobForm customers={customerRows} />
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useTransition } from 'react';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { advanceJobStatus } from '@/lib/jobs/actions';
|
|
6
|
+
import type { JobStatus } from '@/lib/jobs/types';
|
|
7
|
+
import { JOB_STATUSES, JOB_STATUS_LABEL } from '@/lib/jobs/types';
|
|
8
|
+
|
|
9
|
+
interface AdvanceStatusButtonProps {
|
|
10
|
+
jobId: string;
|
|
11
|
+
currentStatus: JobStatus;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function AdvanceStatusButton({ jobId, currentStatus }: AdvanceStatusButtonProps) {
|
|
15
|
+
const [pending, start] = useTransition();
|
|
16
|
+
|
|
17
|
+
const idx = JOB_STATUSES.indexOf(currentStatus);
|
|
18
|
+
const isTerminal = idx >= JOB_STATUSES.length - 2; // closed or cancelled
|
|
19
|
+
const nextStatus = !isTerminal ? JOB_STATUSES[idx + 1] : null;
|
|
20
|
+
|
|
21
|
+
if (isTerminal || !nextStatus) {
|
|
22
|
+
return <Button size="sm" variant="outline" disabled>Pipeline complete</Button>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Button
|
|
27
|
+
size="sm"
|
|
28
|
+
disabled={pending}
|
|
29
|
+
onClick={() => start(() => advanceJobStatus(jobId, currentStatus))}
|
|
30
|
+
>
|
|
31
|
+
{pending ? 'Updating...' : `Advance → ${JOB_STATUS_LABEL[nextStatus]}`}
|
|
32
|
+
</Button>
|
|
33
|
+
);
|
|
34
|
+
}
|