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,141 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useTransition } from 'react';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
6
|
+
import { Input } from '@/components/ui/input';
|
|
7
|
+
import { Label } from '@/components/ui/label';
|
|
8
|
+
import { approveEstimateByPortal, declineEstimateByPortal } from '@/lib/portal/actions';
|
|
9
|
+
import { formatCurrency } from '@/lib/utils';
|
|
10
|
+
import { SignaturePad } from './signature-pad';
|
|
11
|
+
|
|
12
|
+
interface EstimateApprovalProps {
|
|
13
|
+
portalToken: string;
|
|
14
|
+
estimateId: string;
|
|
15
|
+
total: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function EstimateApproval({ portalToken, estimateId, total }: EstimateApprovalProps) {
|
|
19
|
+
const [mode, setMode] = useState<'idle' | 'approving' | 'declining'>('idle');
|
|
20
|
+
const [pending, start] = useTransition();
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
const [signerName, setSignerName] = useState('');
|
|
23
|
+
const [signatureDataUrl, setSignatureDataUrl] = useState('');
|
|
24
|
+
const [declineReason, setDeclineReason] = useState('');
|
|
25
|
+
|
|
26
|
+
function handleApprove() {
|
|
27
|
+
setError(null);
|
|
28
|
+
if (!signerName.trim()) { setError('Type your full name.'); return; }
|
|
29
|
+
if (!signatureDataUrl) { setError('Please draw your signature.'); return; }
|
|
30
|
+
start(async () => {
|
|
31
|
+
try {
|
|
32
|
+
await approveEstimateByPortal({
|
|
33
|
+
portalToken,
|
|
34
|
+
estimateId,
|
|
35
|
+
signatureDataUrl,
|
|
36
|
+
signerName,
|
|
37
|
+
});
|
|
38
|
+
} catch (err) {
|
|
39
|
+
setError((err as Error).message);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function handleDecline() {
|
|
45
|
+
setError(null);
|
|
46
|
+
start(async () => {
|
|
47
|
+
try {
|
|
48
|
+
await declineEstimateByPortal({
|
|
49
|
+
portalToken,
|
|
50
|
+
estimateId,
|
|
51
|
+
reason: declineReason || undefined,
|
|
52
|
+
});
|
|
53
|
+
} catch (err) {
|
|
54
|
+
setError((err as Error).message);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (mode === 'idle') {
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex flex-wrap items-center gap-3 border-t pt-4">
|
|
62
|
+
<Button onClick={() => setMode('approving')} size="lg">
|
|
63
|
+
Approve & sign · {formatCurrency(total)}
|
|
64
|
+
</Button>
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={() => setMode('declining')}
|
|
68
|
+
className="text-muted-foreground hover:text-foreground text-sm underline"
|
|
69
|
+
>
|
|
70
|
+
Decline
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (mode === 'declining') {
|
|
77
|
+
return (
|
|
78
|
+
<Card className="border-t-2 mt-4">
|
|
79
|
+
<CardHeader>
|
|
80
|
+
<CardTitle className="text-base">Decline this estimate</CardTitle>
|
|
81
|
+
<CardDescription>Let us know why if you'd like — it's optional.</CardDescription>
|
|
82
|
+
</CardHeader>
|
|
83
|
+
<CardContent className="space-y-3">
|
|
84
|
+
<textarea
|
|
85
|
+
value={declineReason}
|
|
86
|
+
onChange={(e) => setDeclineReason(e.target.value)}
|
|
87
|
+
rows={3}
|
|
88
|
+
placeholder="Reason (optional)"
|
|
89
|
+
className="border-input bg-background focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2"
|
|
90
|
+
/>
|
|
91
|
+
{error && <p className="text-destructive text-sm">{error}</p>}
|
|
92
|
+
<div className="flex justify-end gap-2">
|
|
93
|
+
<Button type="button" variant="outline" onClick={() => setMode('idle')} disabled={pending}>
|
|
94
|
+
Back
|
|
95
|
+
</Button>
|
|
96
|
+
<Button type="button" onClick={handleDecline} variant="destructive" disabled={pending}>
|
|
97
|
+
{pending ? 'Sending…' : 'Decline estimate'}
|
|
98
|
+
</Button>
|
|
99
|
+
</div>
|
|
100
|
+
</CardContent>
|
|
101
|
+
</Card>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<Card className="border-brand border-t-2 mt-4">
|
|
107
|
+
<CardHeader>
|
|
108
|
+
<CardTitle className="text-base">Approve {formatCurrency(total)}</CardTitle>
|
|
109
|
+
<CardDescription>
|
|
110
|
+
By typing your name and signing below, you authorize this work to begin.
|
|
111
|
+
</CardDescription>
|
|
112
|
+
</CardHeader>
|
|
113
|
+
<CardContent className="space-y-4">
|
|
114
|
+
<div className="space-y-2">
|
|
115
|
+
<Label htmlFor="signerName">Type your full name *</Label>
|
|
116
|
+
<Input
|
|
117
|
+
id="signerName"
|
|
118
|
+
value={signerName}
|
|
119
|
+
onChange={(e) => setSignerName(e.target.value)}
|
|
120
|
+
placeholder="e.g. Jamie Rodriguez"
|
|
121
|
+
autoComplete="name"
|
|
122
|
+
required
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
<div className="space-y-2">
|
|
126
|
+
<Label>Signature *</Label>
|
|
127
|
+
<SignaturePad onChange={setSignatureDataUrl} />
|
|
128
|
+
</div>
|
|
129
|
+
{error && <p className="text-destructive text-sm">{error}</p>}
|
|
130
|
+
<div className="flex justify-end gap-2">
|
|
131
|
+
<Button type="button" variant="outline" onClick={() => setMode('idle')} disabled={pending}>
|
|
132
|
+
Cancel
|
|
133
|
+
</Button>
|
|
134
|
+
<Button type="button" onClick={handleApprove} disabled={pending}>
|
|
135
|
+
{pending ? 'Submitting…' : 'Approve & sign'}
|
|
136
|
+
</Button>
|
|
137
|
+
</div>
|
|
138
|
+
</CardContent>
|
|
139
|
+
</Card>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
interface SignaturePadProps {
|
|
6
|
+
/** Called whenever the user finishes a stroke, with the current data URL. */
|
|
7
|
+
onChange: (dataUrl: string) => void;
|
|
8
|
+
/** Width of the drawing area in CSS pixels. */
|
|
9
|
+
width?: number;
|
|
10
|
+
/** Height of the drawing area in CSS pixels. */
|
|
11
|
+
height?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Minimal canvas-based signature pad. No external dependency. Supports
|
|
16
|
+
* mouse + touch + pointer events. High-DPI aware via devicePixelRatio
|
|
17
|
+
* scaling so strokes look crisp on retina.
|
|
18
|
+
*
|
|
19
|
+
* Strokes are rendered as smoothed black lines; the parent can clear /
|
|
20
|
+
* read the value via the imperative methods exposed through the ref.
|
|
21
|
+
*/
|
|
22
|
+
export function SignaturePad({ onChange, width = 480, height = 160 }: SignaturePadProps) {
|
|
23
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
24
|
+
const drawing = useRef(false);
|
|
25
|
+
const [hasInk, setHasInk] = useState(false);
|
|
26
|
+
|
|
27
|
+
const resize = useCallback(() => {
|
|
28
|
+
const canvas = canvasRef.current;
|
|
29
|
+
if (!canvas) return;
|
|
30
|
+
const ratio = window.devicePixelRatio || 1;
|
|
31
|
+
canvas.width = width * ratio;
|
|
32
|
+
canvas.height = height * ratio;
|
|
33
|
+
canvas.style.width = `${width}px`;
|
|
34
|
+
canvas.style.height = `${height}px`;
|
|
35
|
+
const ctx = canvas.getContext('2d');
|
|
36
|
+
if (!ctx) return;
|
|
37
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0); // reset before scale
|
|
38
|
+
ctx.scale(ratio, ratio);
|
|
39
|
+
ctx.lineWidth = 2;
|
|
40
|
+
ctx.lineCap = 'round';
|
|
41
|
+
ctx.lineJoin = 'round';
|
|
42
|
+
ctx.strokeStyle = '#000';
|
|
43
|
+
}, [width, height]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => { resize(); }, [resize]);
|
|
46
|
+
|
|
47
|
+
function getPoint(e: PointerEvent | React.PointerEvent<HTMLCanvasElement>): [number, number] {
|
|
48
|
+
const canvas = canvasRef.current;
|
|
49
|
+
if (!canvas) return [0, 0];
|
|
50
|
+
const rect = canvas.getBoundingClientRect();
|
|
51
|
+
return [
|
|
52
|
+
(e as PointerEvent).clientX - rect.left,
|
|
53
|
+
(e as PointerEvent).clientY - rect.top,
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function start(e: React.PointerEvent<HTMLCanvasElement>) {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
const canvas = canvasRef.current;
|
|
60
|
+
const ctx = canvas?.getContext('2d');
|
|
61
|
+
if (!ctx) return;
|
|
62
|
+
const [x, y] = getPoint(e);
|
|
63
|
+
drawing.current = true;
|
|
64
|
+
ctx.beginPath();
|
|
65
|
+
ctx.moveTo(x, y);
|
|
66
|
+
setHasInk(true);
|
|
67
|
+
canvas?.setPointerCapture(e.pointerId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function move(e: React.PointerEvent<HTMLCanvasElement>) {
|
|
71
|
+
if (!drawing.current) return;
|
|
72
|
+
const ctx = canvasRef.current?.getContext('2d');
|
|
73
|
+
if (!ctx) return;
|
|
74
|
+
const [x, y] = getPoint(e);
|
|
75
|
+
ctx.lineTo(x, y);
|
|
76
|
+
ctx.stroke();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function end() {
|
|
80
|
+
if (!drawing.current) return;
|
|
81
|
+
drawing.current = false;
|
|
82
|
+
const canvas = canvasRef.current;
|
|
83
|
+
if (canvas) onChange(canvas.toDataURL('image/png'));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function clear() {
|
|
87
|
+
const canvas = canvasRef.current;
|
|
88
|
+
const ctx = canvas?.getContext('2d');
|
|
89
|
+
if (!canvas || !ctx) return;
|
|
90
|
+
const ratio = window.devicePixelRatio || 1;
|
|
91
|
+
ctx.clearRect(0, 0, canvas.width / ratio, canvas.height / ratio);
|
|
92
|
+
setHasInk(false);
|
|
93
|
+
onChange('');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="space-y-2">
|
|
98
|
+
<div className="relative">
|
|
99
|
+
<canvas
|
|
100
|
+
ref={canvasRef}
|
|
101
|
+
onPointerDown={start}
|
|
102
|
+
onPointerMove={move}
|
|
103
|
+
onPointerUp={end}
|
|
104
|
+
onPointerLeave={end}
|
|
105
|
+
onPointerCancel={end}
|
|
106
|
+
className="border-input bg-background w-full touch-none rounded-md border"
|
|
107
|
+
/>
|
|
108
|
+
{!hasInk && (
|
|
109
|
+
<div className="text-muted-foreground pointer-events-none absolute inset-0 flex items-center justify-center text-xs">
|
|
110
|
+
Sign here
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
<div className="flex items-center justify-between text-xs">
|
|
115
|
+
<span className="text-muted-foreground">Use your finger, mouse, or stylus.</span>
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
onClick={clear}
|
|
119
|
+
className="text-muted-foreground hover:text-foreground underline"
|
|
120
|
+
>
|
|
121
|
+
Clear
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { and, eq } from 'drizzle-orm';
|
|
4
|
+
import { revalidatePath } from 'next/cache';
|
|
5
|
+
import { db } from '@/db/client';
|
|
6
|
+
import { customers, estimates } from '@/db/schema';
|
|
7
|
+
|
|
8
|
+
interface PortalEstimateContext {
|
|
9
|
+
portalToken: string;
|
|
10
|
+
estimateId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Verifies that the given estimateId belongs to the customer identified
|
|
15
|
+
* by portalToken. Returns the resolved customer + estimate, or throws.
|
|
16
|
+
* This is the security boundary for portal write actions.
|
|
17
|
+
*/
|
|
18
|
+
async function authorizePortalEstimate({ portalToken, estimateId }: PortalEstimateContext) {
|
|
19
|
+
const [row] = await db
|
|
20
|
+
.select({ customer: customers, estimate: estimates })
|
|
21
|
+
.from(customers)
|
|
22
|
+
.innerJoin(estimates, eq(estimates.customerId, customers.id))
|
|
23
|
+
.where(and(eq(customers.publicToken, portalToken), eq(estimates.id, estimateId)))
|
|
24
|
+
.limit(1);
|
|
25
|
+
if (!row) throw new Error('Estimate not found or does not belong to this portal account.');
|
|
26
|
+
return row;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ApproveEstimateInput {
|
|
30
|
+
portalToken: string;
|
|
31
|
+
estimateId: string;
|
|
32
|
+
/** Customer's drawn signature as a data: URL (PNG). */
|
|
33
|
+
signatureDataUrl: string;
|
|
34
|
+
/** Customer types their full name as an additional acknowledgment. */
|
|
35
|
+
signerName: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function approveEstimateByPortal(input: ApproveEstimateInput): Promise<void> {
|
|
39
|
+
const { estimate } = await authorizePortalEstimate(input);
|
|
40
|
+
if (!['sent', 'viewed', 'draft'].includes(estimate.status)) {
|
|
41
|
+
throw new Error(`Cannot approve an estimate in status "${estimate.status}".`);
|
|
42
|
+
}
|
|
43
|
+
if (!input.signerName.trim()) throw new Error('Please type your name to acknowledge.');
|
|
44
|
+
if (!input.signatureDataUrl.startsWith('data:image/')) {
|
|
45
|
+
throw new Error('Signature is required — please draw your signature.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await db
|
|
49
|
+
.update(estimates)
|
|
50
|
+
.set({
|
|
51
|
+
status: 'approved',
|
|
52
|
+
approvedAt: new Date(),
|
|
53
|
+
approvedSignerName: input.signerName.trim(),
|
|
54
|
+
approvedSignatureDataUrl: input.signatureDataUrl,
|
|
55
|
+
updatedAt: new Date(),
|
|
56
|
+
})
|
|
57
|
+
.where(eq(estimates.id, input.estimateId));
|
|
58
|
+
|
|
59
|
+
revalidatePath(`/portal/${input.portalToken}`);
|
|
60
|
+
revalidatePath(`/portal/${input.portalToken}/estimates`);
|
|
61
|
+
revalidatePath(`/estimates/${input.estimateId}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface DeclineEstimateInput {
|
|
65
|
+
portalToken: string;
|
|
66
|
+
estimateId: string;
|
|
67
|
+
reason?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function declineEstimateByPortal(input: DeclineEstimateInput): Promise<void> {
|
|
71
|
+
const { estimate } = await authorizePortalEstimate(input);
|
|
72
|
+
if (!['sent', 'viewed', 'draft'].includes(estimate.status)) {
|
|
73
|
+
throw new Error(`Cannot decline an estimate in status "${estimate.status}".`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await db
|
|
77
|
+
.update(estimates)
|
|
78
|
+
.set({
|
|
79
|
+
status: 'declined',
|
|
80
|
+
declinedAt: new Date(),
|
|
81
|
+
declinedReason: input.reason?.trim() || null,
|
|
82
|
+
updatedAt: new Date(),
|
|
83
|
+
})
|
|
84
|
+
.where(eq(estimates.id, input.estimateId));
|
|
85
|
+
|
|
86
|
+
revalidatePath(`/portal/${input.portalToken}`);
|
|
87
|
+
revalidatePath(`/portal/${input.portalToken}/estimates`);
|
|
88
|
+
revalidatePath(`/estimates/${input.estimateId}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Bumps an estimate from 'sent' → 'viewed' on first portal load. Called
|
|
93
|
+
* server-side from the portal estimates page (idempotent: noop if already
|
|
94
|
+
* past 'viewed').
|
|
95
|
+
*/
|
|
96
|
+
export async function markEstimateViewed(portalToken: string, estimateId: string): Promise<void> {
|
|
97
|
+
try {
|
|
98
|
+
const { estimate } = await authorizePortalEstimate({ portalToken, estimateId });
|
|
99
|
+
if (estimate.status !== 'sent') return;
|
|
100
|
+
await db
|
|
101
|
+
.update(estimates)
|
|
102
|
+
.set({ status: 'viewed', updatedAt: new Date() })
|
|
103
|
+
.where(eq(estimates.id, estimateId));
|
|
104
|
+
} catch {
|
|
105
|
+
/* Silent: don't break the page render if mark-viewed fails. */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { desc, eq } from 'drizzle-orm';
|
|
2
|
+
import { db } from '@/db/client';
|
|
3
|
+
import {
|
|
4
|
+
customers as customersTable,
|
|
5
|
+
jobs as jobsTable,
|
|
6
|
+
estimates as estimatesTable,
|
|
7
|
+
invoices as invoicesTable,
|
|
8
|
+
invoicePayments as paymentsTable,
|
|
9
|
+
} from '@/db/schema';
|
|
10
|
+
import type { Job, JobStatus, LineItem } from '@/lib/jobs/types';
|
|
11
|
+
import type { Estimate, EstimateLineItem, EstimateStatus } from '@/lib/estimates/types';
|
|
12
|
+
import type { Invoice, InvoiceLineItem, InvoiceStatus } from '@/lib/invoices/types';
|
|
13
|
+
|
|
14
|
+
export interface PortalCustomer {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
email?: string;
|
|
18
|
+
phone?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PortalData {
|
|
22
|
+
customer: PortalCustomer;
|
|
23
|
+
jobs: Job[];
|
|
24
|
+
estimates: Estimate[];
|
|
25
|
+
invoices: Invoice[];
|
|
26
|
+
totals: {
|
|
27
|
+
balanceDue: number;
|
|
28
|
+
lifetimeValue: number;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolves a customer + all their jobs/estimates/invoices in one shot,
|
|
34
|
+
* keyed by the magic-link publicToken. Returns null when the token is
|
|
35
|
+
* unknown so the page can render notFound().
|
|
36
|
+
*/
|
|
37
|
+
export async function getPortalData(token: string): Promise<PortalData | null> {
|
|
38
|
+
const [customer] = await db
|
|
39
|
+
.select()
|
|
40
|
+
.from(customersTable)
|
|
41
|
+
.where(eq(customersTable.publicToken, token))
|
|
42
|
+
.limit(1);
|
|
43
|
+
if (!customer) return null;
|
|
44
|
+
|
|
45
|
+
const [jobRows, estimateRows, invoiceRows] = await Promise.all([
|
|
46
|
+
db.select().from(jobsTable).where(eq(jobsTable.customerId, customer.id)).orderBy(desc(jobsTable.createdAt)),
|
|
47
|
+
db.select().from(estimatesTable).where(eq(estimatesTable.customerId, customer.id)).orderBy(desc(estimatesTable.createdAt)),
|
|
48
|
+
db.select().from(invoicesTable).where(eq(invoicesTable.customerId, customer.id)).orderBy(desc(invoicesTable.createdAt)),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
// Fetch all payments for these invoices in one round-trip.
|
|
52
|
+
const allPayments = invoiceRows.length === 0
|
|
53
|
+
? []
|
|
54
|
+
: await db.select().from(paymentsTable).orderBy(desc(paymentsTable.recordedAt));
|
|
55
|
+
const paymentsByInvoice = new Map<string, (typeof paymentsTable.$inferSelect)[]>();
|
|
56
|
+
for (const p of allPayments) {
|
|
57
|
+
const arr = paymentsByInvoice.get(p.invoiceId) ?? [];
|
|
58
|
+
arr.push(p);
|
|
59
|
+
paymentsByInvoice.set(p.invoiceId, arr);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const portalCustomer: PortalCustomer = {
|
|
63
|
+
id: customer.id,
|
|
64
|
+
name: customer.name,
|
|
65
|
+
email: (customer.emails as string[])[0],
|
|
66
|
+
phone: (customer.phones as string[])[0],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const jobs: Job[] = jobRows.map((j) => ({
|
|
70
|
+
id: j.id,
|
|
71
|
+
customerId: j.customerId,
|
|
72
|
+
customerName: customer.name,
|
|
73
|
+
serviceType: j.serviceType,
|
|
74
|
+
status: j.status as JobStatus,
|
|
75
|
+
priority: j.priority as Job['priority'],
|
|
76
|
+
scheduledAt: j.scheduledAt?.toISOString(),
|
|
77
|
+
arrivalWindow: j.arrivalWindow ?? undefined,
|
|
78
|
+
assigneeIds: j.assigneeIds as string[],
|
|
79
|
+
assigneeNames: [],
|
|
80
|
+
lineItems: j.lineItems as LineItem[],
|
|
81
|
+
total: j.total,
|
|
82
|
+
notes: j.notes ?? undefined,
|
|
83
|
+
createdAt: j.createdAt.toISOString(),
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
const estimates: Estimate[] = estimateRows.map((e) => {
|
|
87
|
+
const lineItems = e.lineItems as EstimateLineItem[];
|
|
88
|
+
const totalCost = lineItems.reduce((acc, li) => acc + li.qty * (li.unitCost ?? 0), 0);
|
|
89
|
+
const margin = e.total - totalCost;
|
|
90
|
+
const marginPct = e.total > 0 ? Math.round((margin / e.total) * 100) : 0;
|
|
91
|
+
return {
|
|
92
|
+
id: e.id,
|
|
93
|
+
customerId: e.customerId,
|
|
94
|
+
customerName: customer.name,
|
|
95
|
+
status: e.status as EstimateStatus,
|
|
96
|
+
lineItems,
|
|
97
|
+
subtotal: e.subtotal,
|
|
98
|
+
total: e.total,
|
|
99
|
+
totalCost,
|
|
100
|
+
margin,
|
|
101
|
+
marginPct,
|
|
102
|
+
notes: e.notes ?? undefined,
|
|
103
|
+
validUntil: e.validUntil?.toISOString(),
|
|
104
|
+
sentAt: e.sentAt?.toISOString(),
|
|
105
|
+
approvedAt: e.approvedAt?.toISOString(),
|
|
106
|
+
convertedJobId: e.convertedJobId ?? undefined,
|
|
107
|
+
createdAt: e.createdAt.toISOString(),
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const invoices: Invoice[] = invoiceRows.map((i) => {
|
|
112
|
+
const amountDue = Math.max(0, i.total - i.amountPaid);
|
|
113
|
+
const isOverdue =
|
|
114
|
+
i.dueDate !== null && amountDue > 0 && new Date(i.dueDate) < new Date() &&
|
|
115
|
+
(i.status === 'sent' || i.status === 'partial');
|
|
116
|
+
return {
|
|
117
|
+
id: i.id,
|
|
118
|
+
invoiceNumber: i.invoiceNumber,
|
|
119
|
+
customerId: i.customerId,
|
|
120
|
+
customerName: customer.name,
|
|
121
|
+
jobId: i.jobId ?? undefined,
|
|
122
|
+
estimateId: i.estimateId ?? undefined,
|
|
123
|
+
status: (isOverdue ? 'overdue' : i.status) as InvoiceStatus,
|
|
124
|
+
lineItems: i.lineItems as InvoiceLineItem[],
|
|
125
|
+
subtotal: i.subtotal,
|
|
126
|
+
total: i.total,
|
|
127
|
+
amountPaid: i.amountPaid,
|
|
128
|
+
amountDue,
|
|
129
|
+
dueDate: i.dueDate?.toISOString(),
|
|
130
|
+
sentAt: i.sentAt?.toISOString(),
|
|
131
|
+
paidAt: i.paidAt?.toISOString(),
|
|
132
|
+
notes: i.notes ?? undefined,
|
|
133
|
+
createdAt: i.createdAt.toISOString(),
|
|
134
|
+
payments: (paymentsByInvoice.get(i.id) ?? []).map((p) => ({
|
|
135
|
+
id: p.id,
|
|
136
|
+
amount: p.amount,
|
|
137
|
+
method: p.method as Invoice['payments'][number]['method'],
|
|
138
|
+
recordedAt: p.recordedAt.toISOString(),
|
|
139
|
+
note: p.note ?? undefined,
|
|
140
|
+
})),
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const balanceDue = invoices.reduce((acc, i) => acc + i.amountDue, 0);
|
|
145
|
+
const lifetimeValue = invoices.reduce((acc, i) => acc + i.amountPaid, 0);
|
|
146
|
+
|
|
147
|
+
return { customer: portalCustomer, jobs, estimates, invoices, totals: { balanceDue, lifetimeValue } };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Public token for a single invoice — used in portal pay buttons. */
|
|
151
|
+
export async function getInvoicePublicToken(invoiceId: string): Promise<string | null> {
|
|
152
|
+
const [row] = await db
|
|
153
|
+
.select({ token: invoicesTable.publicToken })
|
|
154
|
+
.from(invoicesTable)
|
|
155
|
+
.where(eq(invoicesTable.id, invoiceId))
|
|
156
|
+
.limit(1);
|
|
157
|
+
return row?.token ?? null;
|
|
158
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
customers: defineTable({
|
|
2
|
+
name: v.string(),
|
|
3
|
+
phones: v.array(v.string()),
|
|
4
|
+
emails: v.array(v.string()),
|
|
5
|
+
billingAddress: v.object({
|
|
6
|
+
line1: v.string(),
|
|
7
|
+
line2: v.optional(v.string()),
|
|
8
|
+
city: v.string(),
|
|
9
|
+
state: v.string(),
|
|
10
|
+
postalCode: v.string(),
|
|
11
|
+
}),
|
|
12
|
+
serviceAddresses: v.array(
|
|
13
|
+
v.object({
|
|
14
|
+
line1: v.string(),
|
|
15
|
+
line2: v.optional(v.string()),
|
|
16
|
+
city: v.string(),
|
|
17
|
+
state: v.string(),
|
|
18
|
+
postalCode: v.string(),
|
|
19
|
+
}),
|
|
20
|
+
),
|
|
21
|
+
tags: v.array(v.string()),
|
|
22
|
+
leadSource: v.optional(v.string()),
|
|
23
|
+
lifetimeValue: v.number(),
|
|
24
|
+
balanceDue: v.number(),
|
|
25
|
+
doNotContact: v.boolean(),
|
|
26
|
+
notes: v.optional(v.string()),
|
|
27
|
+
createdAt: v.number(),
|
|
28
|
+
}).index('by_created_at', ['createdAt']),
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import { Badge } from '@/components/ui/badge';
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
5
|
+
import { Separator } from '@/components/ui/separator';
|
|
6
|
+
import { getCustomer } from '@/lib/customers/data';
|
|
7
|
+
import { formatAddress } from '@/lib/customers/types';
|
|
8
|
+
import { formatCurrency } from '@/lib/utils';
|
|
9
|
+
|
|
10
|
+
interface PageProps {
|
|
11
|
+
params: Promise<{ id: string }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default async function CustomerDetailPage({ params }: PageProps) {
|
|
15
|
+
const { id } = await params;
|
|
16
|
+
const customer = await getCustomer(id);
|
|
17
|
+
if (!customer) notFound();
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className="space-y-6">
|
|
21
|
+
<div>
|
|
22
|
+
<Link href="/customers" className="text-muted-foreground text-sm hover:underline">
|
|
23
|
+
← Customers
|
|
24
|
+
</Link>
|
|
25
|
+
<h1 className="mt-1 text-3xl font-bold tracking-tight">{customer.name}</h1>
|
|
26
|
+
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
27
|
+
{customer.tags.map((t) => (
|
|
28
|
+
<Badge key={t} variant="secondary" className="uppercase text-[10px]">{t}</Badge>
|
|
29
|
+
))}
|
|
30
|
+
{customer.doNotContact && <Badge variant="destructive">Do not contact</Badge>}
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
34
|
+
<Card>
|
|
35
|
+
<CardHeader><CardTitle className="text-base">Lifetime value</CardTitle></CardHeader>
|
|
36
|
+
<CardContent className="text-2xl font-bold">{formatCurrency(customer.lifetimeValue)}</CardContent>
|
|
37
|
+
</Card>
|
|
38
|
+
<Card>
|
|
39
|
+
<CardHeader><CardTitle className="text-base">Balance due</CardTitle></CardHeader>
|
|
40
|
+
<CardContent className={`text-2xl font-bold ${customer.balanceDue > 0 ? 'text-destructive' : ''}`}>
|
|
41
|
+
{formatCurrency(customer.balanceDue)}
|
|
42
|
+
</CardContent>
|
|
43
|
+
</Card>
|
|
44
|
+
<Card>
|
|
45
|
+
<CardHeader><CardTitle className="text-base">Lead source</CardTitle></CardHeader>
|
|
46
|
+
<CardContent className="text-muted-foreground text-lg">{customer.leadSource ?? '—'}</CardContent>
|
|
47
|
+
</Card>
|
|
48
|
+
</div>
|
|
49
|
+
<Card>
|
|
50
|
+
<CardHeader><CardTitle>Contact</CardTitle></CardHeader>
|
|
51
|
+
<CardContent className="space-y-3 text-sm">
|
|
52
|
+
<div>
|
|
53
|
+
<div className="text-muted-foreground text-xs uppercase">Phones</div>
|
|
54
|
+
{customer.phones.map((p) => <div key={p}>{p}</div>)}
|
|
55
|
+
</div>
|
|
56
|
+
<div>
|
|
57
|
+
<div className="text-muted-foreground text-xs uppercase">Emails</div>
|
|
58
|
+
{customer.emails.map((e) => <div key={e}>{e}</div>)}
|
|
59
|
+
</div>
|
|
60
|
+
<Separator />
|
|
61
|
+
<div>
|
|
62
|
+
<div className="text-muted-foreground text-xs uppercase">Billing address</div>
|
|
63
|
+
<div>{formatAddress(customer.billingAddress)}</div>
|
|
64
|
+
</div>
|
|
65
|
+
{customer.serviceAddresses.length > 0 && (
|
|
66
|
+
<div>
|
|
67
|
+
<div className="text-muted-foreground text-xs uppercase">Service addresses</div>
|
|
68
|
+
{customer.serviceAddresses.map((a, i) => <div key={i}>{formatAddress(a)}</div>)}
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
</CardContent>
|
|
72
|
+
</Card>
|
|
73
|
+
{customer.notes && (
|
|
74
|
+
<Card>
|
|
75
|
+
<CardHeader><CardTitle>Notes</CardTitle></CardHeader>
|
|
76
|
+
<CardContent className="text-sm">{customer.notes}</CardContent>
|
|
77
|
+
</Card>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
2
|
+
|
|
3
|
+
export default function NewCustomerPage() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="mx-auto max-w-2xl space-y-6">
|
|
6
|
+
<h1 className="text-3xl font-bold tracking-tight">New customer</h1>
|
|
7
|
+
<Card>
|
|
8
|
+
<CardHeader><CardTitle>Form goes here</CardTitle></CardHeader>
|
|
9
|
+
<CardContent className="text-muted-foreground text-sm">
|
|
10
|
+
Wire this form to your stack's server action / Convex mutation.
|
|
11
|
+
The customers schema is already defined — see the table in your DB.
|
|
12
|
+
</CardContent>
|
|
13
|
+
</Card>
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
}
|