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,59 @@
|
|
|
1
|
+
import { integer, jsonb, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
|
2
|
+
import { customers } from './customers';
|
|
3
|
+
import { jobs } from './jobs';
|
|
4
|
+
import { estimates } from './estimates';
|
|
5
|
+
|
|
6
|
+
export const invoiceStatus = pgEnum('invoice_status', [
|
|
7
|
+
'draft', 'sent', 'partial', 'paid', 'overdue', 'void',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export const paymentMethod = pgEnum('payment_method', [
|
|
11
|
+
'card', 'ach', 'cash', 'check', 'manual', 'stripe',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
interface LineItemJson {
|
|
15
|
+
description: string;
|
|
16
|
+
qty: number;
|
|
17
|
+
unitPrice: number; // cents
|
|
18
|
+
taxable?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const invoices = pgTable('invoices', {
|
|
22
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
23
|
+
invoiceNumber: text('invoice_number').notNull().unique(),
|
|
24
|
+
customerId: uuid('customer_id').notNull().references(() => customers.id, { onDelete: 'restrict' }),
|
|
25
|
+
jobId: uuid('job_id').references(() => jobs.id, { onDelete: 'set null' }),
|
|
26
|
+
estimateId: uuid('estimate_id').references(() => estimates.id, { onDelete: 'set null' }),
|
|
27
|
+
status: invoiceStatus('status').notNull().default('draft'),
|
|
28
|
+
lineItems: jsonb('line_items').$type<LineItemJson[]>().notNull().default([]),
|
|
29
|
+
subtotal: integer('subtotal').notNull().default(0), // cents
|
|
30
|
+
total: integer('total').notNull().default(0),
|
|
31
|
+
amountPaid: integer('amount_paid').notNull().default(0),
|
|
32
|
+
// Stripe metadata key for webhook reconciliation. Each invoice gets a
|
|
33
|
+
// random public token used both as the Stripe metadata link AND for the
|
|
34
|
+
// public payment page at /i/[publicToken] (Phase 18.5 ships that page).
|
|
35
|
+
publicToken: text('public_token').notNull().unique(),
|
|
36
|
+
notes: text('notes'),
|
|
37
|
+
dueDate: timestamp('due_date'),
|
|
38
|
+
sentAt: timestamp('sent_at'),
|
|
39
|
+
paidAt: timestamp('paid_at'),
|
|
40
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
41
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const invoicePayments = pgTable('invoice_payments', {
|
|
45
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
46
|
+
invoiceId: uuid('invoice_id').notNull().references(() => invoices.id, { onDelete: 'cascade' }),
|
|
47
|
+
amount: integer('amount').notNull(), // cents
|
|
48
|
+
method: paymentMethod('method').notNull(),
|
|
49
|
+
stripeSessionId: text('stripe_session_id'), // links back to Stripe Checkout Session
|
|
50
|
+
stripePaymentIntentId: text('stripe_payment_intent_id'),
|
|
51
|
+
note: text('note'),
|
|
52
|
+
recordedAt: timestamp('recorded_at').notNull().defaultNow(),
|
|
53
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export type InvoiceRow = typeof invoices.$inferSelect;
|
|
57
|
+
export type NewInvoice = typeof invoices.$inferInsert;
|
|
58
|
+
export type InvoicePaymentRow = typeof invoicePayments.$inferSelect;
|
|
59
|
+
export type NewInvoicePayment = typeof invoicePayments.$inferInsert;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { eq } from 'drizzle-orm';
|
|
4
|
+
import { redirect } from 'next/navigation';
|
|
5
|
+
import { revalidatePath } from 'next/cache';
|
|
6
|
+
import { db } from '@/db/client';
|
|
7
|
+
import { estimates, jobs } from '@/db/schema';
|
|
8
|
+
import type { EstimateLineItem, EstimateStatus } from './types';
|
|
9
|
+
|
|
10
|
+
export interface CreateEstimateInput {
|
|
11
|
+
customerId: string;
|
|
12
|
+
status: EstimateStatus;
|
|
13
|
+
lineItems: EstimateLineItem[];
|
|
14
|
+
notes: string | null;
|
|
15
|
+
validUntil: string | null; // ISO
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function createEstimate(input: CreateEstimateInput): Promise<void> {
|
|
19
|
+
if (!input.customerId) throw new Error('Customer is required');
|
|
20
|
+
|
|
21
|
+
const subtotal = input.lineItems.reduce((acc, li) => acc + li.qty * li.unitPrice, 0);
|
|
22
|
+
const totalCost = input.lineItems.reduce(
|
|
23
|
+
(acc, li) => acc + li.qty * (li.unitCost ?? 0),
|
|
24
|
+
0,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const [row] = await db
|
|
28
|
+
.insert(estimates)
|
|
29
|
+
.values({
|
|
30
|
+
customerId: input.customerId,
|
|
31
|
+
status: input.status,
|
|
32
|
+
lineItems: input.lineItems,
|
|
33
|
+
subtotal,
|
|
34
|
+
total: subtotal, // no tax/discount yet — Phase 22 wires those in
|
|
35
|
+
totalCost,
|
|
36
|
+
notes: input.notes ?? null,
|
|
37
|
+
validUntil: input.validUntil ? new Date(input.validUntil) : null,
|
|
38
|
+
sentAt: input.status === 'sent' ? new Date() : null,
|
|
39
|
+
})
|
|
40
|
+
.returning({ id: estimates.id });
|
|
41
|
+
|
|
42
|
+
revalidatePath('/estimates');
|
|
43
|
+
redirect(`/estimates/${row.id}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function setEstimateStatus(estimateId: string, status: EstimateStatus): Promise<void> {
|
|
47
|
+
const updates: {
|
|
48
|
+
status: EstimateStatus;
|
|
49
|
+
updatedAt: Date;
|
|
50
|
+
sentAt?: Date;
|
|
51
|
+
approvedAt?: Date;
|
|
52
|
+
} = {
|
|
53
|
+
status,
|
|
54
|
+
updatedAt: new Date(),
|
|
55
|
+
};
|
|
56
|
+
if (status === 'sent') updates.sentAt = new Date();
|
|
57
|
+
if (status === 'approved') updates.approvedAt = new Date();
|
|
58
|
+
await db.update(estimates).set(updates).where(eq(estimates.id, estimateId));
|
|
59
|
+
revalidatePath(`/estimates/${estimateId}`);
|
|
60
|
+
revalidatePath('/estimates');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Server action — clones an approved estimate into a new job (status =
|
|
65
|
+
* scheduled, line items copied over), marks the estimate as converted, and
|
|
66
|
+
* redirects to the new job detail page.
|
|
67
|
+
*/
|
|
68
|
+
export async function convertEstimateToJob(estimateId: string): Promise<void> {
|
|
69
|
+
const [estimate] = await db.select().from(estimates).where(eq(estimates.id, estimateId)).limit(1);
|
|
70
|
+
if (!estimate) throw new Error('Estimate not found');
|
|
71
|
+
if (estimate.status !== 'approved') {
|
|
72
|
+
throw new Error('Only approved estimates can be converted');
|
|
73
|
+
}
|
|
74
|
+
if (estimate.convertedJobId) {
|
|
75
|
+
redirect(`/jobs/${estimate.convertedJobId}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const lineItems = estimate.lineItems as EstimateLineItem[];
|
|
79
|
+
const [newJob] = await db
|
|
80
|
+
.insert(jobs)
|
|
81
|
+
.values({
|
|
82
|
+
customerId: estimate.customerId,
|
|
83
|
+
serviceType: 'From estimate',
|
|
84
|
+
status: 'scheduled',
|
|
85
|
+
priority: 'normal',
|
|
86
|
+
assigneeIds: [],
|
|
87
|
+
lineItems: lineItems.map((li) => ({
|
|
88
|
+
description: li.description,
|
|
89
|
+
qty: li.qty,
|
|
90
|
+
unitPrice: li.unitPrice,
|
|
91
|
+
})),
|
|
92
|
+
total: estimate.total,
|
|
93
|
+
notes: estimate.notes,
|
|
94
|
+
})
|
|
95
|
+
.returning({ id: jobs.id });
|
|
96
|
+
|
|
97
|
+
await db
|
|
98
|
+
.update(estimates)
|
|
99
|
+
.set({
|
|
100
|
+
status: 'converted',
|
|
101
|
+
convertedJobId: newJob.id,
|
|
102
|
+
updatedAt: new Date(),
|
|
103
|
+
})
|
|
104
|
+
.where(eq(estimates.id, estimateId));
|
|
105
|
+
|
|
106
|
+
revalidatePath('/estimates');
|
|
107
|
+
revalidatePath('/jobs');
|
|
108
|
+
revalidatePath(`/estimates/${estimateId}`);
|
|
109
|
+
redirect(`/jobs/${newJob.id}`);
|
|
110
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { desc, eq } from 'drizzle-orm';
|
|
2
|
+
import { db } from '@/db/client';
|
|
3
|
+
import { estimates as estimatesTable, customers as customersTable } from '@/db/schema';
|
|
4
|
+
import type { Estimate, EstimateLineItem, EstimateStatus } from './types';
|
|
5
|
+
|
|
6
|
+
interface JoinedRow {
|
|
7
|
+
estimate: typeof estimatesTable.$inferSelect;
|
|
8
|
+
customer: typeof customersTable.$inferSelect | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function toEstimate(row: JoinedRow): Estimate {
|
|
12
|
+
const e = row.estimate;
|
|
13
|
+
const lineItems = e.lineItems as EstimateLineItem[];
|
|
14
|
+
const totalCost = lineItems.reduce(
|
|
15
|
+
(acc, li) => acc + (li.unitCost ?? 0) * li.qty,
|
|
16
|
+
0,
|
|
17
|
+
);
|
|
18
|
+
const margin = e.total - totalCost;
|
|
19
|
+
const marginPct = e.total > 0 ? Math.round((margin / e.total) * 100) : 0;
|
|
20
|
+
return {
|
|
21
|
+
id: e.id,
|
|
22
|
+
customerId: e.customerId,
|
|
23
|
+
customerName: row.customer?.name ?? '(deleted)',
|
|
24
|
+
status: e.status as EstimateStatus,
|
|
25
|
+
lineItems,
|
|
26
|
+
subtotal: e.subtotal,
|
|
27
|
+
total: e.total,
|
|
28
|
+
totalCost,
|
|
29
|
+
margin,
|
|
30
|
+
marginPct,
|
|
31
|
+
notes: e.notes ?? undefined,
|
|
32
|
+
validUntil: e.validUntil?.toISOString(),
|
|
33
|
+
sentAt: e.sentAt?.toISOString(),
|
|
34
|
+
approvedAt: e.approvedAt?.toISOString(),
|
|
35
|
+
convertedJobId: e.convertedJobId ?? undefined,
|
|
36
|
+
createdAt: e.createdAt.toISOString(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function getEstimates(): Promise<Estimate[]> {
|
|
41
|
+
const rows = await db
|
|
42
|
+
.select({ estimate: estimatesTable, customer: customersTable })
|
|
43
|
+
.from(estimatesTable)
|
|
44
|
+
.leftJoin(customersTable, eq(estimatesTable.customerId, customersTable.id))
|
|
45
|
+
.orderBy(desc(estimatesTable.createdAt));
|
|
46
|
+
return rows.map(toEstimate);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function getEstimate(id: string): Promise<Estimate | null> {
|
|
50
|
+
const [row] = await db
|
|
51
|
+
.select({ estimate: estimatesTable, customer: customersTable })
|
|
52
|
+
.from(estimatesTable)
|
|
53
|
+
.leftJoin(customersTable, eq(estimatesTable.customerId, customersTable.id))
|
|
54
|
+
.where(eq(estimatesTable.id, id))
|
|
55
|
+
.limit(1);
|
|
56
|
+
return row ? toEstimate(row) : null;
|
|
57
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { eq, sql } from 'drizzle-orm';
|
|
5
|
+
import { headers } from 'next/headers';
|
|
6
|
+
import { redirect } from 'next/navigation';
|
|
7
|
+
import { revalidatePath } from 'next/cache';
|
|
8
|
+
import { db } from '@/db/client';
|
|
9
|
+
import { invoices, invoicePayments, jobs, customers } from '@/db/schema';
|
|
10
|
+
import { stripe, isStripeConfigured } from '@/lib/stripe';
|
|
11
|
+
import type { InvoiceLineItem, PaymentMethod } from './types';
|
|
12
|
+
|
|
13
|
+
function genInvoiceNumber(): string {
|
|
14
|
+
const year = new Date().getUTCFullYear();
|
|
15
|
+
const random = crypto.randomBytes(3).toString('hex').toUpperCase();
|
|
16
|
+
return `INV-${year}-${random}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CreateInvoiceFromJobInput {
|
|
20
|
+
jobId: string;
|
|
21
|
+
dueDateDays?: number; // days from now; default 30
|
|
22
|
+
customNote?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Server action — generates an invoice from a Finished/Completed job.
|
|
27
|
+
* Copies the job's line items + total + customer. Status defaults to
|
|
28
|
+
* 'sent' so the customer can pay immediately.
|
|
29
|
+
*/
|
|
30
|
+
export async function createInvoiceFromJob(input: CreateInvoiceFromJobInput): Promise<void> {
|
|
31
|
+
const [job] = await db.select().from(jobs).where(eq(jobs.id, input.jobId)).limit(1);
|
|
32
|
+
if (!job) throw new Error('Job not found');
|
|
33
|
+
|
|
34
|
+
const lineItems = (job.lineItems as InvoiceLineItem[]).map((li) => ({
|
|
35
|
+
description: li.description,
|
|
36
|
+
qty: li.qty,
|
|
37
|
+
unitPrice: li.unitPrice,
|
|
38
|
+
taxable: true,
|
|
39
|
+
}));
|
|
40
|
+
const total = lineItems.reduce((acc, li) => acc + li.qty * li.unitPrice, 0);
|
|
41
|
+
if (total <= 0) {
|
|
42
|
+
throw new Error('Job has no priced line items — add some before invoicing.');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const dueDate = new Date();
|
|
46
|
+
dueDate.setDate(dueDate.getDate() + (input.dueDateDays ?? 30));
|
|
47
|
+
|
|
48
|
+
const [row] = await db
|
|
49
|
+
.insert(invoices)
|
|
50
|
+
.values({
|
|
51
|
+
invoiceNumber: genInvoiceNumber(),
|
|
52
|
+
customerId: job.customerId,
|
|
53
|
+
jobId: job.id,
|
|
54
|
+
status: 'sent',
|
|
55
|
+
lineItems,
|
|
56
|
+
subtotal: total,
|
|
57
|
+
total,
|
|
58
|
+
amountPaid: 0,
|
|
59
|
+
publicToken: crypto.randomBytes(16).toString('hex'),
|
|
60
|
+
notes: input.customNote ?? null,
|
|
61
|
+
dueDate,
|
|
62
|
+
sentAt: new Date(),
|
|
63
|
+
})
|
|
64
|
+
.returning({ id: invoices.id });
|
|
65
|
+
|
|
66
|
+
revalidatePath('/invoices');
|
|
67
|
+
revalidatePath(`/jobs/${input.jobId}`);
|
|
68
|
+
redirect(`/invoices/${row.id}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface RecordPaymentInput {
|
|
72
|
+
invoiceId: string;
|
|
73
|
+
amount: number; // cents
|
|
74
|
+
method: PaymentMethod;
|
|
75
|
+
note?: string;
|
|
76
|
+
stripeSessionId?: string;
|
|
77
|
+
stripePaymentIntentId?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Records a payment against an invoice. Re-computes amountPaid + transitions
|
|
82
|
+
* status to 'paid' if fully covered or 'partial' otherwise. Safe to call
|
|
83
|
+
* from both UI (manual entry) and the Stripe webhook (idempotency via
|
|
84
|
+
* stripeSessionId uniqueness check).
|
|
85
|
+
*/
|
|
86
|
+
export async function recordInvoicePayment(input: RecordPaymentInput): Promise<void> {
|
|
87
|
+
// Idempotency: if a payment with the same stripeSessionId already exists,
|
|
88
|
+
// bail. Stripe retries webhooks; we don't want to double-record.
|
|
89
|
+
if (input.stripeSessionId) {
|
|
90
|
+
const existing = await db
|
|
91
|
+
.select({ id: invoicePayments.id })
|
|
92
|
+
.from(invoicePayments)
|
|
93
|
+
.where(eq(invoicePayments.stripeSessionId, input.stripeSessionId))
|
|
94
|
+
.limit(1);
|
|
95
|
+
if (existing.length > 0) return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await db.insert(invoicePayments).values({
|
|
99
|
+
invoiceId: input.invoiceId,
|
|
100
|
+
amount: input.amount,
|
|
101
|
+
method: input.method,
|
|
102
|
+
note: input.note ?? null,
|
|
103
|
+
stripeSessionId: input.stripeSessionId ?? null,
|
|
104
|
+
stripePaymentIntentId: input.stripePaymentIntentId ?? null,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Recompute amountPaid by summing payment rows.
|
|
108
|
+
const [{ paid }] = await db
|
|
109
|
+
.select({ paid: sql<number>`COALESCE(SUM(${invoicePayments.amount}), 0)::int` })
|
|
110
|
+
.from(invoicePayments)
|
|
111
|
+
.where(eq(invoicePayments.invoiceId, input.invoiceId));
|
|
112
|
+
|
|
113
|
+
const [invoice] = await db
|
|
114
|
+
.select({ total: invoices.total })
|
|
115
|
+
.from(invoices)
|
|
116
|
+
.where(eq(invoices.id, input.invoiceId))
|
|
117
|
+
.limit(1);
|
|
118
|
+
if (!invoice) return;
|
|
119
|
+
|
|
120
|
+
const fullyPaid = paid >= invoice.total;
|
|
121
|
+
await db
|
|
122
|
+
.update(invoices)
|
|
123
|
+
.set({
|
|
124
|
+
amountPaid: paid,
|
|
125
|
+
status: fullyPaid ? 'paid' : 'partial',
|
|
126
|
+
paidAt: fullyPaid ? new Date() : null,
|
|
127
|
+
updatedAt: new Date(),
|
|
128
|
+
})
|
|
129
|
+
.where(eq(invoices.id, input.invoiceId));
|
|
130
|
+
|
|
131
|
+
revalidatePath(`/invoices/${input.invoiceId}`);
|
|
132
|
+
revalidatePath('/invoices');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Server action — creates a Stripe Checkout Session for an invoice's
|
|
137
|
+
* remaining amount due and redirects the user to Stripe-hosted checkout.
|
|
138
|
+
* Session metadata.invoiceId + metadata.publicToken are read by the
|
|
139
|
+
* webhook handler to record the payment.
|
|
140
|
+
*/
|
|
141
|
+
export async function createCheckoutForInvoice(invoiceId: string): Promise<void> {
|
|
142
|
+
if (!isStripeConfigured) {
|
|
143
|
+
throw new Error('Stripe is not configured. Set STRIPE_SECRET_KEY in .env.local.');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Fetch the invoice + customer email for the Stripe receipt.
|
|
147
|
+
const [row] = await db
|
|
148
|
+
.select({ invoice: invoices, customerEmail: customers.emails })
|
|
149
|
+
.from(invoices)
|
|
150
|
+
.leftJoin(customers, eq(invoices.customerId, customers.id))
|
|
151
|
+
.where(eq(invoices.id, invoiceId))
|
|
152
|
+
.limit(1);
|
|
153
|
+
if (!row) throw new Error('Invoice not found');
|
|
154
|
+
const { invoice } = row;
|
|
155
|
+
const amountDue = Math.max(0, invoice.total - invoice.amountPaid);
|
|
156
|
+
if (amountDue <= 0) throw new Error('Invoice has nothing left to pay');
|
|
157
|
+
|
|
158
|
+
const h = await headers();
|
|
159
|
+
const host = h.get('host') ?? 'localhost:3000';
|
|
160
|
+
const proto = h.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https');
|
|
161
|
+
const origin = `${proto}://${host}`;
|
|
162
|
+
const customerEmail = Array.isArray(row.customerEmail) ? row.customerEmail[0] : undefined;
|
|
163
|
+
|
|
164
|
+
const session = await stripe.checkout.sessions.create({
|
|
165
|
+
mode: 'payment',
|
|
166
|
+
customer_email: customerEmail,
|
|
167
|
+
line_items: [
|
|
168
|
+
{
|
|
169
|
+
price_data: {
|
|
170
|
+
currency: 'usd',
|
|
171
|
+
product_data: { name: `Invoice ${invoice.invoiceNumber}` },
|
|
172
|
+
unit_amount: amountDue,
|
|
173
|
+
},
|
|
174
|
+
quantity: 1,
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
success_url: `${origin}/invoices/${invoice.id}?paid=true`,
|
|
178
|
+
cancel_url: `${origin}/invoices/${invoice.id}?cancelled=true`,
|
|
179
|
+
metadata: {
|
|
180
|
+
invoiceId: invoice.id,
|
|
181
|
+
invoiceNumber: invoice.invoiceNumber,
|
|
182
|
+
publicToken: invoice.publicToken,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
if (!session.url) throw new Error('Stripe did not return a checkout URL');
|
|
187
|
+
redirect(session.url);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Public-page variant — accepts a publicToken (used in the customer-facing
|
|
192
|
+
* /i/[token] page so no internal invoiceId leaks into URLs). Resolves the
|
|
193
|
+
* token to an invoice and forwards to createCheckoutForInvoice.
|
|
194
|
+
*/
|
|
195
|
+
export async function payByPublicToken(token: string): Promise<void> {
|
|
196
|
+
const [row] = await db.select().from(invoices).where(eq(invoices.publicToken, token)).limit(1);
|
|
197
|
+
if (!row) throw new Error('Invoice not found');
|
|
198
|
+
await createCheckoutForInvoice(row.id);
|
|
199
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { desc, eq } from 'drizzle-orm';
|
|
2
|
+
import { db } from '@/db/client';
|
|
3
|
+
import {
|
|
4
|
+
invoices as invoicesTable,
|
|
5
|
+
invoicePayments as paymentsTable,
|
|
6
|
+
customers as customersTable,
|
|
7
|
+
} from '@/db/schema';
|
|
8
|
+
import type { Invoice, InvoiceLineItem, InvoicePayment, InvoiceStatus, PaymentMethod } from './types';
|
|
9
|
+
|
|
10
|
+
interface InvoiceRow {
|
|
11
|
+
invoice: typeof invoicesTable.$inferSelect;
|
|
12
|
+
customer: typeof customersTable.$inferSelect | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function toInvoice(row: InvoiceRow, payments: (typeof paymentsTable.$inferSelect)[]): Invoice {
|
|
16
|
+
const i = row.invoice;
|
|
17
|
+
const lineItems = i.lineItems as InvoiceLineItem[];
|
|
18
|
+
const amountDue = Math.max(0, i.total - i.amountPaid);
|
|
19
|
+
const isOverdue =
|
|
20
|
+
i.dueDate !== null &&
|
|
21
|
+
amountDue > 0 &&
|
|
22
|
+
new Date(i.dueDate) < new Date() &&
|
|
23
|
+
(i.status === 'sent' || i.status === 'partial');
|
|
24
|
+
return {
|
|
25
|
+
id: i.id,
|
|
26
|
+
invoiceNumber: i.invoiceNumber,
|
|
27
|
+
customerId: i.customerId,
|
|
28
|
+
customerName: row.customer?.name ?? '(deleted)',
|
|
29
|
+
jobId: i.jobId ?? undefined,
|
|
30
|
+
estimateId: i.estimateId ?? undefined,
|
|
31
|
+
status: (isOverdue ? 'overdue' : i.status) as InvoiceStatus,
|
|
32
|
+
lineItems,
|
|
33
|
+
subtotal: i.subtotal,
|
|
34
|
+
total: i.total,
|
|
35
|
+
amountPaid: i.amountPaid,
|
|
36
|
+
amountDue,
|
|
37
|
+
dueDate: i.dueDate?.toISOString(),
|
|
38
|
+
sentAt: i.sentAt?.toISOString(),
|
|
39
|
+
paidAt: i.paidAt?.toISOString(),
|
|
40
|
+
notes: i.notes ?? undefined,
|
|
41
|
+
createdAt: i.createdAt.toISOString(),
|
|
42
|
+
payments: payments.map(
|
|
43
|
+
(p): InvoicePayment => ({
|
|
44
|
+
id: p.id,
|
|
45
|
+
amount: p.amount,
|
|
46
|
+
method: p.method as PaymentMethod,
|
|
47
|
+
recordedAt: p.recordedAt.toISOString(),
|
|
48
|
+
note: p.note ?? undefined,
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function getInvoices(): Promise<Invoice[]> {
|
|
55
|
+
const rows = await db
|
|
56
|
+
.select({ invoice: invoicesTable, customer: customersTable })
|
|
57
|
+
.from(invoicesTable)
|
|
58
|
+
.leftJoin(customersTable, eq(invoicesTable.customerId, customersTable.id))
|
|
59
|
+
.orderBy(desc(invoicesTable.createdAt));
|
|
60
|
+
// List page doesn't need full payment history — pass empty for perf.
|
|
61
|
+
return rows.map((r) => toInvoice(r, []));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function getInvoice(id: string): Promise<Invoice | null> {
|
|
65
|
+
const [row] = await db
|
|
66
|
+
.select({ invoice: invoicesTable, customer: customersTable })
|
|
67
|
+
.from(invoicesTable)
|
|
68
|
+
.leftJoin(customersTable, eq(invoicesTable.customerId, customersTable.id))
|
|
69
|
+
.where(eq(invoicesTable.id, id))
|
|
70
|
+
.limit(1);
|
|
71
|
+
if (!row) return null;
|
|
72
|
+
const payments = await db
|
|
73
|
+
.select()
|
|
74
|
+
.from(paymentsTable)
|
|
75
|
+
.where(eq(paymentsTable.invoiceId, id))
|
|
76
|
+
.orderBy(desc(paymentsTable.recordedAt));
|
|
77
|
+
return toInvoice(row, payments);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Public-page lookup keyed by the random per-invoice publicToken.
|
|
82
|
+
* The token is the security boundary: anyone with the token can VIEW
|
|
83
|
+
* and PAY the invoice. No login required.
|
|
84
|
+
*/
|
|
85
|
+
export async function getInvoiceByPublicToken(token: string): Promise<Invoice | null> {
|
|
86
|
+
const [row] = await db
|
|
87
|
+
.select({ invoice: invoicesTable, customer: customersTable })
|
|
88
|
+
.from(invoicesTable)
|
|
89
|
+
.leftJoin(customersTable, eq(invoicesTable.customerId, customersTable.id))
|
|
90
|
+
.where(eq(invoicesTable.publicToken, token))
|
|
91
|
+
.limit(1);
|
|
92
|
+
if (!row) return null;
|
|
93
|
+
const payments = await db
|
|
94
|
+
.select()
|
|
95
|
+
.from(paymentsTable)
|
|
96
|
+
.where(eq(paymentsTable.invoiceId, row.invoice.id))
|
|
97
|
+
.orderBy(desc(paymentsTable.recordedAt));
|
|
98
|
+
return toInvoice(row, payments);
|
|
99
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { headers } from 'next/headers';
|
|
4
|
+
import { eq } from 'drizzle-orm';
|
|
5
|
+
import { revalidatePath } from 'next/cache';
|
|
6
|
+
import { db } from '@/db/client';
|
|
7
|
+
import { invoices, customers } from '@/db/schema';
|
|
8
|
+
import { business } from '@/lib/business';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sends an invoice email via Resend's REST API. We hit the API directly
|
|
12
|
+
* (no SDK dep) so this works whether or not the comms-email module is
|
|
13
|
+
* installed.
|
|
14
|
+
*
|
|
15
|
+
* Requires RESEND_API_KEY and EMAIL_FROM env vars. Errors surface to the
|
|
16
|
+
* UI via the calling component's error state.
|
|
17
|
+
*/
|
|
18
|
+
export async function sendInvoiceEmail(invoiceId: string): Promise<{ ok: true; messageId: string }> {
|
|
19
|
+
const apiKey = process.env.RESEND_API_KEY;
|
|
20
|
+
const from = process.env.EMAIL_FROM;
|
|
21
|
+
if (!apiKey || !from) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
'Resend not configured. Set RESEND_API_KEY and EMAIL_FROM in .env.local.',
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const [row] = await db
|
|
28
|
+
.select({ invoice: invoices, customerEmails: customers.emails, customerName: customers.name })
|
|
29
|
+
.from(invoices)
|
|
30
|
+
.leftJoin(customers, eq(invoices.customerId, customers.id))
|
|
31
|
+
.where(eq(invoices.id, invoiceId))
|
|
32
|
+
.limit(1);
|
|
33
|
+
if (!row) throw new Error('Invoice not found');
|
|
34
|
+
const customerEmail = Array.isArray(row.customerEmails) ? row.customerEmails[0] : null;
|
|
35
|
+
if (!customerEmail) throw new Error('Customer has no email on file');
|
|
36
|
+
|
|
37
|
+
const h = await headers();
|
|
38
|
+
const host = h.get('host') ?? 'localhost:3000';
|
|
39
|
+
const proto = h.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https');
|
|
40
|
+
const publicUrl = `${proto}://${host}/i/${row.invoice.publicToken}`;
|
|
41
|
+
const amountDue = Math.max(0, row.invoice.total - row.invoice.amountPaid);
|
|
42
|
+
const amountFmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
|
|
43
|
+
amountDue / 100,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const subject = `Invoice ${row.invoice.invoiceNumber} from ${business.name}`;
|
|
47
|
+
const html = `
|
|
48
|
+
<div style="font-family: ui-sans-serif, system-ui, sans-serif; max-width: 520px; margin: 0 auto; padding: 24px;">
|
|
49
|
+
<h1 style="font-size: 20px; margin: 0 0 8px;">${escapeHtml(business.name)}</h1>
|
|
50
|
+
<p style="color: #6b7280; margin: 0 0 24px;">Invoice <strong>${escapeHtml(row.invoice.invoiceNumber)}</strong></p>
|
|
51
|
+
<p>Hi ${escapeHtml(row.customerName ?? 'there')},</p>
|
|
52
|
+
<p>Your invoice for ${amountFmt} is ready. Click below to view details or pay online — it takes about 30 seconds.</p>
|
|
53
|
+
<p style="margin: 24px 0;">
|
|
54
|
+
<a href="${publicUrl}" style="display: inline-block; background: hsl(160 84% 39%); color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 600;">View & pay ${amountFmt}</a>
|
|
55
|
+
</p>
|
|
56
|
+
<p style="color: #6b7280; font-size: 13px;">Or copy this link: <br><a href="${publicUrl}">${publicUrl}</a></p>
|
|
57
|
+
${business.phone ? `<p style="color: #6b7280; font-size: 13px;">Questions? Call us at <a href="tel:${business.phone}">${business.phone}</a>.</p>` : ''}
|
|
58
|
+
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 32px 0;">
|
|
59
|
+
<p style="color: #9ca3af; font-size: 11px;">${escapeHtml(business.legalName)}${business.address ? ` · ${escapeHtml(business.address)}` : ''}</p>
|
|
60
|
+
</div>
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const resp = await fetch('https://api.resend.com/emails', {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: {
|
|
66
|
+
Authorization: `Bearer ${apiKey}`,
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify({
|
|
70
|
+
from,
|
|
71
|
+
to: customerEmail,
|
|
72
|
+
subject,
|
|
73
|
+
html,
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (!resp.ok) {
|
|
78
|
+
const errBody = await resp.text();
|
|
79
|
+
throw new Error(`Resend API ${resp.status}: ${errBody}`);
|
|
80
|
+
}
|
|
81
|
+
const result = (await resp.json()) as { id?: string };
|
|
82
|
+
|
|
83
|
+
// Mark the invoice as sent if it was still in draft.
|
|
84
|
+
if (row.invoice.status === 'draft') {
|
|
85
|
+
await db
|
|
86
|
+
.update(invoices)
|
|
87
|
+
.set({ status: 'sent', sentAt: new Date(), updatedAt: new Date() })
|
|
88
|
+
.where(eq(invoices.id, invoiceId));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
revalidatePath(`/invoices/${invoiceId}`);
|
|
92
|
+
return { ok: true, messageId: result.id ?? '' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function escapeHtml(s: string): string {
|
|
96
|
+
return s
|
|
97
|
+
.replace(/&/g, '&')
|
|
98
|
+
.replace(/</g, '<')
|
|
99
|
+
.replace(/>/g, '>')
|
|
100
|
+
.replace(/"/g, '"')
|
|
101
|
+
.replace(/'/g, ''');
|
|
102
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
2
|
+
import { Badge } from '@/components/ui/badge';
|
|
3
|
+
|
|
4
|
+
const CHECKLISTS = [
|
|
5
|
+
{
|
|
6
|
+
id: 'cl_panel',
|
|
7
|
+
name: 'Panel upgrade inspection',
|
|
8
|
+
items: [
|
|
9
|
+
'Main breaker rating verified',
|
|
10
|
+
'Grounding electrode bonded',
|
|
11
|
+
'Service entrance conductors sized correctly',
|
|
12
|
+
'Working clearance meets code',
|
|
13
|
+
'GFCI/AFCI protection installed',
|
|
14
|
+
'Panel directory labeled',
|
|
15
|
+
'Permit number posted on-site',
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 'cl_outlet',
|
|
20
|
+
name: 'Outlet/circuit inspection',
|
|
21
|
+
items: [
|
|
22
|
+
'GFCI tested with built-in test button',
|
|
23
|
+
'Polarity verified',
|
|
24
|
+
'Junction box covers in place',
|
|
25
|
+
'Wire gauge matches breaker rating',
|
|
26
|
+
'No double-tapped breakers',
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export default function InspectionsPage() {
|
|
32
|
+
return (
|
|
33
|
+
<div className="space-y-6">
|
|
34
|
+
<div>
|
|
35
|
+
<h1 className="text-3xl font-bold tracking-tight">Inspection checklists</h1>
|
|
36
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
37
|
+
Pre-built lists technicians complete in the field. Customize per service type.
|
|
38
|
+
</p>
|
|
39
|
+
</div>
|
|
40
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
41
|
+
{CHECKLISTS.map((cl) => (
|
|
42
|
+
<Card key={cl.id}>
|
|
43
|
+
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
|
44
|
+
<CardTitle className="text-base">{cl.name}</CardTitle>
|
|
45
|
+
<Badge variant="outline">{cl.items.length} items</Badge>
|
|
46
|
+
</CardHeader>
|
|
47
|
+
<CardContent className="space-y-2">
|
|
48
|
+
{cl.items.map((item, i) => (
|
|
49
|
+
<div key={i} className="flex items-start gap-2 text-sm">
|
|
50
|
+
<input type="checkbox" disabled className="mt-1" />
|
|
51
|
+
<span>{item}</span>
|
|
52
|
+
</div>
|
|
53
|
+
))}
|
|
54
|
+
</CardContent>
|
|
55
|
+
</Card>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|