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,168 @@
|
|
|
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 { getInvoice } from '@/lib/invoices/data';
|
|
6
|
+
import {
|
|
7
|
+
INVOICE_STATUS_LABEL,
|
|
8
|
+
INVOICE_STATUS_VARIANT,
|
|
9
|
+
PAYMENT_METHOD_LABEL,
|
|
10
|
+
} from '@/lib/invoices/types';
|
|
11
|
+
import { PayInvoiceButton } from '@/components/invoices/pay-invoice-button';
|
|
12
|
+
import { SendInvoiceEmailButton } from '@/components/invoices/send-invoice-email-button';
|
|
13
|
+
import { formatCurrency, formatDate } from '@/lib/utils';
|
|
14
|
+
|
|
15
|
+
interface PageProps { params: Promise<{ id: string }>; }
|
|
16
|
+
|
|
17
|
+
export default async function InvoiceDetailPage({ params }: PageProps) {
|
|
18
|
+
const { id } = await params;
|
|
19
|
+
const invoice = await getInvoice(id);
|
|
20
|
+
if (!invoice) notFound();
|
|
21
|
+
|
|
22
|
+
const overdueDays =
|
|
23
|
+
invoice.dueDate && invoice.amountDue > 0 && new Date(invoice.dueDate) < new Date()
|
|
24
|
+
? Math.floor((Date.now() - new Date(invoice.dueDate).getTime()) / 86_400_000)
|
|
25
|
+
: 0;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="space-y-6">
|
|
29
|
+
<div>
|
|
30
|
+
<Link href="/invoices" className="text-muted-foreground text-sm hover:underline">← Invoices</Link>
|
|
31
|
+
<div className="mt-1 flex items-center gap-3">
|
|
32
|
+
<h1 className="text-3xl font-bold tracking-tight">{invoice.invoiceNumber}</h1>
|
|
33
|
+
<Badge variant={INVOICE_STATUS_VARIANT[invoice.status]}>
|
|
34
|
+
{INVOICE_STATUS_LABEL[invoice.status]}
|
|
35
|
+
</Badge>
|
|
36
|
+
<div className="ml-auto flex items-center gap-2">
|
|
37
|
+
<SendInvoiceEmailButton invoiceId={invoice.id} />
|
|
38
|
+
{invoice.amountDue > 0 && (
|
|
39
|
+
<PayInvoiceButton invoiceId={invoice.id} amountDue={invoice.amountDue} />
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
<Link href={`/customers/${invoice.customerId}`} className="text-muted-foreground hover:underline">
|
|
44
|
+
{invoice.customerName}
|
|
45
|
+
</Link>
|
|
46
|
+
{invoice.jobId && (
|
|
47
|
+
<span className="text-muted-foreground ml-2 text-sm">
|
|
48
|
+
· from <Link href={`/jobs/${invoice.jobId}`} className="underline">job #{invoice.jobId.slice(-6)}</Link>
|
|
49
|
+
</span>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
|
53
|
+
<Card>
|
|
54
|
+
<CardHeader><CardTitle className="text-sm font-medium">Total</CardTitle></CardHeader>
|
|
55
|
+
<CardContent className="text-2xl font-bold">{formatCurrency(invoice.total)}</CardContent>
|
|
56
|
+
</Card>
|
|
57
|
+
<Card>
|
|
58
|
+
<CardHeader><CardTitle className="text-sm font-medium">Paid</CardTitle></CardHeader>
|
|
59
|
+
<CardContent>
|
|
60
|
+
<div className="text-2xl font-bold text-emerald-600">{formatCurrency(invoice.amountPaid)}</div>
|
|
61
|
+
<div className="text-muted-foreground mt-1 text-xs">
|
|
62
|
+
{invoice.total > 0
|
|
63
|
+
? Math.round((invoice.amountPaid / invoice.total) * 100)
|
|
64
|
+
: 0}% of total
|
|
65
|
+
</div>
|
|
66
|
+
</CardContent>
|
|
67
|
+
</Card>
|
|
68
|
+
<Card>
|
|
69
|
+
<CardHeader><CardTitle className="text-sm font-medium">Due now</CardTitle></CardHeader>
|
|
70
|
+
<CardContent>
|
|
71
|
+
<div className={`text-2xl font-bold ${invoice.amountDue > 0 && invoice.status === 'overdue' ? 'text-destructive' : ''}`}>
|
|
72
|
+
{formatCurrency(invoice.amountDue)}
|
|
73
|
+
</div>
|
|
74
|
+
{overdueDays > 0 && (
|
|
75
|
+
<div className="text-destructive mt-1 text-xs font-bold">
|
|
76
|
+
{overdueDays} day{overdueDays === 1 ? '' : 's'} overdue
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</CardContent>
|
|
80
|
+
</Card>
|
|
81
|
+
<Card>
|
|
82
|
+
<CardHeader><CardTitle className="text-sm font-medium">Due date</CardTitle></CardHeader>
|
|
83
|
+
<CardContent className="text-sm">
|
|
84
|
+
{invoice.dueDate ? (
|
|
85
|
+
<div className="font-medium">{formatDate(invoice.dueDate)}</div>
|
|
86
|
+
) : (
|
|
87
|
+
<span className="text-muted-foreground">No due date</span>
|
|
88
|
+
)}
|
|
89
|
+
{invoice.sentAt && (
|
|
90
|
+
<div className="text-muted-foreground mt-1 text-xs">Sent {formatDate(invoice.sentAt)}</div>
|
|
91
|
+
)}
|
|
92
|
+
{invoice.paidAt && (
|
|
93
|
+
<div className="text-emerald-600 mt-1 text-xs">Paid {formatDate(invoice.paidAt)}</div>
|
|
94
|
+
)}
|
|
95
|
+
</CardContent>
|
|
96
|
+
</Card>
|
|
97
|
+
</div>
|
|
98
|
+
<Card>
|
|
99
|
+
<CardHeader><CardTitle>Line items</CardTitle></CardHeader>
|
|
100
|
+
<CardContent className="p-0">
|
|
101
|
+
<table className="w-full text-sm">
|
|
102
|
+
<thead className="text-muted-foreground bg-muted/50 text-xs uppercase">
|
|
103
|
+
<tr>
|
|
104
|
+
<th className="px-4 py-3 text-left font-medium">Description</th>
|
|
105
|
+
<th className="px-4 py-3 text-right font-medium">Qty</th>
|
|
106
|
+
<th className="px-4 py-3 text-right font-medium">Unit price</th>
|
|
107
|
+
<th className="px-4 py-3 text-right font-medium">Subtotal</th>
|
|
108
|
+
</tr>
|
|
109
|
+
</thead>
|
|
110
|
+
<tbody className="divide-border divide-y">
|
|
111
|
+
{invoice.lineItems.map((li, i) => (
|
|
112
|
+
<tr key={i}>
|
|
113
|
+
<td className="px-4 py-3">{li.description}</td>
|
|
114
|
+
<td className="px-4 py-3 text-right">{li.qty}</td>
|
|
115
|
+
<td className="px-4 py-3 text-right">{formatCurrency(li.unitPrice)}</td>
|
|
116
|
+
<td className="px-4 py-3 text-right font-medium">{formatCurrency(li.qty * li.unitPrice)}</td>
|
|
117
|
+
</tr>
|
|
118
|
+
))}
|
|
119
|
+
</tbody>
|
|
120
|
+
<tfoot className="bg-muted/30">
|
|
121
|
+
<tr>
|
|
122
|
+
<td colSpan={3} className="px-4 py-3 text-right text-muted-foreground">Total</td>
|
|
123
|
+
<td className="px-4 py-3 text-right font-bold">{formatCurrency(invoice.total)}</td>
|
|
124
|
+
</tr>
|
|
125
|
+
</tfoot>
|
|
126
|
+
</table>
|
|
127
|
+
</CardContent>
|
|
128
|
+
</Card>
|
|
129
|
+
<Card>
|
|
130
|
+
<CardHeader>
|
|
131
|
+
<CardTitle>Payments ({invoice.payments.length})</CardTitle>
|
|
132
|
+
</CardHeader>
|
|
133
|
+
<CardContent>
|
|
134
|
+
{invoice.payments.length === 0 ? (
|
|
135
|
+
<p className="text-muted-foreground text-sm">No payments yet.</p>
|
|
136
|
+
) : (
|
|
137
|
+
<table className="w-full text-sm">
|
|
138
|
+
<thead className="text-muted-foreground text-xs uppercase">
|
|
139
|
+
<tr>
|
|
140
|
+
<th className="py-2 text-left font-medium">Date</th>
|
|
141
|
+
<th className="py-2 text-left font-medium">Method</th>
|
|
142
|
+
<th className="py-2 text-left font-medium">Note</th>
|
|
143
|
+
<th className="py-2 text-right font-medium">Amount</th>
|
|
144
|
+
</tr>
|
|
145
|
+
</thead>
|
|
146
|
+
<tbody className="divide-border divide-y">
|
|
147
|
+
{invoice.payments.map((p) => (
|
|
148
|
+
<tr key={p.id}>
|
|
149
|
+
<td className="text-muted-foreground py-2">{formatDate(p.recordedAt)}</td>
|
|
150
|
+
<td className="py-2">{PAYMENT_METHOD_LABEL[p.method]}</td>
|
|
151
|
+
<td className="text-muted-foreground py-2 text-xs">{p.note ?? '—'}</td>
|
|
152
|
+
<td className="py-2 text-right font-medium">{formatCurrency(p.amount)}</td>
|
|
153
|
+
</tr>
|
|
154
|
+
))}
|
|
155
|
+
</tbody>
|
|
156
|
+
</table>
|
|
157
|
+
)}
|
|
158
|
+
</CardContent>
|
|
159
|
+
</Card>
|
|
160
|
+
{invoice.notes && (
|
|
161
|
+
<Card>
|
|
162
|
+
<CardHeader><CardTitle>Notes</CardTitle></CardHeader>
|
|
163
|
+
<CardContent className="text-sm whitespace-pre-wrap">{invoice.notes}</CardContent>
|
|
164
|
+
</Card>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
3
|
+
import { Badge } from '@/components/ui/badge';
|
|
4
|
+
import { getInvoices } from '@/lib/invoices/data';
|
|
5
|
+
import { INVOICE_STATUS_LABEL, INVOICE_STATUS_VARIANT } from '@/lib/invoices/types';
|
|
6
|
+
import { formatCurrency, formatDate } from '@/lib/utils';
|
|
7
|
+
|
|
8
|
+
export default async function InvoicesPage() {
|
|
9
|
+
const invoices = await getInvoices();
|
|
10
|
+
const ar = invoices
|
|
11
|
+
.filter((i) => i.status !== 'paid' && i.status !== 'void' && i.status !== 'draft')
|
|
12
|
+
.reduce((acc, i) => acc + i.amountDue, 0);
|
|
13
|
+
const overdue = invoices
|
|
14
|
+
.filter((i) => i.status === 'overdue')
|
|
15
|
+
.reduce((acc, i) => acc + i.amountDue, 0);
|
|
16
|
+
const collected = invoices.reduce((acc, i) => acc + i.amountPaid, 0);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="space-y-6">
|
|
20
|
+
<div className="flex items-center justify-between">
|
|
21
|
+
<div>
|
|
22
|
+
<h1 className="text-3xl font-bold tracking-tight">Invoices</h1>
|
|
23
|
+
<p className="text-muted-foreground mt-1 text-sm">{invoices.length} total</p>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
27
|
+
<Card>
|
|
28
|
+
<CardHeader><CardTitle className="text-base">Open A/R</CardTitle></CardHeader>
|
|
29
|
+
<CardContent>
|
|
30
|
+
<div className="text-3xl font-bold">{formatCurrency(ar)}</div>
|
|
31
|
+
<p className="text-muted-foreground mt-1 text-xs">Sent + partially-paid + overdue</p>
|
|
32
|
+
</CardContent>
|
|
33
|
+
</Card>
|
|
34
|
+
<Card>
|
|
35
|
+
<CardHeader><CardTitle className="text-base">Overdue</CardTitle></CardHeader>
|
|
36
|
+
<CardContent>
|
|
37
|
+
<div className={`text-3xl font-bold ${overdue > 0 ? 'text-destructive' : ''}`}>
|
|
38
|
+
{formatCurrency(overdue)}
|
|
39
|
+
</div>
|
|
40
|
+
<p className="text-muted-foreground mt-1 text-xs">Past due date</p>
|
|
41
|
+
</CardContent>
|
|
42
|
+
</Card>
|
|
43
|
+
<Card>
|
|
44
|
+
<CardHeader><CardTitle className="text-base">Collected</CardTitle></CardHeader>
|
|
45
|
+
<CardContent>
|
|
46
|
+
<div className="text-3xl font-bold">{formatCurrency(collected)}</div>
|
|
47
|
+
<p className="text-muted-foreground mt-1 text-xs">All-time across all invoices</p>
|
|
48
|
+
</CardContent>
|
|
49
|
+
</Card>
|
|
50
|
+
</div>
|
|
51
|
+
<Card>
|
|
52
|
+
<CardHeader><CardTitle>All invoices</CardTitle></CardHeader>
|
|
53
|
+
<CardContent className="p-0">
|
|
54
|
+
<table className="w-full text-sm">
|
|
55
|
+
<thead className="bg-muted/50 text-muted-foreground text-xs uppercase">
|
|
56
|
+
<tr>
|
|
57
|
+
<th className="px-4 py-3 text-left font-medium">Invoice #</th>
|
|
58
|
+
<th className="px-4 py-3 text-left font-medium">Customer</th>
|
|
59
|
+
<th className="px-4 py-3 text-left font-medium">Status</th>
|
|
60
|
+
<th className="px-4 py-3 text-left font-medium">Due</th>
|
|
61
|
+
<th className="px-4 py-3 text-right font-medium">Total</th>
|
|
62
|
+
<th className="px-4 py-3 text-right font-medium">Due now</th>
|
|
63
|
+
</tr>
|
|
64
|
+
</thead>
|
|
65
|
+
<tbody className="divide-border divide-y">
|
|
66
|
+
{invoices.map((i) => (
|
|
67
|
+
<tr key={i.id} className="hover:bg-muted/30">
|
|
68
|
+
<td className="px-4 py-3 font-mono text-xs">
|
|
69
|
+
<Link href={`/invoices/${i.id}`} className="hover:underline font-medium">
|
|
70
|
+
{i.invoiceNumber}
|
|
71
|
+
</Link>
|
|
72
|
+
</td>
|
|
73
|
+
<td className="px-4 py-3">{i.customerName}</td>
|
|
74
|
+
<td className="px-4 py-3">
|
|
75
|
+
<Badge variant={INVOICE_STATUS_VARIANT[i.status]}>
|
|
76
|
+
{INVOICE_STATUS_LABEL[i.status]}
|
|
77
|
+
</Badge>
|
|
78
|
+
</td>
|
|
79
|
+
<td className="text-muted-foreground px-4 py-3">
|
|
80
|
+
{i.dueDate ? formatDate(i.dueDate) : '—'}
|
|
81
|
+
</td>
|
|
82
|
+
<td className="px-4 py-3 text-right">{formatCurrency(i.total)}</td>
|
|
83
|
+
<td className="px-4 py-3 text-right">
|
|
84
|
+
{i.amountDue > 0 ? (
|
|
85
|
+
<span className={i.status === 'overdue' ? 'text-destructive font-bold' : 'font-medium'}>
|
|
86
|
+
{formatCurrency(i.amountDue)}
|
|
87
|
+
</span>
|
|
88
|
+
) : (
|
|
89
|
+
<span className="text-muted-foreground">—</span>
|
|
90
|
+
)}
|
|
91
|
+
</td>
|
|
92
|
+
</tr>
|
|
93
|
+
))}
|
|
94
|
+
</tbody>
|
|
95
|
+
</table>
|
|
96
|
+
</CardContent>
|
|
97
|
+
</Card>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Button } from '@/components/ui/button';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default stub for Convex / sample-data. Disabled. Drizzle's installer
|
|
5
|
+
* overwrites this with a working server-action-backed version.
|
|
6
|
+
*/
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
8
|
+
export function ConvertToJobButton({ estimateId }: { estimateId: string }) {
|
|
9
|
+
return (
|
|
10
|
+
<Button size="sm" variant="outline" disabled title="Wire to your stack to enable">
|
|
11
|
+
Convert to job
|
|
12
|
+
</Button>
|
|
13
|
+
);
|
|
14
|
+
}
|
package/template/extras/estimates-invoices/_shared/src/components/invoices/pay-invoice-button.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Button } from '@/components/ui/button';
|
|
2
|
+
import { formatCurrency } from '@/lib/utils';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default stub for Convex / sample-data. Disabled. Drizzle's installer
|
|
6
|
+
* overwrites this with a Stripe-Checkout-backed version.
|
|
7
|
+
*/
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
9
|
+
export function PayInvoiceButton({ invoiceId, amountDue }: { invoiceId: string; amountDue: number }) {
|
|
10
|
+
return (
|
|
11
|
+
<Button size="sm" variant="outline" disabled title="Wire to your stack to enable">
|
|
12
|
+
Pay {formatCurrency(amountDue)}
|
|
13
|
+
</Button>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Button } from '@/components/ui/button';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default stub for Convex / sample-data. Disabled. Drizzle's installer
|
|
5
|
+
* overwrites this with a Resend-backed working version.
|
|
6
|
+
*/
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
8
|
+
export function SendInvoiceEmailButton({ invoiceId }: { invoiceId: string }) {
|
|
9
|
+
return (
|
|
10
|
+
<Button size="sm" variant="outline" disabled title="Wire to your stack to enable">
|
|
11
|
+
Email invoice
|
|
12
|
+
</Button>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default data source — returns sample estimates. Stack-specific installers
|
|
3
|
+
* (Drizzle / Convex) OVERWRITE this file with real DB queries.
|
|
4
|
+
*/
|
|
5
|
+
import { sampleEstimates } from './sample-data';
|
|
6
|
+
import type { Estimate } from './types';
|
|
7
|
+
|
|
8
|
+
export async function getEstimates(): Promise<Estimate[]> {
|
|
9
|
+
return sampleEstimates;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function getEstimate(id: string): Promise<Estimate | null> {
|
|
13
|
+
return sampleEstimates.find((e) => e.id === id) ?? null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Estimate } from './types';
|
|
2
|
+
|
|
3
|
+
export const sampleEstimates: Estimate[] = [
|
|
4
|
+
{
|
|
5
|
+
id: 'est_001',
|
|
6
|
+
customerId: 'cus_001',
|
|
7
|
+
customerName: 'Acme Property Management',
|
|
8
|
+
status: 'sent',
|
|
9
|
+
lineItems: [
|
|
10
|
+
{ description: 'AC condenser replacement (3 ton)', qty: 1, unitPrice: 320000, unitCost: 195000, taxable: true },
|
|
11
|
+
{ description: 'Refrigerant (R-410A, 6 lb)', qty: 6, unitPrice: 10750, unitCost: 4500, taxable: true },
|
|
12
|
+
],
|
|
13
|
+
subtotal: 384500,
|
|
14
|
+
total: 384500,
|
|
15
|
+
totalCost: 222000,
|
|
16
|
+
margin: 162500,
|
|
17
|
+
marginPct: 42,
|
|
18
|
+
sentAt: '2026-05-22T10:00:00Z',
|
|
19
|
+
validUntil: '2026-06-21T23:59:59Z',
|
|
20
|
+
createdAt: '2026-05-22T09:30:00Z',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'est_002',
|
|
24
|
+
customerId: 'cus_004',
|
|
25
|
+
customerName: 'Priya Patel',
|
|
26
|
+
status: 'draft',
|
|
27
|
+
lineItems: [
|
|
28
|
+
{ description: 'Tankless water heater install', qty: 1, unitPrice: 142000, unitCost: 91000, taxable: true },
|
|
29
|
+
],
|
|
30
|
+
subtotal: 142000,
|
|
31
|
+
total: 142000,
|
|
32
|
+
totalCost: 91000,
|
|
33
|
+
margin: 51000,
|
|
34
|
+
marginPct: 36,
|
|
35
|
+
createdAt: '2026-05-23T14:00:00Z',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'est_003',
|
|
39
|
+
customerId: 'cus_003',
|
|
40
|
+
customerName: 'Mason Hardware Co.',
|
|
41
|
+
status: 'approved',
|
|
42
|
+
lineItems: [
|
|
43
|
+
{ description: 'Walk-in cooler — full install', qty: 1, unitPrice: 950000, unitCost: 612000, taxable: true },
|
|
44
|
+
{ description: 'Permit + inspection', qty: 1, unitPrice: 75000, unitCost: 18000, taxable: false },
|
|
45
|
+
{ description: 'Annual service plan (yr 1)', qty: 1, unitPrice: 262000, unitCost: 80000, taxable: false },
|
|
46
|
+
],
|
|
47
|
+
subtotal: 1287000,
|
|
48
|
+
total: 1287000,
|
|
49
|
+
totalCost: 710000,
|
|
50
|
+
margin: 577000,
|
|
51
|
+
marginPct: 45,
|
|
52
|
+
sentAt: '2026-05-15T11:00:00Z',
|
|
53
|
+
approvedAt: '2026-05-20T16:42:00Z',
|
|
54
|
+
validUntil: '2026-06-15T23:59:59Z',
|
|
55
|
+
createdAt: '2026-05-15T10:30:00Z',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'est_004',
|
|
59
|
+
customerId: 'cus_002',
|
|
60
|
+
customerName: 'Jamie Rodriguez',
|
|
61
|
+
status: 'expired',
|
|
62
|
+
lineItems: [
|
|
63
|
+
{ description: 'Capacitor + recharge', qty: 1, unitPrice: 62500, unitCost: 28000, taxable: true },
|
|
64
|
+
],
|
|
65
|
+
subtotal: 62500,
|
|
66
|
+
total: 62500,
|
|
67
|
+
totalCost: 28000,
|
|
68
|
+
margin: 34500,
|
|
69
|
+
marginPct: 55,
|
|
70
|
+
sentAt: '2026-04-15T09:00:00Z',
|
|
71
|
+
validUntil: '2026-05-15T23:59:59Z',
|
|
72
|
+
createdAt: '2026-04-15T08:30:00Z',
|
|
73
|
+
},
|
|
74
|
+
];
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export const ESTIMATE_STATUSES = [
|
|
2
|
+
'draft',
|
|
3
|
+
'sent',
|
|
4
|
+
'viewed',
|
|
5
|
+
'approved',
|
|
6
|
+
'declined',
|
|
7
|
+
'expired',
|
|
8
|
+
'converted',
|
|
9
|
+
] as const;
|
|
10
|
+
export type EstimateStatus = (typeof ESTIMATE_STATUSES)[number];
|
|
11
|
+
|
|
12
|
+
export const ESTIMATE_STATUS_LABEL: Record<EstimateStatus, string> = {
|
|
13
|
+
draft: 'Draft',
|
|
14
|
+
sent: 'Sent',
|
|
15
|
+
viewed: 'Viewed',
|
|
16
|
+
approved: 'Approved',
|
|
17
|
+
declined: 'Declined',
|
|
18
|
+
expired: 'Expired',
|
|
19
|
+
converted: 'Converted to job',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const ESTIMATE_STATUS_VARIANT: Record<
|
|
23
|
+
EstimateStatus,
|
|
24
|
+
'default' | 'secondary' | 'destructive' | 'outline'
|
|
25
|
+
> = {
|
|
26
|
+
draft: 'outline',
|
|
27
|
+
sent: 'secondary',
|
|
28
|
+
viewed: 'secondary',
|
|
29
|
+
approved: 'default',
|
|
30
|
+
declined: 'destructive',
|
|
31
|
+
expired: 'destructive',
|
|
32
|
+
converted: 'default',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface EstimateLineItem {
|
|
36
|
+
description: string;
|
|
37
|
+
qty: number;
|
|
38
|
+
unitPrice: number; // cents
|
|
39
|
+
unitCost?: number; // cents — used for live margin display
|
|
40
|
+
taxable?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface Estimate {
|
|
44
|
+
id: string;
|
|
45
|
+
customerId: string;
|
|
46
|
+
customerName: string;
|
|
47
|
+
status: EstimateStatus;
|
|
48
|
+
lineItems: EstimateLineItem[];
|
|
49
|
+
subtotal: number; // cents (sum of qty × unitPrice)
|
|
50
|
+
total: number; // cents (subtotal + tax, less discount)
|
|
51
|
+
totalCost: number; // cents (sum of qty × unitCost)
|
|
52
|
+
margin: number; // cents (total - totalCost)
|
|
53
|
+
marginPct: number; // 0-100 (margin / total × 100, 0 if total is 0)
|
|
54
|
+
notes?: string;
|
|
55
|
+
validUntil?: string; // ISO
|
|
56
|
+
sentAt?: string; // ISO
|
|
57
|
+
approvedAt?: string; // ISO
|
|
58
|
+
convertedJobId?: string;
|
|
59
|
+
createdAt: string; // ISO
|
|
60
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default data source — returns sample invoices. Stack-specific installers
|
|
3
|
+
* (Drizzle / Convex) OVERWRITE this file with real DB queries.
|
|
4
|
+
*/
|
|
5
|
+
import { sampleInvoices } from './sample-data';
|
|
6
|
+
import type { Invoice } from './types';
|
|
7
|
+
|
|
8
|
+
export async function getInvoices(): Promise<Invoice[]> {
|
|
9
|
+
return sampleInvoices;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function getInvoice(id: string): Promise<Invoice | null> {
|
|
13
|
+
return sampleInvoices.find((i) => i.id === id) ?? null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Invoice } from './types';
|
|
2
|
+
|
|
3
|
+
export const sampleInvoices: Invoice[] = [
|
|
4
|
+
{
|
|
5
|
+
id: 'inv_001',
|
|
6
|
+
invoiceNumber: 'INV-2026-0001',
|
|
7
|
+
customerId: 'cus_001',
|
|
8
|
+
customerName: 'Acme Property Management',
|
|
9
|
+
status: 'paid',
|
|
10
|
+
lineItems: [
|
|
11
|
+
{ description: 'Quarterly HVAC maintenance (4 units)', qty: 4, unitPrice: 12500, taxable: true },
|
|
12
|
+
],
|
|
13
|
+
subtotal: 50000,
|
|
14
|
+
total: 50000,
|
|
15
|
+
amountPaid: 50000,
|
|
16
|
+
amountDue: 0,
|
|
17
|
+
dueDate: '2026-05-15T23:59:59Z',
|
|
18
|
+
sentAt: '2026-05-01T10:00:00Z',
|
|
19
|
+
paidAt: '2026-05-14T15:23:00Z',
|
|
20
|
+
payments: [
|
|
21
|
+
{ id: 'pay_001', amount: 50000, method: 'card', recordedAt: '2026-05-14T15:23:00Z' },
|
|
22
|
+
],
|
|
23
|
+
createdAt: '2026-05-01T09:00:00Z',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'inv_002',
|
|
27
|
+
invoiceNumber: 'INV-2026-0002',
|
|
28
|
+
customerId: 'cus_002',
|
|
29
|
+
customerName: 'Jamie Rodriguez',
|
|
30
|
+
status: 'sent',
|
|
31
|
+
lineItems: [
|
|
32
|
+
{ description: 'Diagnostic fee', qty: 1, unitPrice: 8900, taxable: true },
|
|
33
|
+
{ description: 'Capacitor replacement', qty: 1, unitPrice: 18500, taxable: true },
|
|
34
|
+
],
|
|
35
|
+
subtotal: 27400,
|
|
36
|
+
total: 27400,
|
|
37
|
+
amountPaid: 0,
|
|
38
|
+
amountDue: 27400,
|
|
39
|
+
dueDate: '2026-06-08T23:59:59Z',
|
|
40
|
+
sentAt: '2026-05-25T16:00:00Z',
|
|
41
|
+
payments: [],
|
|
42
|
+
createdAt: '2026-05-25T15:30:00Z',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'inv_003',
|
|
46
|
+
invoiceNumber: 'INV-2026-0003',
|
|
47
|
+
customerId: 'cus_003',
|
|
48
|
+
customerName: 'Mason Hardware Co.',
|
|
49
|
+
status: 'overdue',
|
|
50
|
+
lineItems: [
|
|
51
|
+
{ description: 'Walk-in cooler service (50% progress invoice)', qty: 1, unitPrice: 152000, taxable: true },
|
|
52
|
+
],
|
|
53
|
+
subtotal: 152000,
|
|
54
|
+
total: 152000,
|
|
55
|
+
amountPaid: 0,
|
|
56
|
+
amountDue: 152000,
|
|
57
|
+
dueDate: '2026-05-01T23:59:59Z',
|
|
58
|
+
sentAt: '2026-04-15T10:00:00Z',
|
|
59
|
+
payments: [],
|
|
60
|
+
createdAt: '2026-04-15T09:30:00Z',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'inv_004',
|
|
64
|
+
invoiceNumber: 'INV-2026-0004',
|
|
65
|
+
customerId: 'cus_001',
|
|
66
|
+
customerName: 'Acme Property Management',
|
|
67
|
+
status: 'partial',
|
|
68
|
+
lineItems: [
|
|
69
|
+
{ description: 'Walk-in cooler — full install (deposit)', qty: 1, unitPrice: 200000, taxable: true },
|
|
70
|
+
{ description: 'Walk-in cooler — labor', qty: 1, unitPrice: 380000, taxable: true },
|
|
71
|
+
],
|
|
72
|
+
subtotal: 580000,
|
|
73
|
+
total: 580000,
|
|
74
|
+
amountPaid: 200000,
|
|
75
|
+
amountDue: 380000,
|
|
76
|
+
dueDate: '2026-06-15T23:59:59Z',
|
|
77
|
+
sentAt: '2026-05-20T11:00:00Z',
|
|
78
|
+
payments: [
|
|
79
|
+
{ id: 'pay_002', amount: 200000, method: 'ach', recordedAt: '2026-05-22T09:15:00Z', note: 'Deposit' },
|
|
80
|
+
],
|
|
81
|
+
createdAt: '2026-05-20T10:30:00Z',
|
|
82
|
+
},
|
|
83
|
+
];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export const INVOICE_STATUSES = [
|
|
2
|
+
'draft',
|
|
3
|
+
'sent',
|
|
4
|
+
'partial',
|
|
5
|
+
'paid',
|
|
6
|
+
'overdue',
|
|
7
|
+
'void',
|
|
8
|
+
] as const;
|
|
9
|
+
export type InvoiceStatus = (typeof INVOICE_STATUSES)[number];
|
|
10
|
+
|
|
11
|
+
export const INVOICE_STATUS_LABEL: Record<InvoiceStatus, string> = {
|
|
12
|
+
draft: 'Draft',
|
|
13
|
+
sent: 'Sent',
|
|
14
|
+
partial: 'Partially paid',
|
|
15
|
+
paid: 'Paid',
|
|
16
|
+
overdue: 'Overdue',
|
|
17
|
+
void: 'Void',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const INVOICE_STATUS_VARIANT: Record<
|
|
21
|
+
InvoiceStatus,
|
|
22
|
+
'default' | 'secondary' | 'destructive' | 'outline'
|
|
23
|
+
> = {
|
|
24
|
+
draft: 'outline',
|
|
25
|
+
sent: 'secondary',
|
|
26
|
+
partial: 'secondary',
|
|
27
|
+
paid: 'default',
|
|
28
|
+
overdue: 'destructive',
|
|
29
|
+
void: 'outline',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const PAYMENT_METHODS = ['card', 'ach', 'cash', 'check', 'manual', 'stripe'] as const;
|
|
33
|
+
export type PaymentMethod = (typeof PAYMENT_METHODS)[number];
|
|
34
|
+
|
|
35
|
+
export const PAYMENT_METHOD_LABEL: Record<PaymentMethod, string> = {
|
|
36
|
+
card: 'Card',
|
|
37
|
+
ach: 'ACH',
|
|
38
|
+
cash: 'Cash',
|
|
39
|
+
check: 'Check',
|
|
40
|
+
manual: 'Manual',
|
|
41
|
+
stripe: 'Stripe',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface InvoiceLineItem {
|
|
45
|
+
description: string;
|
|
46
|
+
qty: number;
|
|
47
|
+
unitPrice: number; // cents
|
|
48
|
+
taxable?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface InvoicePayment {
|
|
52
|
+
id: string;
|
|
53
|
+
amount: number; // cents
|
|
54
|
+
method: PaymentMethod;
|
|
55
|
+
recordedAt: string; // ISO
|
|
56
|
+
note?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Invoice {
|
|
60
|
+
id: string;
|
|
61
|
+
invoiceNumber: string;
|
|
62
|
+
customerId: string;
|
|
63
|
+
customerName: string;
|
|
64
|
+
jobId?: string;
|
|
65
|
+
estimateId?: string;
|
|
66
|
+
status: InvoiceStatus;
|
|
67
|
+
lineItems: InvoiceLineItem[];
|
|
68
|
+
subtotal: number;
|
|
69
|
+
total: number;
|
|
70
|
+
amountPaid: number;
|
|
71
|
+
amountDue: number;
|
|
72
|
+
dueDate?: string;
|
|
73
|
+
sentAt?: string;
|
|
74
|
+
paidAt?: string;
|
|
75
|
+
notes?: string;
|
|
76
|
+
payments: InvoicePayment[];
|
|
77
|
+
createdAt: string;
|
|
78
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { asc } from 'drizzle-orm';
|
|
2
|
+
import { db } from '@/db/client';
|
|
3
|
+
import { customers } from '@/db/schema';
|
|
4
|
+
import { NewEstimateForm } from '@/components/estimates/new-estimate-form';
|
|
5
|
+
|
|
6
|
+
export default async function NewEstimatePage() {
|
|
7
|
+
const customerRows = await db
|
|
8
|
+
.select({ id: customers.id, name: customers.name })
|
|
9
|
+
.from(customers)
|
|
10
|
+
.orderBy(asc(customers.name));
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className="mx-auto max-w-3xl space-y-6">
|
|
14
|
+
<h1 className="text-3xl font-bold tracking-tight">New estimate</h1>
|
|
15
|
+
<NewEstimateForm customers={customerRows} />
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|