create-crm-starter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/dist/index.js +2341 -0
- package/package.json +51 -0
- package/template/base/_dot_gitignore +11 -0
- package/template/base/eslint.config.mjs +7 -0
- package/template/base/next.config.ts +17 -0
- package/template/base/package.json +35 -0
- package/template/base/postcss.config.mjs +7 -0
- package/template/base/scripts/setup.mjs +146 -0
- package/template/base/src/app/globals.css +68 -0
- package/template/base/src/app/layout.tsx +19 -0
- package/template/base/src/app/page.tsx +37 -0
- package/template/base/src/components/ui/badge.tsx +29 -0
- package/template/base/src/components/ui/button.tsx +49 -0
- package/template/base/src/components/ui/card.tsx +54 -0
- package/template/base/src/components/ui/input.tsx +19 -0
- package/template/base/src/components/ui/label.tsx +19 -0
- package/template/base/src/components/ui/separator.tsx +25 -0
- package/template/base/src/components/ui/skeleton.tsx +7 -0
- package/template/base/src/lib/utils.ts +17 -0
- package/template/base/tsconfig.json +21 -0
- package/template/extras/auth-better-auth/src/app/(auth)/layout.tsx +7 -0
- package/template/extras/auth-better-auth/src/app/(auth)/sign-in/page.tsx +65 -0
- package/template/extras/auth-better-auth/src/app/(auth)/sign-up/page.tsx +70 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/admin/page.tsx +68 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/dashboard/page.tsx +42 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/layout.tsx +41 -0
- package/template/extras/auth-better-auth/src/app/api/auth/[...all]/route.ts +4 -0
- package/template/extras/auth-better-auth/src/components/sign-out-button.tsx +19 -0
- package/template/extras/auth-better-auth/src/lib/auth-client.ts +7 -0
- package/template/extras/auth-better-auth/src/lib/auth-server.ts +51 -0
- package/template/extras/auth-better-auth/src/lib/auth.ts +76 -0
- package/template/extras/auth-better-auth/src/middleware.ts +25 -0
- package/template/extras/auth-clerk/src/app/(auth)/layout.tsx +7 -0
- package/template/extras/auth-clerk/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +5 -0
- package/template/extras/auth-clerk/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx +5 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/admin/page.tsx +37 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/dashboard/page.tsx +42 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/layout.tsx +57 -0
- package/template/extras/auth-clerk/src/lib/auth.ts +55 -0
- package/template/extras/auth-clerk/src/middleware.ts +17 -0
- package/template/extras/calendar-dispatch/_shared/src/app/(dashboard)/calendar/page.tsx +39 -0
- package/template/extras/calendar-dispatch/drizzle/src/app/(dashboard)/calendar/page.tsx +21 -0
- package/template/extras/calendar-dispatch/drizzle/src/components/calendar/calendar-board.tsx +195 -0
- package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/actions.ts +35 -0
- package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/data.ts +74 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/[id]/page.tsx +48 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/new/page.tsx +15 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/page.tsx +83 -0
- package/template/extras/checklists/_shared/src/components/jobs/job-checklists-section.tsx +18 -0
- package/template/extras/checklists/_shared/src/lib/checklists/data.ts +17 -0
- package/template/extras/checklists/_shared/src/lib/checklists/sample-data.ts +56 -0
- package/template/extras/checklists/_shared/src/lib/checklists/types.ts +47 -0
- package/template/extras/checklists/drizzle/src/app/(dashboard)/checklists/new/page.tsx +10 -0
- package/template/extras/checklists/drizzle/src/components/checklists/new-template-form.tsx +158 -0
- package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-client.tsx +202 -0
- package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-section.tsx +24 -0
- package/template/extras/checklists/drizzle/src/db/schema/checklists.ts +52 -0
- package/template/extras/checklists/drizzle/src/lib/checklists/actions.ts +112 -0
- package/template/extras/checklists/drizzle/src/lib/checklists/data.ts +80 -0
- package/template/extras/comms-email/src/app/(dashboard)/email/page.tsx +32 -0
- package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/new/page.tsx +35 -0
- package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/page.tsx +55 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/[customerId]/page.tsx +102 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/new/page.tsx +120 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/page.tsx +70 -0
- package/template/extras/comms-sms/drizzle/src/app/api/twilio/sms/route.ts +69 -0
- package/template/extras/comms-sms/drizzle/src/db/schema/sms-messages.ts +27 -0
- package/template/extras/comms-sms/drizzle/src/lib/sms/actions.ts +107 -0
- package/template/extras/comms-sms/drizzle/src/lib/sms/data.ts +111 -0
- package/template/extras/customer-portal/_shared/src/app/portal/[token]/layout.tsx +51 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/appointments/page.tsx +78 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/estimates/page.tsx +94 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/invoices/page.tsx +71 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/page.tsx +118 -0
- package/template/extras/customer-portal/drizzle/src/components/portal/estimate-approval.tsx +141 -0
- package/template/extras/customer-portal/drizzle/src/components/portal/signature-pad.tsx +126 -0
- package/template/extras/customer-portal/drizzle/src/lib/portal/actions.ts +107 -0
- package/template/extras/customer-portal/drizzle/src/lib/portal/data.ts +158 -0
- package/template/extras/customers/_fragments/convex.txt +28 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/[id]/page.tsx +81 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/new/page.tsx +16 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/page.tsx +73 -0
- package/template/extras/customers/_shared/src/lib/customers/data.ts +15 -0
- package/template/extras/customers/_shared/src/lib/customers/sample-data.ts +67 -0
- package/template/extras/customers/_shared/src/lib/customers/types.ts +31 -0
- package/template/extras/customers/convex/convex/customers.ts +52 -0
- package/template/extras/customers/convex/src/lib/customers/data.ts +64 -0
- package/template/extras/customers/drizzle/src/app/(dashboard)/customers/new/page.tsx +82 -0
- package/template/extras/customers/drizzle/src/db/schema/customers.ts +34 -0
- package/template/extras/customers/drizzle/src/lib/customers/actions.ts +67 -0
- package/template/extras/customers/drizzle/src/lib/customers/data.ts +34 -0
- package/template/extras/db-convex/convex/_generated/README.md +13 -0
- package/template/extras/db-convex/convex/_generated/api.d.ts +8 -0
- package/template/extras/db-convex/convex/_generated/api.js +12 -0
- package/template/extras/db-convex/convex/_generated/dataModel.d.ts +9 -0
- package/template/extras/db-convex/convex/_generated/server.d.ts +18 -0
- package/template/extras/db-convex/convex/_generated/server.js +12 -0
- package/template/extras/db-convex/convex/auth.config.ts +17 -0
- package/template/extras/db-convex/convex/schema.ts +28 -0
- package/template/extras/db-convex/src/app/layout.tsx +20 -0
- package/template/extras/db-convex/src/components/providers.tsx +28 -0
- package/template/extras/db-convex/src/lib/convex.ts +6 -0
- package/template/extras/db-drizzle-pg/docker-compose.yml +21 -0
- package/template/extras/db-drizzle-pg/drizzle.config.ts +24 -0
- package/template/extras/db-drizzle-pg/src/app/layout.tsx +20 -0
- package/template/extras/db-drizzle-pg/src/components/providers.tsx +16 -0
- package/template/extras/db-drizzle-pg/src/db/client.ts +14 -0
- package/template/extras/db-drizzle-pg/src/db/schema/auth.ts +62 -0
- package/template/extras/db-drizzle-pg/src/db/schema/index.ts +3 -0
- package/template/extras/emergency-dispatch/src/app/(dashboard)/emergency/page.tsx +43 -0
- package/template/extras/equipment-tracking/src/app/(dashboard)/equipment/page.tsx +61 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/[id]/page.tsx +123 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/new/page.tsx +22 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/page.tsx +102 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/[id]/page.tsx +168 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/page.tsx +100 -0
- package/template/extras/estimates-invoices/_shared/src/components/estimates/convert-to-job-button.tsx +14 -0
- package/template/extras/estimates-invoices/_shared/src/components/invoices/pay-invoice-button.tsx +15 -0
- package/template/extras/estimates-invoices/_shared/src/components/invoices/send-invoice-email-button.tsx +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/data.ts +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/sample-data.ts +74 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/types.ts +60 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/data.ts +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/sample-data.ts +83 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/types.ts +78 -0
- package/template/extras/estimates-invoices/drizzle/src/app/(dashboard)/estimates/new/page.tsx +18 -0
- package/template/extras/estimates-invoices/drizzle/src/app/api/stripe/webhook/route.ts +87 -0
- package/template/extras/estimates-invoices/drizzle/src/app/i/[token]/page.tsx +148 -0
- package/template/extras/estimates-invoices/drizzle/src/components/estimates/convert-to-job-button.tsx +18 -0
- package/template/extras/estimates-invoices/drizzle/src/components/estimates/new-estimate-form.tsx +261 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/pay-invoice-button.tsx +19 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/public-pay-button.tsx +20 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/send-invoice-email-button.tsx +37 -0
- package/template/extras/estimates-invoices/drizzle/src/components/jobs/generate-invoice-button.tsx +23 -0
- package/template/extras/estimates-invoices/drizzle/src/db/schema/estimates.ts +41 -0
- package/template/extras/estimates-invoices/drizzle/src/db/schema/invoices.ts +59 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/estimates/actions.ts +110 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/estimates/data.ts +57 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/actions.ts +199 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/data.ts +99 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/email-actions.ts +102 -0
- package/template/extras/inspection-checklists/src/app/(dashboard)/inspections/page.tsx +60 -0
- package/template/extras/jobs/_fragments/convex.txt +21 -0
- package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/[id]/page.tsx +102 -0
- package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/page.tsx +72 -0
- package/template/extras/jobs/_shared/src/components/jobs/advance-status-button.tsx +21 -0
- package/template/extras/jobs/_shared/src/components/jobs/generate-invoice-button.tsx +15 -0
- package/template/extras/jobs/_shared/src/components/jobs/photo-gallery.tsx +17 -0
- package/template/extras/jobs/_shared/src/components/jobs/photos-section.tsx +18 -0
- package/template/extras/jobs/_shared/src/lib/jobs/data.ts +14 -0
- package/template/extras/jobs/_shared/src/lib/jobs/sample-data.ts +50 -0
- package/template/extras/jobs/_shared/src/lib/jobs/types.ts +62 -0
- package/template/extras/jobs/convex/convex/jobs.ts +46 -0
- package/template/extras/jobs/convex/src/lib/jobs/data.ts +65 -0
- package/template/extras/jobs/drizzle/src/app/(dashboard)/jobs/new/page.tsx +18 -0
- package/template/extras/jobs/drizzle/src/components/jobs/advance-status-button.tsx +34 -0
- package/template/extras/jobs/drizzle/src/components/jobs/new-job-form.tsx +275 -0
- package/template/extras/jobs/drizzle/src/components/jobs/photo-gallery.tsx +130 -0
- package/template/extras/jobs/drizzle/src/components/jobs/photos-section.tsx +7 -0
- package/template/extras/jobs/drizzle/src/db/schema/job-attachments.ts +26 -0
- package/template/extras/jobs/drizzle/src/db/schema/jobs.ts +29 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/actions.ts +71 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/data.ts +48 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/photos-actions.ts +121 -0
- package/template/extras/jobs/drizzle/src/lib/r2.ts +45 -0
- package/template/extras/landing-page/_shared/src/app/book/page.tsx +43 -0
- package/template/extras/landing-page/_shared/src/app/book/thanks/page.tsx +31 -0
- package/template/extras/landing-page/_shared/src/app/page.tsx +81 -0
- package/template/extras/landing-page/drizzle/src/app/book/page.tsx +105 -0
- package/template/extras/landing-page/drizzle/src/lib/booking/actions.ts +97 -0
- package/template/extras/maintenance-plans/src/app/(dashboard)/maintenance-plans/page.tsx +72 -0
- package/template/extras/mobile/mobile/README.md +67 -0
- package/template/extras/mobile/mobile/_dot_env.example +5 -0
- package/template/extras/mobile/mobile/_dot_gitignore +26 -0
- package/template/extras/mobile/mobile/app/(app)/_layout.tsx +37 -0
- package/template/extras/mobile/mobile/app/(app)/estimate.tsx +135 -0
- package/template/extras/mobile/mobile/app/(app)/inbox/[customerId].tsx +103 -0
- package/template/extras/mobile/mobile/app/(app)/inbox/index.tsx +70 -0
- package/template/extras/mobile/mobile/app/(app)/index.tsx +111 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id]/checklist.tsx +99 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id]/invoice.tsx +143 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id].tsx +259 -0
- package/template/extras/mobile/mobile/app/_layout.tsx +14 -0
- package/template/extras/mobile/mobile/app/index.tsx +23 -0
- package/template/extras/mobile/mobile/app/sign-in.tsx +101 -0
- package/template/extras/mobile/mobile/app.brand.ts +14 -0
- package/template/extras/mobile/mobile/app.config.ts +40 -0
- package/template/extras/mobile/mobile/app.features.ts +11 -0
- package/template/extras/mobile/mobile/components/SignaturePad.tsx +60 -0
- package/template/extras/mobile/mobile/eas.json +22 -0
- package/template/extras/mobile/mobile/lib/api.ts +253 -0
- package/template/extras/mobile/mobile/lib/auth.ts +51 -0
- package/template/extras/mobile/mobile/lib/format.ts +23 -0
- package/template/extras/mobile/mobile/lib/push.ts +24 -0
- package/template/extras/mobile/mobile/lib/theme.ts +16 -0
- package/template/extras/mobile/mobile/package.json +34 -0
- package/template/extras/mobile/mobile/tsconfig.json +11 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/customers/[id]/route.ts +18 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/devices/route.ts +40 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/attachments/route.ts +59 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/item/route.ts +34 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/route.ts +15 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/route.ts +35 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/status/route.ts +28 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-in/route.ts +35 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-out/route.ts +27 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/route.ts +36 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/route.ts +30 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/me/route.ts +26 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/uploads/sign/route.ts +46 -0
- package/template/extras/mobile-api/drizzle/src/db/schema/push-tokens.ts +21 -0
- package/template/extras/mobile-api/drizzle/src/db/schema/time-entries.ts +23 -0
- package/template/extras/mobile-api/drizzle/src/lib/mobile/cors.ts +30 -0
- package/template/extras/mobile-api/drizzle/src/lib/mobile/guard.ts +49 -0
- package/template/extras/mobile-api/drizzle/src/lib/push/send.ts +56 -0
- package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/estimates/route.ts +52 -0
- package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/price-book/route.ts +14 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/payments/route.ts +32 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/route.ts +28 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/jobs/[id]/invoice/route.ts +82 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/send/route.ts +32 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/[customerId]/route.ts +15 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/route.ts +14 -0
- package/template/extras/payments-stripe/src/app/(dashboard)/payments/page.tsx +56 -0
- package/template/extras/payments-stripe/src/app/api/stripe/webhook/route.ts +56 -0
- package/template/extras/payments-stripe/src/components/payments/demo-checkout-button.tsx +18 -0
- package/template/extras/payments-stripe/src/lib/payments/actions.ts +63 -0
- package/template/extras/payments-stripe/src/lib/stripe.ts +25 -0
- package/template/extras/permit-tracking/src/app/(dashboard)/permits/page.tsx +56 -0
- package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/new/page.tsx +20 -0
- package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/page.tsx +79 -0
- package/template/extras/price-book/_shared/src/components/price-book/item-picker.tsx +145 -0
- package/template/extras/price-book/_shared/src/lib/price-book/actions.ts +14 -0
- package/template/extras/price-book/_shared/src/lib/price-book/data.ts +28 -0
- package/template/extras/price-book/_shared/src/lib/price-book/sample-data.ts +62 -0
- package/template/extras/price-book/_shared/src/lib/price-book/types.ts +35 -0
- package/template/extras/price-book/drizzle/src/app/(dashboard)/price-book/new/page.tsx +18 -0
- package/template/extras/price-book/drizzle/src/components/price-book/new-item-form.tsx +254 -0
- package/template/extras/price-book/drizzle/src/db/schema/price-book.ts +33 -0
- package/template/extras/price-book/drizzle/src/lib/price-book/actions.ts +72 -0
- package/template/extras/price-book/drizzle/src/lib/price-book/data.ts +81 -0
- package/template/extras/reporting/src/app/(dashboard)/reports/page.tsx +66 -0
- package/template/extras/reviews/src/app/(dashboard)/reviews/page.tsx +58 -0
- package/template/extras/seed/_shared/scripts/seed.ts +35 -0
- package/template/extras/seed/drizzle/scripts/seed.ts +314 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/[id]/page.tsx +114 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/new/page.tsx +18 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/page.tsx +92 -0
- package/template/extras/service-plans/_shared/src/components/service-plans/subscribe-customer-section.tsx +17 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/data.ts +14 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/sample-data.ts +83 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/types.ts +57 -0
- package/template/extras/service-plans/drizzle/src/app/(dashboard)/service-plans/new/page.tsx +10 -0
- package/template/extras/service-plans/drizzle/src/app/api/stripe/webhook/route.ts +143 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/new-plan-form.tsx +126 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-form.tsx +88 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-section.tsx +12 -0
- package/template/extras/service-plans/drizzle/src/db/schema/service-plans.ts +46 -0
- package/template/extras/service-plans/drizzle/src/lib/service-plans/actions.ts +196 -0
- package/template/extras/service-plans/drizzle/src/lib/service-plans/data.ts +124 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
2
|
+
import { Badge } from '@/components/ui/badge';
|
|
3
|
+
import { DemoCheckoutButton } from '@/components/payments/demo-checkout-button';
|
|
4
|
+
import { formatCurrency, formatDate } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
const SAMPLE = [
|
|
7
|
+
{ id: 'pay_001', customer: 'Acme Property Management', amount: 50000, method: 'Card', status: 'succeeded', at: '2026-05-14' },
|
|
8
|
+
{ id: 'pay_002', customer: 'Jamie Rodriguez', amount: 14500, method: 'ACH', status: 'succeeded', at: '2026-04-16' },
|
|
9
|
+
{ id: 'pay_003', customer: 'Mason Hardware Co.', amount: 50000, method: 'Card', status: 'pending', at: '2026-05-23' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export default function PaymentsPage() {
|
|
13
|
+
return (
|
|
14
|
+
<div className="space-y-6">
|
|
15
|
+
<div className="flex items-start justify-between">
|
|
16
|
+
<div>
|
|
17
|
+
<h1 className="text-3xl font-bold tracking-tight">Payments</h1>
|
|
18
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
19
|
+
Powered by Stripe. Set <code>STRIPE_SECRET_KEY</code> + <code>STRIPE_WEBHOOK_SECRET</code> in <code>.env.local</code>;
|
|
20
|
+
webhook handler is wired at <code>/api/stripe/webhook</code>.
|
|
21
|
+
</p>
|
|
22
|
+
</div>
|
|
23
|
+
<DemoCheckoutButton />
|
|
24
|
+
</div>
|
|
25
|
+
<Card>
|
|
26
|
+
<CardHeader><CardTitle>Recent payments</CardTitle></CardHeader>
|
|
27
|
+
<CardContent className="p-0">
|
|
28
|
+
<table className="w-full text-sm">
|
|
29
|
+
<thead className="bg-muted/50 text-muted-foreground text-xs uppercase">
|
|
30
|
+
<tr>
|
|
31
|
+
<th className="px-4 py-3 text-left font-medium">Customer</th>
|
|
32
|
+
<th className="px-4 py-3 text-left font-medium">Method</th>
|
|
33
|
+
<th className="px-4 py-3 text-left font-medium">Status</th>
|
|
34
|
+
<th className="px-4 py-3 text-left font-medium">Date</th>
|
|
35
|
+
<th className="px-4 py-3 text-right font-medium">Amount</th>
|
|
36
|
+
</tr>
|
|
37
|
+
</thead>
|
|
38
|
+
<tbody className="divide-border divide-y">
|
|
39
|
+
{SAMPLE.map((p) => (
|
|
40
|
+
<tr key={p.id}>
|
|
41
|
+
<td className="px-4 py-3 font-medium">{p.customer}</td>
|
|
42
|
+
<td className="text-muted-foreground px-4 py-3">{p.method}</td>
|
|
43
|
+
<td className="px-4 py-3">
|
|
44
|
+
<Badge variant={p.status === 'succeeded' ? 'default' : 'secondary'}>{p.status}</Badge>
|
|
45
|
+
</td>
|
|
46
|
+
<td className="text-muted-foreground px-4 py-3">{formatDate(p.at)}</td>
|
|
47
|
+
<td className="px-4 py-3 text-right font-medium">{formatCurrency(p.amount)}</td>
|
|
48
|
+
</tr>
|
|
49
|
+
))}
|
|
50
|
+
</tbody>
|
|
51
|
+
</table>
|
|
52
|
+
</CardContent>
|
|
53
|
+
</Card>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import type Stripe from 'stripe';
|
|
4
|
+
import { stripe } from '@/lib/stripe';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Stripe webhook endpoint. Set STRIPE_WEBHOOK_SECRET in .env.local and
|
|
8
|
+
* point Stripe (CLI: `stripe listen --forward-to localhost:3000/api/stripe/webhook`)
|
|
9
|
+
* here. The handler verifies the signature, then dispatches by event type.
|
|
10
|
+
*/
|
|
11
|
+
export async function POST(req: NextRequest) {
|
|
12
|
+
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
|
13
|
+
if (!secret) {
|
|
14
|
+
return NextResponse.json({ error: 'STRIPE_WEBHOOK_SECRET not set' }, { status: 500 });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const signature = req.headers.get('stripe-signature');
|
|
18
|
+
if (!signature) {
|
|
19
|
+
return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const body = await req.text();
|
|
23
|
+
let event: Stripe.Event;
|
|
24
|
+
try {
|
|
25
|
+
event = stripe.webhooks.constructEvent(body, signature, secret);
|
|
26
|
+
} catch (err) {
|
|
27
|
+
const message = err instanceof Error ? err.message : 'Signature verification failed';
|
|
28
|
+
return NextResponse.json({ error: message }, { status: 400 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
switch (event.type) {
|
|
32
|
+
case 'checkout.session.completed': {
|
|
33
|
+
const session = event.data.object as Stripe.Checkout.Session;
|
|
34
|
+
console.log(
|
|
35
|
+
'[stripe] checkout.session.completed',
|
|
36
|
+
session.id,
|
|
37
|
+
session.amount_total,
|
|
38
|
+
session.metadata,
|
|
39
|
+
);
|
|
40
|
+
// TODO: mark the matching invoice as paid in your DB here. The
|
|
41
|
+
// session.metadata you set in createCheckoutSession is your reconcile
|
|
42
|
+
// key (invoiceId, customerId, jobId, etc.).
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
case 'payment_intent.succeeded':
|
|
46
|
+
case 'payment_intent.payment_failed':
|
|
47
|
+
// Stripe Checkout's session.completed event covers the success path;
|
|
48
|
+
// these are useful if you take payments via PaymentIntents directly.
|
|
49
|
+
break;
|
|
50
|
+
default:
|
|
51
|
+
// Acknowledge unhandled events so Stripe doesn't keep retrying them.
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return NextResponse.json({ received: true });
|
|
56
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useTransition } from 'react';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { startDemoCheckout } from '@/lib/payments/actions';
|
|
6
|
+
|
|
7
|
+
export function DemoCheckoutButton() {
|
|
8
|
+
const [pending, start] = useTransition();
|
|
9
|
+
return (
|
|
10
|
+
<Button
|
|
11
|
+
size="sm"
|
|
12
|
+
disabled={pending}
|
|
13
|
+
onClick={() => start(() => startDemoCheckout())}
|
|
14
|
+
>
|
|
15
|
+
{pending ? 'Redirecting...' : 'Try $10 Stripe checkout'}
|
|
16
|
+
</Button>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { headers } from 'next/headers';
|
|
4
|
+
import { redirect } from 'next/navigation';
|
|
5
|
+
import { stripe, isStripeConfigured } from '@/lib/stripe';
|
|
6
|
+
|
|
7
|
+
export interface CreateCheckoutSessionInput {
|
|
8
|
+
amount: number; // cents
|
|
9
|
+
description: string;
|
|
10
|
+
customerEmail?: string;
|
|
11
|
+
successPath?: string; // path to redirect to on success
|
|
12
|
+
cancelPath?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Server action — creates a Stripe Checkout Session and redirects the
|
|
17
|
+
* user to Stripe's hosted checkout page. The session metadata captures
|
|
18
|
+
* what the payment is for; the webhook handler at
|
|
19
|
+
* /api/stripe/webhook reconciles status on completion.
|
|
20
|
+
*/
|
|
21
|
+
export async function createCheckoutSession(input: CreateCheckoutSessionInput): Promise<void> {
|
|
22
|
+
if (!isStripeConfigured) {
|
|
23
|
+
throw new Error('Stripe is not configured. Set STRIPE_SECRET_KEY in .env.local.');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const h = await headers();
|
|
27
|
+
const host = h.get('host') ?? 'localhost:3000';
|
|
28
|
+
const proto = h.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https');
|
|
29
|
+
const origin = `${proto}://${host}`;
|
|
30
|
+
|
|
31
|
+
const session = await stripe.checkout.sessions.create({
|
|
32
|
+
mode: 'payment',
|
|
33
|
+
customer_email: input.customerEmail,
|
|
34
|
+
line_items: [
|
|
35
|
+
{
|
|
36
|
+
price_data: {
|
|
37
|
+
currency: 'usd',
|
|
38
|
+
product_data: { name: input.description },
|
|
39
|
+
unit_amount: input.amount,
|
|
40
|
+
},
|
|
41
|
+
quantity: 1,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
success_url: `${origin}${input.successPath ?? '/payments?status=success'}&session_id={CHECKOUT_SESSION_ID}`,
|
|
45
|
+
cancel_url: `${origin}${input.cancelPath ?? '/payments?status=cancelled'}`,
|
|
46
|
+
metadata: { description: input.description },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!session.url) throw new Error('Stripe did not return a checkout URL');
|
|
50
|
+
redirect(session.url);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Convenience action — kicks off a $10 demo checkout from the payments page.
|
|
55
|
+
*/
|
|
56
|
+
export async function startDemoCheckout(): Promise<void> {
|
|
57
|
+
await createCheckoutSession({
|
|
58
|
+
amount: 1000,
|
|
59
|
+
description: 'CRM demo checkout',
|
|
60
|
+
successPath: '/payments?status=success',
|
|
61
|
+
cancelPath: '/payments?status=cancelled',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import Stripe from 'stripe';
|
|
3
|
+
|
|
4
|
+
const apiKey = process.env.STRIPE_SECRET_KEY;
|
|
5
|
+
|
|
6
|
+
// NOTE: do NOT throw at module load when the key is missing. `next build`
|
|
7
|
+
// imports this module while collecting route data for /api/stripe/webhook,
|
|
8
|
+
// so throwing here breaks every production build done before Stripe is
|
|
9
|
+
// configured (the common first-deploy case). Instead construct with a
|
|
10
|
+
// harmless placeholder and gate real usage on `isStripeConfigured` at call
|
|
11
|
+
// time (server actions already do this, and the webhook route 503s early).
|
|
12
|
+
|
|
13
|
+
// Let Stripe default to the account's pinned API version. Pin explicitly
|
|
14
|
+
// (e.g. apiVersion: '2025-02-24.acacia') if you need a specific version.
|
|
15
|
+
// `||` (not `??`): a scaffolded .env.local ships STRIPE_SECRET_KEY="" (empty
|
|
16
|
+
// string), which `??` would NOT replace — Stripe then throws "Neither apiKey
|
|
17
|
+
// nor config.authenticator provided" while `next build` collects route data.
|
|
18
|
+
export const stripe = new Stripe(apiKey || 'sk_test_placeholder', {
|
|
19
|
+
typescript: true,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const isStripeConfigured = (() => {
|
|
23
|
+
const k = process.env.STRIPE_SECRET_KEY;
|
|
24
|
+
return typeof k === 'string' && k.startsWith('sk_') && !k.includes('placeholder');
|
|
25
|
+
})();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
2
|
+
import { Badge } from '@/components/ui/badge';
|
|
3
|
+
import { formatDate } from '@/lib/utils';
|
|
4
|
+
|
|
5
|
+
const PERMITS = [
|
|
6
|
+
{ id: 'p1', job: 'Mason Hardware — Walk-in Cooler', jurisdiction: 'Austin', number: 'A-2026-04812', status: 'approved', filedAt: '2026-04-15', expiresAt: '2027-04-15' },
|
|
7
|
+
{ id: 'p2', job: 'Acme — Service Upgrade', jurisdiction: 'Austin', number: 'A-2026-04901', status: 'pending', filedAt: '2026-05-20', expiresAt: null },
|
|
8
|
+
{ id: 'p3', job: 'Priya — Panel Upgrade', jurisdiction: 'Cedar Park', number: 'CP-2026-1207', status: 'inspection_scheduled', filedAt: '2026-05-10', expiresAt: '2027-05-10' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
|
12
|
+
pending: 'secondary',
|
|
13
|
+
approved: 'default',
|
|
14
|
+
inspection_scheduled: 'outline',
|
|
15
|
+
rejected: 'destructive',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default function PermitsPage() {
|
|
19
|
+
return (
|
|
20
|
+
<div className="space-y-6">
|
|
21
|
+
<div>
|
|
22
|
+
<h1 className="text-3xl font-bold tracking-tight">Permits</h1>
|
|
23
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
24
|
+
Track filings, inspections, and approvals across jurisdictions.
|
|
25
|
+
</p>
|
|
26
|
+
</div>
|
|
27
|
+
<Card>
|
|
28
|
+
<CardHeader><CardTitle>Open permits</CardTitle></CardHeader>
|
|
29
|
+
<CardContent className="p-0">
|
|
30
|
+
<table className="w-full text-sm">
|
|
31
|
+
<thead className="bg-muted/50 text-muted-foreground text-xs uppercase">
|
|
32
|
+
<tr>
|
|
33
|
+
<th className="px-4 py-3 text-left font-medium">Job</th>
|
|
34
|
+
<th className="px-4 py-3 text-left font-medium">Jurisdiction</th>
|
|
35
|
+
<th className="px-4 py-3 text-left font-medium">Number</th>
|
|
36
|
+
<th className="px-4 py-3 text-left font-medium">Filed</th>
|
|
37
|
+
<th className="px-4 py-3 text-left font-medium">Status</th>
|
|
38
|
+
</tr>
|
|
39
|
+
</thead>
|
|
40
|
+
<tbody className="divide-border divide-y">
|
|
41
|
+
{PERMITS.map((p) => (
|
|
42
|
+
<tr key={p.id}>
|
|
43
|
+
<td className="px-4 py-3 font-medium">{p.job}</td>
|
|
44
|
+
<td className="text-muted-foreground px-4 py-3">{p.jurisdiction}</td>
|
|
45
|
+
<td className="text-muted-foreground px-4 py-3 font-mono text-xs">{p.number}</td>
|
|
46
|
+
<td className="text-muted-foreground px-4 py-3">{formatDate(p.filedAt)}</td>
|
|
47
|
+
<td className="px-4 py-3"><Badge variant={VARIANT[p.status]}>{p.status.replace(/_/g, ' ')}</Badge></td>
|
|
48
|
+
</tr>
|
|
49
|
+
))}
|
|
50
|
+
</tbody>
|
|
51
|
+
</table>
|
|
52
|
+
</CardContent>
|
|
53
|
+
</Card>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default stub form. The Drizzle installer overwrites this with a working
|
|
5
|
+
* server-action-backed form.
|
|
6
|
+
*/
|
|
7
|
+
export default function NewPriceBookItemPage() {
|
|
8
|
+
return (
|
|
9
|
+
<div className="mx-auto max-w-2xl space-y-6">
|
|
10
|
+
<h1 className="text-3xl font-bold tracking-tight">New price book item</h1>
|
|
11
|
+
<Card>
|
|
12
|
+
<CardHeader><CardTitle>Form goes here</CardTitle></CardHeader>
|
|
13
|
+
<CardContent className="text-muted-foreground text-sm">
|
|
14
|
+
Wire this to your stack to enable. Drizzle ships a real version
|
|
15
|
+
when you scaffold with that stack.
|
|
16
|
+
</CardContent>
|
|
17
|
+
</Card>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { Card, CardContent } from '@/components/ui/card';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { Badge } from '@/components/ui/badge';
|
|
5
|
+
import { getPriceBook } from '@/lib/price-book/data';
|
|
6
|
+
import { PRICE_BOOK_KIND_LABEL } from '@/lib/price-book/types';
|
|
7
|
+
import { formatCurrency } from '@/lib/utils';
|
|
8
|
+
|
|
9
|
+
export default async function PriceBookPage() {
|
|
10
|
+
const catalog = await getPriceBook();
|
|
11
|
+
const totalItems = catalog.reduce((acc, c) => acc + c.items.length, 0);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="space-y-6">
|
|
15
|
+
<div className="flex items-center justify-between">
|
|
16
|
+
<div>
|
|
17
|
+
<h1 className="text-3xl font-bold tracking-tight">Price Book</h1>
|
|
18
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
19
|
+
{catalog.length} categories · {totalItems} items · the source of truth for estimates + jobs + invoices
|
|
20
|
+
</p>
|
|
21
|
+
</div>
|
|
22
|
+
<Button asChild>
|
|
23
|
+
<Link href="/price-book/new">New item</Link>
|
|
24
|
+
</Button>
|
|
25
|
+
</div>
|
|
26
|
+
{catalog.map((cat) => (
|
|
27
|
+
<Card key={cat.id}>
|
|
28
|
+
<div className="border-border bg-muted/30 border-b px-4 py-3">
|
|
29
|
+
<h2 className="text-base font-semibold">{cat.name}</h2>
|
|
30
|
+
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
31
|
+
{cat.items.length} item{cat.items.length === 1 ? '' : 's'}
|
|
32
|
+
</p>
|
|
33
|
+
</div>
|
|
34
|
+
<CardContent className="p-0">
|
|
35
|
+
{cat.items.length === 0 ? (
|
|
36
|
+
<p className="text-muted-foreground p-4 text-sm">No items in this category yet.</p>
|
|
37
|
+
) : (
|
|
38
|
+
<table className="w-full text-sm">
|
|
39
|
+
<thead className="text-muted-foreground bg-muted/20 text-xs uppercase">
|
|
40
|
+
<tr>
|
|
41
|
+
<th className="px-4 py-2 text-left font-medium">Name</th>
|
|
42
|
+
<th className="px-4 py-2 text-left font-medium">Kind</th>
|
|
43
|
+
<th className="px-4 py-2 text-left font-medium">SKU</th>
|
|
44
|
+
<th className="px-4 py-2 text-right font-medium">Cost</th>
|
|
45
|
+
<th className="px-4 py-2 text-right font-medium">Price</th>
|
|
46
|
+
<th className="px-4 py-2 text-right font-medium">Margin</th>
|
|
47
|
+
</tr>
|
|
48
|
+
</thead>
|
|
49
|
+
<tbody className="divide-border divide-y">
|
|
50
|
+
{cat.items.map((it) => (
|
|
51
|
+
<tr key={it.id} className="hover:bg-muted/30">
|
|
52
|
+
<td className="px-4 py-2">
|
|
53
|
+
<div className="font-medium">{it.name}</div>
|
|
54
|
+
{it.description && (
|
|
55
|
+
<div className="text-muted-foreground mt-0.5 text-xs">{it.description}</div>
|
|
56
|
+
)}
|
|
57
|
+
</td>
|
|
58
|
+
<td className="px-4 py-2">
|
|
59
|
+
<Badge variant="outline">{PRICE_BOOK_KIND_LABEL[it.kind]}</Badge>
|
|
60
|
+
</td>
|
|
61
|
+
<td className="text-muted-foreground px-4 py-2 font-mono text-xs">{it.sku ?? '—'}</td>
|
|
62
|
+
<td className="text-muted-foreground px-4 py-2 text-right">{formatCurrency(it.unitCost)}</td>
|
|
63
|
+
<td className="px-4 py-2 text-right font-medium">{formatCurrency(it.unitPrice)}</td>
|
|
64
|
+
<td className="px-4 py-2 text-right">
|
|
65
|
+
<span className={it.marginPct < 25 ? 'text-destructive' : it.marginPct >= 50 ? 'text-emerald-600' : ''}>
|
|
66
|
+
{it.marginPct}%
|
|
67
|
+
</span>
|
|
68
|
+
</td>
|
|
69
|
+
</tr>
|
|
70
|
+
))}
|
|
71
|
+
</tbody>
|
|
72
|
+
</table>
|
|
73
|
+
)}
|
|
74
|
+
</CardContent>
|
|
75
|
+
</Card>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
4
|
+
import { Input } from '@/components/ui/input';
|
|
5
|
+
import { searchPriceBookItemsAction } from '@/lib/price-book/actions';
|
|
6
|
+
import type { PriceBookItem } from '@/lib/price-book/types';
|
|
7
|
+
import { formatCurrency } from '@/lib/utils';
|
|
8
|
+
|
|
9
|
+
interface ItemPickerProps {
|
|
10
|
+
/** Current description text (controlled). */
|
|
11
|
+
value: string;
|
|
12
|
+
/** Called as the user types. Always fires, even when picking from dropdown. */
|
|
13
|
+
onTextChange: (value: string) => void;
|
|
14
|
+
/** Called when the user selects a price-book item from the dropdown. */
|
|
15
|
+
onPick: (item: PriceBookItem) => void;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
id?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Autocomplete-style line-item description input backed by the Price
|
|
22
|
+
* Book. Types are debounced; matches appear in a dropdown below the
|
|
23
|
+
* input; picking one fires onPick with the full item (so parent can
|
|
24
|
+
* autofill qty + price + cost + taxable).
|
|
25
|
+
*
|
|
26
|
+
* Falls back gracefully: typing a description that has no matches just
|
|
27
|
+
* leaves the field as free-text — picker stays optional, not blocking.
|
|
28
|
+
*/
|
|
29
|
+
export function PriceBookItemPicker({
|
|
30
|
+
value,
|
|
31
|
+
onTextChange,
|
|
32
|
+
onPick,
|
|
33
|
+
placeholder = 'Search Price Book or type free-text…',
|
|
34
|
+
id,
|
|
35
|
+
}: ItemPickerProps) {
|
|
36
|
+
const fallbackId = useId();
|
|
37
|
+
const inputId = id ?? fallbackId;
|
|
38
|
+
const [results, setResults] = useState<PriceBookItem[]>([]);
|
|
39
|
+
const [open, setOpen] = useState(false);
|
|
40
|
+
const [activeIdx, setActiveIdx] = useState(0);
|
|
41
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
const lastQueryRef = useRef('');
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const q = value.trim();
|
|
46
|
+
if (q.length < 2) {
|
|
47
|
+
setResults([]);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const handle = setTimeout(async () => {
|
|
51
|
+
lastQueryRef.current = q;
|
|
52
|
+
try {
|
|
53
|
+
const items = await searchPriceBookItemsAction(q);
|
|
54
|
+
// Race-condition guard: only commit if this is still the latest query.
|
|
55
|
+
if (lastQueryRef.current === q) {
|
|
56
|
+
setResults(items);
|
|
57
|
+
setActiveIdx(0);
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
setResults([]);
|
|
61
|
+
}
|
|
62
|
+
}, 180);
|
|
63
|
+
return () => clearTimeout(handle);
|
|
64
|
+
}, [value]);
|
|
65
|
+
|
|
66
|
+
// Close dropdown on outside click.
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!open) return;
|
|
69
|
+
function onClick(e: MouseEvent) {
|
|
70
|
+
if (!containerRef.current?.contains(e.target as Node)) setOpen(false);
|
|
71
|
+
}
|
|
72
|
+
document.addEventListener('mousedown', onClick);
|
|
73
|
+
return () => document.removeEventListener('mousedown', onClick);
|
|
74
|
+
}, [open]);
|
|
75
|
+
|
|
76
|
+
function handlePick(item: PriceBookItem) {
|
|
77
|
+
onPick(item);
|
|
78
|
+
setOpen(false);
|
|
79
|
+
setResults([]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function handleKey(e: React.KeyboardEvent<HTMLInputElement>) {
|
|
83
|
+
if (!open || results.length === 0) return;
|
|
84
|
+
if (e.key === 'ArrowDown') {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
setActiveIdx((i) => Math.min(i + 1, results.length - 1));
|
|
87
|
+
} else if (e.key === 'ArrowUp') {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
setActiveIdx((i) => Math.max(i - 1, 0));
|
|
90
|
+
} else if (e.key === 'Enter') {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
handlePick(results[activeIdx]);
|
|
93
|
+
} else if (e.key === 'Escape') {
|
|
94
|
+
setOpen(false);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div ref={containerRef} className="relative">
|
|
100
|
+
<Input
|
|
101
|
+
id={inputId}
|
|
102
|
+
value={value}
|
|
103
|
+
onChange={(e) => {
|
|
104
|
+
onTextChange(e.target.value);
|
|
105
|
+
setOpen(true);
|
|
106
|
+
}}
|
|
107
|
+
onFocus={() => setOpen(true)}
|
|
108
|
+
onKeyDown={handleKey}
|
|
109
|
+
placeholder={placeholder}
|
|
110
|
+
autoComplete="off"
|
|
111
|
+
/>
|
|
112
|
+
{open && results.length > 0 && (
|
|
113
|
+
<div className="border-input bg-background absolute z-20 mt-1 max-h-72 w-full overflow-y-auto rounded-md border shadow-lg">
|
|
114
|
+
{results.map((item, idx) => (
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
key={item.id}
|
|
118
|
+
onMouseDown={(e) => { e.preventDefault(); handlePick(item); }}
|
|
119
|
+
onMouseEnter={() => setActiveIdx(idx)}
|
|
120
|
+
className={`flex w-full items-start justify-between gap-3 border-b px-3 py-2 text-left last:border-b-0 ${
|
|
121
|
+
idx === activeIdx ? 'bg-accent' : 'hover:bg-accent/50'
|
|
122
|
+
}`}
|
|
123
|
+
>
|
|
124
|
+
<div className="min-w-0">
|
|
125
|
+
<div className="truncate text-sm font-medium">{item.name}</div>
|
|
126
|
+
{item.description && (
|
|
127
|
+
<div className="text-muted-foreground truncate text-xs">{item.description}</div>
|
|
128
|
+
)}
|
|
129
|
+
<div className="text-muted-foreground mt-0.5 text-[10px] uppercase">
|
|
130
|
+
{item.categoryName} · {item.kind}{item.sku ? ` · ${item.sku}` : ''}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
<div className="text-right whitespace-nowrap text-sm">
|
|
134
|
+
<div className="font-medium">{formatCurrency(item.unitPrice)}</div>
|
|
135
|
+
<div className={`text-[10px] ${item.marginPct < 25 ? 'text-destructive' : 'text-muted-foreground'}`}>
|
|
136
|
+
{item.marginPct}% margin
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</button>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { searchPriceBookItems } from './data';
|
|
4
|
+
import type { PriceBookItem } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Server-callable search wrapper used by client components (autocomplete
|
|
8
|
+
* pickers in the job + estimate line-item rows). The Drizzle installer
|
|
9
|
+
* overwrites this with a version that hits the real DB; the default here
|
|
10
|
+
* just searches sample data.
|
|
11
|
+
*/
|
|
12
|
+
export async function searchPriceBookItemsAction(query: string): Promise<PriceBookItem[]> {
|
|
13
|
+
return searchPriceBookItems(query);
|
|
14
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default data source — returns sample price book data. Stack-specific
|
|
3
|
+
* installers OVERWRITE this with real DB queries.
|
|
4
|
+
*/
|
|
5
|
+
import { sampleCategories, sampleItems } from './sample-data';
|
|
6
|
+
import type { PriceBookCategoryWithItems, PriceBookItem } from './types';
|
|
7
|
+
|
|
8
|
+
export async function getPriceBook(): Promise<PriceBookCategoryWithItems[]> {
|
|
9
|
+
return sampleCategories.map((c) => ({
|
|
10
|
+
...c,
|
|
11
|
+
items: sampleItems.filter((i) => i.categoryId === c.id),
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function getPriceBookItems(): Promise<PriceBookItem[]> {
|
|
16
|
+
return sampleItems;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function searchPriceBookItems(query: string): Promise<PriceBookItem[]> {
|
|
20
|
+
const q = query.trim().toLowerCase();
|
|
21
|
+
if (!q) return [];
|
|
22
|
+
return sampleItems.filter(
|
|
23
|
+
(i) =>
|
|
24
|
+
i.name.toLowerCase().includes(q) ||
|
|
25
|
+
i.description?.toLowerCase().includes(q) ||
|
|
26
|
+
i.sku?.toLowerCase().includes(q),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { PriceBookCategory, PriceBookItem } from './types';
|
|
2
|
+
|
|
3
|
+
export const sampleCategories: PriceBookCategory[] = [
|
|
4
|
+
{ id: 'cat_service', name: 'Service & Maintenance', sortOrder: 0 },
|
|
5
|
+
{ id: 'cat_install', name: 'Installation', sortOrder: 1 },
|
|
6
|
+
{ id: 'cat_repair', name: 'Repair', sortOrder: 2 },
|
|
7
|
+
{ id: 'cat_parts', name: 'Parts & Materials', sortOrder: 3 },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
function withMargin(item: Omit<PriceBookItem, 'marginPct'>): PriceBookItem {
|
|
11
|
+
return {
|
|
12
|
+
...item,
|
|
13
|
+
marginPct: item.unitPrice > 0
|
|
14
|
+
? Math.round(((item.unitPrice - item.unitCost) / item.unitPrice) * 100)
|
|
15
|
+
: 0,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const sampleItems: PriceBookItem[] = [
|
|
20
|
+
withMargin({
|
|
21
|
+
id: 'pbi_001', categoryId: 'cat_service', categoryName: 'Service & Maintenance',
|
|
22
|
+
kind: 'service', name: 'HVAC tune-up (single unit)',
|
|
23
|
+
description: 'Inspection, filter change, coil clean, refrigerant check',
|
|
24
|
+
defaultQty: 1, unitCost: 4500, unitPrice: 14500, taxable: true, durationMinutes: 60,
|
|
25
|
+
}),
|
|
26
|
+
withMargin({
|
|
27
|
+
id: 'pbi_002', categoryId: 'cat_service', categoryName: 'Service & Maintenance',
|
|
28
|
+
kind: 'service', name: 'Diagnostic fee',
|
|
29
|
+
defaultQty: 1, unitCost: 0, unitPrice: 8900, taxable: true, durationMinutes: 30,
|
|
30
|
+
}),
|
|
31
|
+
withMargin({
|
|
32
|
+
id: 'pbi_003', categoryId: 'cat_install', categoryName: 'Installation',
|
|
33
|
+
kind: 'service', name: 'AC condenser install (3 ton)',
|
|
34
|
+
description: 'Labor only; pad + lineset extra',
|
|
35
|
+
defaultQty: 1, unitCost: 95000, unitPrice: 245000, taxable: true, durationMinutes: 480,
|
|
36
|
+
}),
|
|
37
|
+
withMargin({
|
|
38
|
+
id: 'pbi_004', categoryId: 'cat_install', categoryName: 'Installation',
|
|
39
|
+
kind: 'service', name: 'Tankless water heater install',
|
|
40
|
+
defaultQty: 1, unitCost: 91000, unitPrice: 142000, taxable: true, durationMinutes: 360,
|
|
41
|
+
}),
|
|
42
|
+
withMargin({
|
|
43
|
+
id: 'pbi_005', categoryId: 'cat_repair', categoryName: 'Repair',
|
|
44
|
+
kind: 'service', name: 'Capacitor replacement',
|
|
45
|
+
defaultQty: 1, unitCost: 4200, unitPrice: 18500, taxable: true, durationMinutes: 45,
|
|
46
|
+
}),
|
|
47
|
+
withMargin({
|
|
48
|
+
id: 'pbi_006', categoryId: 'cat_parts', categoryName: 'Parts & Materials',
|
|
49
|
+
kind: 'material', name: 'Refrigerant R-410A (1 lb)',
|
|
50
|
+
sku: 'R410A-1LB', defaultQty: 1, unitCost: 4500, unitPrice: 10750, taxable: true,
|
|
51
|
+
}),
|
|
52
|
+
withMargin({
|
|
53
|
+
id: 'pbi_007', categoryId: 'cat_parts', categoryName: 'Parts & Materials',
|
|
54
|
+
kind: 'material', name: 'Standard air filter (16x25x1)',
|
|
55
|
+
sku: 'AF-16251', defaultQty: 1, unitCost: 800, unitPrice: 2500, taxable: true,
|
|
56
|
+
}),
|
|
57
|
+
withMargin({
|
|
58
|
+
id: 'pbi_008', categoryId: 'cat_service', categoryName: 'Service & Maintenance',
|
|
59
|
+
kind: 'labor', name: 'Standard hourly labor',
|
|
60
|
+
defaultQty: 1, unitCost: 4500, unitPrice: 12500, taxable: false, durationMinutes: 60,
|
|
61
|
+
}),
|
|
62
|
+
];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const PRICE_BOOK_KINDS = ['service', 'material', 'labor'] as const;
|
|
2
|
+
export type PriceBookKind = (typeof PRICE_BOOK_KINDS)[number];
|
|
3
|
+
|
|
4
|
+
export const PRICE_BOOK_KIND_LABEL: Record<PriceBookKind, string> = {
|
|
5
|
+
service: 'Service',
|
|
6
|
+
material: 'Material',
|
|
7
|
+
labor: 'Labor',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export interface PriceBookCategory {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
sortOrder: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PriceBookItem {
|
|
17
|
+
id: string;
|
|
18
|
+
categoryId: string;
|
|
19
|
+
categoryName: string;
|
|
20
|
+
kind: PriceBookKind;
|
|
21
|
+
name: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
sku?: string;
|
|
24
|
+
defaultQty: number;
|
|
25
|
+
unitCost: number; // cents
|
|
26
|
+
unitPrice: number; // cents
|
|
27
|
+
taxable: boolean;
|
|
28
|
+
durationMinutes?: number;
|
|
29
|
+
/** Computed: (unitPrice - unitCost) / unitPrice * 100, 0 if price is 0 */
|
|
30
|
+
marginPct: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PriceBookCategoryWithItems extends PriceBookCategory {
|
|
34
|
+
items: PriceBookItem[];
|
|
35
|
+
}
|