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,56 @@
|
|
|
1
|
+
import type { ChecklistTemplate } from './types';
|
|
2
|
+
|
|
3
|
+
export const sampleTemplates: ChecklistTemplate[] = [
|
|
4
|
+
{
|
|
5
|
+
id: 'tpl_hvac_tune_up',
|
|
6
|
+
name: 'HVAC tune-up',
|
|
7
|
+
description: 'Standard maintenance checklist for annual tune-ups',
|
|
8
|
+
items: [
|
|
9
|
+
{ id: 'i1', label: 'Replace air filter', kind: 'checkbox', required: true, sortOrder: 0 },
|
|
10
|
+
{ id: 'i2', label: 'Clean condenser coil', kind: 'checkbox', required: true, sortOrder: 1 },
|
|
11
|
+
{ id: 'i3', label: 'Check refrigerant pressure', kind: 'text', required: true, sortOrder: 2, helperText: 'PSI reading' },
|
|
12
|
+
{ id: 'i4', label: 'Inspect electrical connections', kind: 'checkbox', required: true, sortOrder: 3 },
|
|
13
|
+
{ id: 'i5', label: 'Photo of completed work', kind: 'photo', required: true, sortOrder: 4 },
|
|
14
|
+
{ id: 'i6', label: 'Customer walkthrough completed', kind: 'checkbox', required: false, sortOrder: 5 },
|
|
15
|
+
],
|
|
16
|
+
itemCount: 6,
|
|
17
|
+
requiredCount: 5,
|
|
18
|
+
attachedJobCount: 7,
|
|
19
|
+
createdAt: '2025-08-15T10:00:00Z',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'tpl_panel_upgrade',
|
|
23
|
+
name: 'Electrical panel upgrade',
|
|
24
|
+
description: 'Code-required inspection items for panel replacement',
|
|
25
|
+
items: [
|
|
26
|
+
{ id: 'p1', label: 'Main breaker rating verified', kind: 'checkbox', required: true, sortOrder: 0 },
|
|
27
|
+
{ id: 'p2', label: 'Grounding electrode bonded', kind: 'checkbox', required: true, sortOrder: 1 },
|
|
28
|
+
{ id: 'p3', label: 'Service entrance conductors sized correctly', kind: 'text', required: true, sortOrder: 2, helperText: 'AWG + ampacity' },
|
|
29
|
+
{ id: 'p4', label: 'Working clearance meets code', kind: 'checkbox', required: true, sortOrder: 3 },
|
|
30
|
+
{ id: 'p5', label: 'GFCI/AFCI protection installed', kind: 'checkbox', required: true, sortOrder: 4 },
|
|
31
|
+
{ id: 'p6', label: 'Panel directory labeled', kind: 'checkbox', required: true, sortOrder: 5 },
|
|
32
|
+
{ id: 'p7', label: 'Permit number posted', kind: 'text', required: true, sortOrder: 6 },
|
|
33
|
+
{ id: 'p8', label: 'Final inspection photo', kind: 'photo', required: true, sortOrder: 7 },
|
|
34
|
+
],
|
|
35
|
+
itemCount: 8,
|
|
36
|
+
requiredCount: 8,
|
|
37
|
+
attachedJobCount: 3,
|
|
38
|
+
createdAt: '2025-09-01T14:00:00Z',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'tpl_plumb_drain',
|
|
42
|
+
name: 'Drain cleaning',
|
|
43
|
+
description: 'Pre/post checks for drain service calls',
|
|
44
|
+
items: [
|
|
45
|
+
{ id: 'd1', label: 'Pre-service photo', kind: 'photo', required: true, sortOrder: 0 },
|
|
46
|
+
{ id: 'd2', label: 'Cleaning method used', kind: 'text', required: true, sortOrder: 1, helperText: 'Auger / hydro-jet / enzyme' },
|
|
47
|
+
{ id: 'd3', label: 'Post-service photo', kind: 'photo', required: true, sortOrder: 2 },
|
|
48
|
+
{ id: 'd4', label: 'Water test passed', kind: 'checkbox', required: true, sortOrder: 3 },
|
|
49
|
+
{ id: 'd5', label: 'Customer notified of any concerns', kind: 'checkbox', required: false, sortOrder: 4 },
|
|
50
|
+
],
|
|
51
|
+
itemCount: 5,
|
|
52
|
+
requiredCount: 4,
|
|
53
|
+
attachedJobCount: 12,
|
|
54
|
+
createdAt: '2025-10-20T09:00:00Z',
|
|
55
|
+
},
|
|
56
|
+
];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const CHECKLIST_ITEM_KINDS = ['checkbox', 'text', 'photo'] as const;
|
|
2
|
+
export type ChecklistItemKind = (typeof CHECKLIST_ITEM_KINDS)[number];
|
|
3
|
+
|
|
4
|
+
export const CHECKLIST_ITEM_KIND_LABEL: Record<ChecklistItemKind, string> = {
|
|
5
|
+
checkbox: 'Checkbox',
|
|
6
|
+
text: 'Text answer',
|
|
7
|
+
photo: 'Photo upload',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export interface ChecklistTemplateItem {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
kind: ChecklistItemKind;
|
|
14
|
+
required: boolean;
|
|
15
|
+
sortOrder: number;
|
|
16
|
+
helperText?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ChecklistTemplate {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
items: ChecklistTemplateItem[];
|
|
24
|
+
itemCount: number; // = items.length, denormalized for list view
|
|
25
|
+
requiredCount: number; // count of items where required=true
|
|
26
|
+
attachedJobCount: number; // how many jobs use this template currently
|
|
27
|
+
createdAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface JobChecklistItem extends ChecklistTemplateItem {
|
|
31
|
+
completed: boolean;
|
|
32
|
+
completedAt?: string;
|
|
33
|
+
/** For kind='text' answers. */
|
|
34
|
+
value?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface JobChecklist {
|
|
38
|
+
id: string;
|
|
39
|
+
jobId: string;
|
|
40
|
+
templateId?: string;
|
|
41
|
+
templateName: string;
|
|
42
|
+
items: JobChecklistItem[];
|
|
43
|
+
/** Computed: all required items completed. Drives the "blocks job finish" UI. */
|
|
44
|
+
allRequiredComplete: boolean;
|
|
45
|
+
/** Computed: total completion percent (0-100). */
|
|
46
|
+
progressPct: number;
|
|
47
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { NewTemplateForm } from '@/components/checklists/new-template-form';
|
|
2
|
+
|
|
3
|
+
export default function NewChecklistTemplatePage() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="mx-auto max-w-3xl space-y-6">
|
|
6
|
+
<h1 className="text-3xl font-bold tracking-tight">New checklist template</h1>
|
|
7
|
+
<NewTemplateForm />
|
|
8
|
+
</div>
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useTransition } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
7
|
+
import { Input } from '@/components/ui/input';
|
|
8
|
+
import { Label } from '@/components/ui/label';
|
|
9
|
+
import { createChecklistTemplate } from '@/lib/checklists/actions';
|
|
10
|
+
import { CHECKLIST_ITEM_KINDS, CHECKLIST_ITEM_KIND_LABEL, type ChecklistItemKind } from '@/lib/checklists/types';
|
|
11
|
+
|
|
12
|
+
interface DraftItem {
|
|
13
|
+
key: string;
|
|
14
|
+
label: string;
|
|
15
|
+
kind: ChecklistItemKind;
|
|
16
|
+
required: boolean;
|
|
17
|
+
helperText: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const NEW_ITEM = (): DraftItem => ({
|
|
21
|
+
key: crypto.randomUUID(),
|
|
22
|
+
label: '',
|
|
23
|
+
kind: 'checkbox',
|
|
24
|
+
required: true,
|
|
25
|
+
helperText: '',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export function NewTemplateForm() {
|
|
29
|
+
const [pending, start] = useTransition();
|
|
30
|
+
const [error, setError] = useState<string | null>(null);
|
|
31
|
+
const [name, setName] = useState('');
|
|
32
|
+
const [description, setDescription] = useState('');
|
|
33
|
+
const [items, setItems] = useState<DraftItem[]>([NEW_ITEM()]);
|
|
34
|
+
|
|
35
|
+
function updateItem(key: string, patch: Partial<DraftItem>) {
|
|
36
|
+
setItems((prev) => prev.map((it) => (it.key === key ? { ...it, ...patch } : it)));
|
|
37
|
+
}
|
|
38
|
+
function removeItem(key: string) {
|
|
39
|
+
setItems((prev) => (prev.length === 1 ? prev : prev.filter((it) => it.key !== key)));
|
|
40
|
+
}
|
|
41
|
+
function addItem() {
|
|
42
|
+
setItems((prev) => [...prev, NEW_ITEM()]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function handleSubmit(e: React.FormEvent) {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
setError(null);
|
|
48
|
+
if (!name.trim()) { setError('Name is required.'); return; }
|
|
49
|
+
const filled = items.filter((it) => it.label.trim());
|
|
50
|
+
if (filled.length === 0) { setError('Add at least one item with a label.'); return; }
|
|
51
|
+
start(async () => {
|
|
52
|
+
try {
|
|
53
|
+
await createChecklistTemplate({
|
|
54
|
+
name,
|
|
55
|
+
description: description || undefined,
|
|
56
|
+
items: filled.map(({ key: _key, ...rest }) => ({
|
|
57
|
+
label: rest.label,
|
|
58
|
+
kind: rest.kind,
|
|
59
|
+
required: rest.required,
|
|
60
|
+
helperText: rest.helperText || undefined,
|
|
61
|
+
})),
|
|
62
|
+
});
|
|
63
|
+
} catch (err) {
|
|
64
|
+
setError((err as Error).message);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<form onSubmit={handleSubmit} className="space-y-6">
|
|
71
|
+
<Card>
|
|
72
|
+
<CardHeader>
|
|
73
|
+
<CardTitle>Template basics</CardTitle>
|
|
74
|
+
<CardDescription>Name and an optional description.</CardDescription>
|
|
75
|
+
</CardHeader>
|
|
76
|
+
<CardContent className="space-y-4">
|
|
77
|
+
<div className="space-y-2">
|
|
78
|
+
<Label htmlFor="name">Name *</Label>
|
|
79
|
+
<Input id="name" required value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. HVAC tune-up" />
|
|
80
|
+
</div>
|
|
81
|
+
<div className="space-y-2">
|
|
82
|
+
<Label htmlFor="description">Description</Label>
|
|
83
|
+
<Input id="description" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="What jobs is this for?" />
|
|
84
|
+
</div>
|
|
85
|
+
</CardContent>
|
|
86
|
+
</Card>
|
|
87
|
+
|
|
88
|
+
<Card>
|
|
89
|
+
<CardHeader>
|
|
90
|
+
<CardTitle>Items</CardTitle>
|
|
91
|
+
<CardDescription>Required items block job "Finished" until completed.</CardDescription>
|
|
92
|
+
</CardHeader>
|
|
93
|
+
<CardContent className="space-y-3">
|
|
94
|
+
{items.map((it) => (
|
|
95
|
+
<div key={it.key} className="border-input space-y-2 rounded-md border p-3">
|
|
96
|
+
<div className="grid grid-cols-[1fr_8rem_2.5rem] gap-2">
|
|
97
|
+
<Input
|
|
98
|
+
value={it.label}
|
|
99
|
+
onChange={(e) => updateItem(it.key, { label: e.target.value })}
|
|
100
|
+
placeholder="Item label"
|
|
101
|
+
/>
|
|
102
|
+
<select
|
|
103
|
+
value={it.kind}
|
|
104
|
+
onChange={(e) => updateItem(it.key, { kind: e.target.value as ChecklistItemKind })}
|
|
105
|
+
className="border-input bg-background h-10 w-full rounded-md border px-2 text-sm"
|
|
106
|
+
>
|
|
107
|
+
{CHECKLIST_ITEM_KINDS.map((k) => (
|
|
108
|
+
<option key={k} value={k}>{CHECKLIST_ITEM_KIND_LABEL[k]}</option>
|
|
109
|
+
))}
|
|
110
|
+
</select>
|
|
111
|
+
<Button
|
|
112
|
+
type="button"
|
|
113
|
+
variant="ghost"
|
|
114
|
+
size="icon"
|
|
115
|
+
onClick={() => removeItem(it.key)}
|
|
116
|
+
disabled={items.length === 1}
|
|
117
|
+
aria-label="Remove"
|
|
118
|
+
>
|
|
119
|
+
✕
|
|
120
|
+
</Button>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="flex items-center justify-between gap-2">
|
|
123
|
+
<Input
|
|
124
|
+
value={it.helperText}
|
|
125
|
+
onChange={(e) => updateItem(it.key, { helperText: e.target.value })}
|
|
126
|
+
placeholder="Helper text (optional)"
|
|
127
|
+
className="text-xs"
|
|
128
|
+
/>
|
|
129
|
+
<label className="flex cursor-pointer items-center gap-2 text-xs whitespace-nowrap">
|
|
130
|
+
<input
|
|
131
|
+
type="checkbox"
|
|
132
|
+
checked={it.required}
|
|
133
|
+
onChange={(e) => updateItem(it.key, { required: e.target.checked })}
|
|
134
|
+
/>
|
|
135
|
+
Required
|
|
136
|
+
</label>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
))}
|
|
140
|
+
<Button type="button" variant="outline" size="sm" onClick={addItem}>
|
|
141
|
+
+ Add item
|
|
142
|
+
</Button>
|
|
143
|
+
</CardContent>
|
|
144
|
+
</Card>
|
|
145
|
+
|
|
146
|
+
{error && <p className="text-destructive text-sm">{error}</p>}
|
|
147
|
+
|
|
148
|
+
<div className="flex justify-end gap-3">
|
|
149
|
+
<Button type="button" variant="outline" asChild>
|
|
150
|
+
<Link href="/checklists">Cancel</Link>
|
|
151
|
+
</Button>
|
|
152
|
+
<Button type="submit" disabled={pending}>
|
|
153
|
+
{pending ? 'Creating…' : 'Create template'}
|
|
154
|
+
</Button>
|
|
155
|
+
</div>
|
|
156
|
+
</form>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useTransition } from 'react';
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
5
|
+
import { Badge } from '@/components/ui/badge';
|
|
6
|
+
import { Button } from '@/components/ui/button';
|
|
7
|
+
import { Input } from '@/components/ui/input';
|
|
8
|
+
import {
|
|
9
|
+
attachChecklistToJob,
|
|
10
|
+
detachJobChecklist,
|
|
11
|
+
setJobChecklistItem,
|
|
12
|
+
} from '@/lib/checklists/actions';
|
|
13
|
+
import type { JobChecklist, JobChecklistItem } from '@/lib/checklists/types';
|
|
14
|
+
|
|
15
|
+
interface ClientProps {
|
|
16
|
+
jobId: string;
|
|
17
|
+
initialAttached: JobChecklist[];
|
|
18
|
+
templates: { id: string; name: string }[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function JobChecklistsClient({ jobId, initialAttached, templates }: ClientProps) {
|
|
22
|
+
const [attached, setAttached] = useState(initialAttached);
|
|
23
|
+
const [pending, start] = useTransition();
|
|
24
|
+
const [error, setError] = useState<string | null>(null);
|
|
25
|
+
const [selectedTemplate, setSelectedTemplate] = useState(templates[0]?.id ?? '');
|
|
26
|
+
|
|
27
|
+
function handleAttach() {
|
|
28
|
+
if (!selectedTemplate) return;
|
|
29
|
+
setError(null);
|
|
30
|
+
start(async () => {
|
|
31
|
+
try {
|
|
32
|
+
await attachChecklistToJob(jobId, selectedTemplate);
|
|
33
|
+
// Optimistic-ish reload — the server revalidated the page so a
|
|
34
|
+
// soft navigation would refresh attached[]. We just clear the
|
|
35
|
+
// picker to indicate the action happened.
|
|
36
|
+
location.reload();
|
|
37
|
+
} catch (err) {
|
|
38
|
+
setError((err as Error).message);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function handleDetach(checklistId: string) {
|
|
44
|
+
if (!confirm('Detach this checklist? Completion state will be lost.')) return;
|
|
45
|
+
start(async () => {
|
|
46
|
+
try {
|
|
47
|
+
await detachJobChecklist(checklistId);
|
|
48
|
+
setAttached((prev) => prev.filter((c) => c.id !== checklistId));
|
|
49
|
+
} catch (err) {
|
|
50
|
+
setError((err as Error).message);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function handleToggle(checklistId: string, item: JobChecklistItem) {
|
|
56
|
+
start(async () => {
|
|
57
|
+
try {
|
|
58
|
+
await setJobChecklistItem(checklistId, item.id, { completed: !item.completed });
|
|
59
|
+
setAttached((prev) =>
|
|
60
|
+
prev.map((c) =>
|
|
61
|
+
c.id !== checklistId
|
|
62
|
+
? c
|
|
63
|
+
: {
|
|
64
|
+
...c,
|
|
65
|
+
items: c.items.map((it) =>
|
|
66
|
+
it.id !== item.id ? it : { ...it, completed: !it.completed },
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
setError((err as Error).message);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function handleTextChange(checklistId: string, item: JobChecklistItem, value: string) {
|
|
78
|
+
setAttached((prev) =>
|
|
79
|
+
prev.map((c) =>
|
|
80
|
+
c.id !== checklistId
|
|
81
|
+
? c
|
|
82
|
+
: { ...c, items: c.items.map((it) => (it.id !== item.id ? it : { ...it, value })) },
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function handleTextBlur(checklistId: string, item: JobChecklistItem) {
|
|
88
|
+
start(async () => {
|
|
89
|
+
try {
|
|
90
|
+
await setJobChecklistItem(checklistId, item.id, { value: item.value ?? '' });
|
|
91
|
+
} catch (err) {
|
|
92
|
+
setError((err as Error).message);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<Card>
|
|
99
|
+
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
|
100
|
+
<CardTitle>Checklists ({attached.length})</CardTitle>
|
|
101
|
+
{templates.length > 0 && (
|
|
102
|
+
<div className="flex items-center gap-2">
|
|
103
|
+
<select
|
|
104
|
+
value={selectedTemplate}
|
|
105
|
+
onChange={(e) => setSelectedTemplate(e.target.value)}
|
|
106
|
+
className="border-input bg-background h-9 rounded-md border px-2 text-sm"
|
|
107
|
+
>
|
|
108
|
+
{templates.map((t) => (
|
|
109
|
+
<option key={t.id} value={t.id}>{t.name}</option>
|
|
110
|
+
))}
|
|
111
|
+
</select>
|
|
112
|
+
<Button size="sm" onClick={handleAttach} disabled={pending}>
|
|
113
|
+
Attach
|
|
114
|
+
</Button>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
</CardHeader>
|
|
118
|
+
<CardContent className="space-y-4">
|
|
119
|
+
{error && <p className="text-destructive text-sm">{error}</p>}
|
|
120
|
+
{attached.length === 0 ? (
|
|
121
|
+
<p className="text-muted-foreground text-sm">
|
|
122
|
+
No checklists attached.{' '}
|
|
123
|
+
{templates.length === 0 && (
|
|
124
|
+
<>Create a template at <code>/checklists/new</code> first.</>
|
|
125
|
+
)}
|
|
126
|
+
</p>
|
|
127
|
+
) : (
|
|
128
|
+
attached.map((cl) => {
|
|
129
|
+
const completedCount = cl.items.filter((i) => i.completed).length;
|
|
130
|
+
return (
|
|
131
|
+
<div key={cl.id} className="border-border space-y-3 rounded-md border p-3">
|
|
132
|
+
<div className="flex items-center justify-between">
|
|
133
|
+
<div>
|
|
134
|
+
<div className="font-semibold">{cl.templateName}</div>
|
|
135
|
+
<div className="text-muted-foreground text-xs">
|
|
136
|
+
{completedCount} / {cl.items.length} complete
|
|
137
|
+
{!cl.allRequiredComplete && (
|
|
138
|
+
<span className="text-destructive ml-2">· Required items pending</span>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
<div className="flex items-center gap-2">
|
|
143
|
+
<Badge variant={cl.allRequiredComplete ? 'default' : 'destructive'}>
|
|
144
|
+
{cl.progressPct}%
|
|
145
|
+
</Badge>
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
onClick={() => handleDetach(cl.id)}
|
|
149
|
+
className="text-muted-foreground hover:text-destructive text-xs underline"
|
|
150
|
+
>
|
|
151
|
+
Detach
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
<div className="space-y-2">
|
|
156
|
+
{[...cl.items]
|
|
157
|
+
.sort((a, b) => a.sortOrder - b.sortOrder)
|
|
158
|
+
.map((item) => (
|
|
159
|
+
<div key={item.id} className="flex items-start gap-3">
|
|
160
|
+
<input
|
|
161
|
+
type="checkbox"
|
|
162
|
+
checked={item.completed}
|
|
163
|
+
onChange={() => handleToggle(cl.id, item)}
|
|
164
|
+
disabled={pending}
|
|
165
|
+
className="mt-1 h-4 w-4 cursor-pointer"
|
|
166
|
+
/>
|
|
167
|
+
<div className="flex-1">
|
|
168
|
+
<div className={`flex flex-wrap items-center gap-2 text-sm ${item.completed ? 'text-muted-foreground line-through' : ''}`}>
|
|
169
|
+
{item.label}
|
|
170
|
+
{item.required && !item.completed && (
|
|
171
|
+
<Badge variant="destructive" className="text-[10px]">required</Badge>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
{item.helperText && (
|
|
175
|
+
<div className="text-muted-foreground mt-0.5 text-xs">{item.helperText}</div>
|
|
176
|
+
)}
|
|
177
|
+
{item.kind === 'text' && (
|
|
178
|
+
<Input
|
|
179
|
+
value={item.value ?? ''}
|
|
180
|
+
onChange={(e) => handleTextChange(cl.id, item, e.target.value)}
|
|
181
|
+
onBlur={() => handleTextBlur(cl.id, item)}
|
|
182
|
+
placeholder="Answer…"
|
|
183
|
+
className="mt-1 text-xs"
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
{item.kind === 'photo' && (
|
|
187
|
+
<div className="text-muted-foreground mt-1 text-[11px]">
|
|
188
|
+
Photo uploads in the Photos section above count toward this item.
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
})
|
|
198
|
+
)}
|
|
199
|
+
</CardContent>
|
|
200
|
+
</Card>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { asc, isNull } from 'drizzle-orm';
|
|
2
|
+
import { db } from '@/db/client';
|
|
3
|
+
import { checklistTemplates } from '@/db/schema';
|
|
4
|
+
import { getJobChecklists } from '@/lib/checklists/data';
|
|
5
|
+
import { JobChecklistsClient } from './job-checklists-client';
|
|
6
|
+
|
|
7
|
+
export async function JobChecklistsSection({ jobId }: { jobId: string }) {
|
|
8
|
+
const [attached, templates] = await Promise.all([
|
|
9
|
+
getJobChecklists(jobId),
|
|
10
|
+
db
|
|
11
|
+
.select({ id: checklistTemplates.id, name: checklistTemplates.name })
|
|
12
|
+
.from(checklistTemplates)
|
|
13
|
+
.where(isNull(checklistTemplates.archivedAt))
|
|
14
|
+
.orderBy(asc(checklistTemplates.name)),
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<JobChecklistsClient
|
|
19
|
+
jobId={jobId}
|
|
20
|
+
initialAttached={attached}
|
|
21
|
+
templates={templates}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { jsonb, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
|
2
|
+
import { jobs } from './jobs';
|
|
3
|
+
|
|
4
|
+
interface ChecklistItemJson {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
kind: 'checkbox' | 'text' | 'photo';
|
|
8
|
+
required: boolean;
|
|
9
|
+
sortOrder: number;
|
|
10
|
+
helperText?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface JobChecklistItemJson extends ChecklistItemJson {
|
|
14
|
+
completed: boolean;
|
|
15
|
+
completedAt?: string;
|
|
16
|
+
value?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reusable templates — admin-editable. items is stored as jsonb because
|
|
21
|
+
* they're effectively static per-template and we never need to query
|
|
22
|
+
* within them. Soft-delete via archivedAt.
|
|
23
|
+
*/
|
|
24
|
+
export const checklistTemplates = pgTable('checklist_templates', {
|
|
25
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
26
|
+
name: text('name').notNull(),
|
|
27
|
+
description: text('description'),
|
|
28
|
+
items: jsonb('items').$type<ChecklistItemJson[]>().notNull().default([]),
|
|
29
|
+
archivedAt: timestamp('archived_at'),
|
|
30
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
31
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Per-job instance — copies the template's items at attach time so
|
|
36
|
+
* template edits don't retroactively change in-flight job checklists.
|
|
37
|
+
* Completion state lives inline on each item.
|
|
38
|
+
*/
|
|
39
|
+
export const jobChecklists = pgTable('job_checklists', {
|
|
40
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
41
|
+
jobId: uuid('job_id').notNull().references(() => jobs.id, { onDelete: 'cascade' }),
|
|
42
|
+
templateId: uuid('template_id').references(() => checklistTemplates.id, { onDelete: 'set null' }),
|
|
43
|
+
templateName: text('template_name').notNull(),
|
|
44
|
+
items: jsonb('items').$type<JobChecklistItemJson[]>().notNull().default([]),
|
|
45
|
+
attachedAt: timestamp('attached_at').notNull().defaultNow(),
|
|
46
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export type ChecklistTemplateRow = typeof checklistTemplates.$inferSelect;
|
|
50
|
+
export type NewChecklistTemplate = typeof checklistTemplates.$inferInsert;
|
|
51
|
+
export type JobChecklistRow = typeof jobChecklists.$inferSelect;
|
|
52
|
+
export type NewJobChecklist = typeof jobChecklists.$inferInsert;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { eq } from 'drizzle-orm';
|
|
5
|
+
import { redirect } from 'next/navigation';
|
|
6
|
+
import { revalidatePath } from 'next/cache';
|
|
7
|
+
import { db } from '@/db/client';
|
|
8
|
+
import { checklistTemplates, jobChecklists } from '@/db/schema';
|
|
9
|
+
import type { ChecklistTemplateItem, JobChecklistItem } from './types';
|
|
10
|
+
|
|
11
|
+
export interface CreateTemplateInput {
|
|
12
|
+
name: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
items: Array<Omit<ChecklistTemplateItem, 'id' | 'sortOrder'>>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function createChecklistTemplate(input: CreateTemplateInput): Promise<void> {
|
|
18
|
+
if (!input.name.trim()) throw new Error('Name is required');
|
|
19
|
+
if (input.items.length === 0) throw new Error('Add at least one item');
|
|
20
|
+
|
|
21
|
+
const items: ChecklistTemplateItem[] = input.items.map((it, i) => ({
|
|
22
|
+
id: crypto.randomBytes(4).toString('hex'),
|
|
23
|
+
label: it.label.trim(),
|
|
24
|
+
kind: it.kind,
|
|
25
|
+
required: it.required,
|
|
26
|
+
helperText: it.helperText?.trim() || undefined,
|
|
27
|
+
sortOrder: i,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
const [row] = await db
|
|
31
|
+
.insert(checklistTemplates)
|
|
32
|
+
.values({
|
|
33
|
+
name: input.name.trim(),
|
|
34
|
+
description: input.description?.trim() || null,
|
|
35
|
+
items,
|
|
36
|
+
})
|
|
37
|
+
.returning({ id: checklistTemplates.id });
|
|
38
|
+
|
|
39
|
+
revalidatePath('/checklists');
|
|
40
|
+
redirect(`/checklists/${row.id}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Server action — attaches a template to a job by copying the template's
|
|
45
|
+
* items into a new job_checklists row with completed=false for each.
|
|
46
|
+
*/
|
|
47
|
+
export async function attachChecklistToJob(jobId: string, templateId: string): Promise<void> {
|
|
48
|
+
const [template] = await db
|
|
49
|
+
.select()
|
|
50
|
+
.from(checklistTemplates)
|
|
51
|
+
.where(eq(checklistTemplates.id, templateId))
|
|
52
|
+
.limit(1);
|
|
53
|
+
if (!template) throw new Error('Template not found');
|
|
54
|
+
|
|
55
|
+
const items: JobChecklistItem[] = (template.items as ChecklistTemplateItem[]).map((it) => ({
|
|
56
|
+
...it,
|
|
57
|
+
completed: false,
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
await db.insert(jobChecklists).values({
|
|
61
|
+
jobId,
|
|
62
|
+
templateId: template.id,
|
|
63
|
+
templateName: template.name,
|
|
64
|
+
items,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
revalidatePath(`/jobs/${jobId}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function setJobChecklistItem(
|
|
71
|
+
jobChecklistId: string,
|
|
72
|
+
itemId: string,
|
|
73
|
+
patch: { completed?: boolean; value?: string },
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const [row] = await db
|
|
76
|
+
.select()
|
|
77
|
+
.from(jobChecklists)
|
|
78
|
+
.where(eq(jobChecklists.id, jobChecklistId))
|
|
79
|
+
.limit(1);
|
|
80
|
+
if (!row) throw new Error('Checklist not found');
|
|
81
|
+
|
|
82
|
+
const items = (row.items as JobChecklistItem[]).map((it) => {
|
|
83
|
+
if (it.id !== itemId) return it;
|
|
84
|
+
const next = { ...it };
|
|
85
|
+
if (patch.completed !== undefined) {
|
|
86
|
+
next.completed = patch.completed;
|
|
87
|
+
next.completedAt = patch.completed ? new Date().toISOString() : undefined;
|
|
88
|
+
}
|
|
89
|
+
if (patch.value !== undefined) {
|
|
90
|
+
next.value = patch.value;
|
|
91
|
+
}
|
|
92
|
+
return next;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await db
|
|
96
|
+
.update(jobChecklists)
|
|
97
|
+
.set({ items, updatedAt: new Date() })
|
|
98
|
+
.where(eq(jobChecklists.id, jobChecklistId));
|
|
99
|
+
|
|
100
|
+
revalidatePath(`/jobs/${row.jobId}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function detachJobChecklist(jobChecklistId: string): Promise<void> {
|
|
104
|
+
const [row] = await db
|
|
105
|
+
.select({ jobId: jobChecklists.jobId })
|
|
106
|
+
.from(jobChecklists)
|
|
107
|
+
.where(eq(jobChecklists.id, jobChecklistId))
|
|
108
|
+
.limit(1);
|
|
109
|
+
if (!row) return;
|
|
110
|
+
await db.delete(jobChecklists).where(eq(jobChecklists.id, jobChecklistId));
|
|
111
|
+
revalidatePath(`/jobs/${row.jobId}`);
|
|
112
|
+
}
|