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,20 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import './globals.css';
|
|
3
|
+
import { Providers } from '@/components/providers';
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: 'CRM',
|
|
7
|
+
description: 'A home-services CRM built with create-crm-starter',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default function RootLayout({
|
|
11
|
+
children,
|
|
12
|
+
}: Readonly<{ children: React.ReactNode }>) {
|
|
13
|
+
return (
|
|
14
|
+
<html lang="en">
|
|
15
|
+
<body className="bg-background text-foreground min-h-screen antialiased">
|
|
16
|
+
<Providers>{children}</Providers>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ClerkProvider, useAuth } from '@clerk/nextjs';
|
|
4
|
+
import { ConvexProviderWithClerk } from 'convex/react-clerk';
|
|
5
|
+
import { ConvexReactClient } from 'convex/react';
|
|
6
|
+
|
|
7
|
+
// Use a placeholder URL when env is unset so client construction doesn't throw
|
|
8
|
+
// during a build before the user has run `npx convex dev`.
|
|
9
|
+
const convexUrl =
|
|
10
|
+
process.env.NEXT_PUBLIC_CONVEX_URL && process.env.NEXT_PUBLIC_CONVEX_URL.startsWith('http')
|
|
11
|
+
? process.env.NEXT_PUBLIC_CONVEX_URL
|
|
12
|
+
: 'https://placeholder.convex.cloud';
|
|
13
|
+
|
|
14
|
+
const convex = new ConvexReactClient(convexUrl);
|
|
15
|
+
|
|
16
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
17
|
+
return (
|
|
18
|
+
<ClerkProvider
|
|
19
|
+
publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}
|
|
20
|
+
signInUrl={process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL ?? '/sign-in'}
|
|
21
|
+
signUpUrl={process.env.NEXT_PUBLIC_CLERK_SIGN_UP_URL ?? '/sign-up'}
|
|
22
|
+
>
|
|
23
|
+
<ConvexProviderWithClerk client={convex} useAuth={useAuth}>
|
|
24
|
+
{children}
|
|
25
|
+
</ConvexProviderWithClerk>
|
|
26
|
+
</ClerkProvider>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
services:
|
|
2
|
+
postgres:
|
|
3
|
+
image: postgres:16-alpine
|
|
4
|
+
container_name: crm-postgres
|
|
5
|
+
restart: unless-stopped
|
|
6
|
+
environment:
|
|
7
|
+
POSTGRES_USER: postgres
|
|
8
|
+
POSTGRES_PASSWORD: postgres
|
|
9
|
+
POSTGRES_DB: crm
|
|
10
|
+
ports:
|
|
11
|
+
- '5432:5432'
|
|
12
|
+
volumes:
|
|
13
|
+
- crm-pgdata:/var/lib/postgresql/data
|
|
14
|
+
healthcheck:
|
|
15
|
+
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
|
16
|
+
interval: 5s
|
|
17
|
+
timeout: 5s
|
|
18
|
+
retries: 5
|
|
19
|
+
|
|
20
|
+
volumes:
|
|
21
|
+
crm-pgdata:
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineConfig } from 'drizzle-kit';
|
|
2
|
+
import { config } from 'dotenv';
|
|
3
|
+
|
|
4
|
+
// drizzle-kit is a standalone CLI — it does NOT auto-load Next's env files.
|
|
5
|
+
// Load .env.local first (takes precedence) then .env, so `pnpm db:push` /
|
|
6
|
+
// `db:generate` / `db:studio` find DATABASE_URL from the same .env.local
|
|
7
|
+
// the Next app uses.
|
|
8
|
+
config({ path: '.env.local' });
|
|
9
|
+
config({ path: '.env' });
|
|
10
|
+
|
|
11
|
+
export default defineConfig({
|
|
12
|
+
schema: './src/db/schema/index.ts',
|
|
13
|
+
out: './drizzle',
|
|
14
|
+
dialect: 'postgresql',
|
|
15
|
+
dbCredentials: {
|
|
16
|
+
url: process.env.DATABASE_URL!,
|
|
17
|
+
},
|
|
18
|
+
verbose: true,
|
|
19
|
+
// `strict: true` makes `drizzle-kit push` ask for approval before every
|
|
20
|
+
// change — which hangs in CI / non-interactive shells. Left off so a fresh
|
|
21
|
+
// `pnpm db:push` applies cleanly first-try. drizzle-kit still warns before
|
|
22
|
+
// genuinely destructive ops interactively.
|
|
23
|
+
strict: false,
|
|
24
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import './globals.css';
|
|
3
|
+
import { Providers } from '@/components/providers';
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: 'CRM',
|
|
7
|
+
description: 'A home-services CRM built with create-crm-starter',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default function RootLayout({
|
|
11
|
+
children,
|
|
12
|
+
}: Readonly<{ children: React.ReactNode }>) {
|
|
13
|
+
return (
|
|
14
|
+
<html lang="en">
|
|
15
|
+
<body className="bg-background text-foreground min-h-screen antialiased">
|
|
16
|
+
<Providers>{children}</Providers>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
5
|
+
|
|
6
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
7
|
+
const [client] = useState(
|
|
8
|
+
() =>
|
|
9
|
+
new QueryClient({
|
|
10
|
+
defaultOptions: {
|
|
11
|
+
queries: { staleTime: 30_000, refetchOnWindowFocus: false },
|
|
12
|
+
},
|
|
13
|
+
}),
|
|
14
|
+
);
|
|
15
|
+
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
2
|
+
import postgres from 'postgres';
|
|
3
|
+
import * as schema from './schema';
|
|
4
|
+
|
|
5
|
+
const connectionString = process.env.DATABASE_URL ?? 'postgres://postgres:postgres@localhost:5432/crm';
|
|
6
|
+
|
|
7
|
+
// Reuse the postgres client across hot reloads in dev.
|
|
8
|
+
const globalForDb = globalThis as unknown as { __pg?: ReturnType<typeof postgres> };
|
|
9
|
+
|
|
10
|
+
const client = globalForDb.__pg ?? postgres(connectionString, { prepare: false, max: 10 });
|
|
11
|
+
if (process.env.NODE_ENV !== 'production') globalForDb.__pg = client;
|
|
12
|
+
|
|
13
|
+
export const db = drizzle(client, { schema });
|
|
14
|
+
export type DB = typeof db;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { pgTable, text, timestamp, boolean } from 'drizzle-orm/pg-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tables required by Better-Auth. Names and shapes match Better-Auth's
|
|
5
|
+
* drizzle adapter expectations. Do not rename without updating the adapter
|
|
6
|
+
* config in `src/lib/auth.ts`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const user = pgTable('user', {
|
|
10
|
+
id: text('id').primaryKey(),
|
|
11
|
+
name: text('name').notNull(),
|
|
12
|
+
email: text('email').notNull().unique(),
|
|
13
|
+
emailVerified: boolean('emailVerified').notNull().default(false),
|
|
14
|
+
image: text('image'),
|
|
15
|
+
// App-specific: role for RBAC.
|
|
16
|
+
role: text('role').notNull().default('csr'),
|
|
17
|
+
createdAt: timestamp('createdAt').notNull().defaultNow(),
|
|
18
|
+
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const session = pgTable('session', {
|
|
22
|
+
id: text('id').primaryKey(),
|
|
23
|
+
userId: text('userId')
|
|
24
|
+
.notNull()
|
|
25
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
26
|
+
token: text('token').notNull().unique(),
|
|
27
|
+
expiresAt: timestamp('expiresAt').notNull(),
|
|
28
|
+
ipAddress: text('ipAddress'),
|
|
29
|
+
userAgent: text('userAgent'),
|
|
30
|
+
createdAt: timestamp('createdAt').notNull().defaultNow(),
|
|
31
|
+
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const account = pgTable('account', {
|
|
35
|
+
id: text('id').primaryKey(),
|
|
36
|
+
userId: text('userId')
|
|
37
|
+
.notNull()
|
|
38
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
39
|
+
accountId: text('accountId').notNull(),
|
|
40
|
+
providerId: text('providerId').notNull(),
|
|
41
|
+
accessToken: text('accessToken'),
|
|
42
|
+
refreshToken: text('refreshToken'),
|
|
43
|
+
idToken: text('idToken'),
|
|
44
|
+
accessTokenExpiresAt: timestamp('accessTokenExpiresAt'),
|
|
45
|
+
refreshTokenExpiresAt: timestamp('refreshTokenExpiresAt'),
|
|
46
|
+
scope: text('scope'),
|
|
47
|
+
password: text('password'),
|
|
48
|
+
createdAt: timestamp('createdAt').notNull().defaultNow(),
|
|
49
|
+
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const verification = pgTable('verification', {
|
|
53
|
+
id: text('id').primaryKey(),
|
|
54
|
+
identifier: text('identifier').notNull(),
|
|
55
|
+
value: text('value').notNull(),
|
|
56
|
+
expiresAt: timestamp('expiresAt').notNull(),
|
|
57
|
+
createdAt: timestamp('createdAt').notNull().defaultNow(),
|
|
58
|
+
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export type User = typeof user.$inferSelect;
|
|
62
|
+
export type Session = typeof session.$inferSelect;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
2
|
+
import { Badge } from '@/components/ui/badge';
|
|
3
|
+
|
|
4
|
+
const QUEUE = [
|
|
5
|
+
{ id: 'e1', customer: 'Jamie Rodriguez', issue: 'Burst pipe — water everywhere', minutesAgo: 4, status: 'unassigned' },
|
|
6
|
+
{ id: 'e2', customer: 'Acme Property Mgmt', issue: 'Sewer backup at 120 Main', minutesAgo: 12, status: 'dispatched' },
|
|
7
|
+
{ id: 'e3', customer: 'Mason Hardware Co.', issue: 'No hot water — restaurant', minutesAgo: 28, status: 'en_route' },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
|
11
|
+
unassigned: 'destructive',
|
|
12
|
+
dispatched: 'secondary',
|
|
13
|
+
en_route: 'default',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default function EmergencyPage() {
|
|
17
|
+
return (
|
|
18
|
+
<div className="space-y-6">
|
|
19
|
+
<div>
|
|
20
|
+
<h1 className="text-3xl font-bold tracking-tight">Emergency queue</h1>
|
|
21
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
22
|
+
After-hours and emergency calls. Surcharges apply per your business rules.
|
|
23
|
+
</p>
|
|
24
|
+
</div>
|
|
25
|
+
<div className="grid grid-cols-1 gap-3">
|
|
26
|
+
{QUEUE.map((e) => (
|
|
27
|
+
<Card key={e.id} className={e.status === 'unassigned' ? 'border-destructive' : ''}>
|
|
28
|
+
<CardHeader className="flex flex-row items-start justify-between space-y-0">
|
|
29
|
+
<div>
|
|
30
|
+
<CardTitle className="text-base">{e.customer}</CardTitle>
|
|
31
|
+
<p className="text-muted-foreground mt-1 text-sm">{e.issue}</p>
|
|
32
|
+
</div>
|
|
33
|
+
<div className="flex flex-col items-end gap-1">
|
|
34
|
+
<Badge variant={VARIANT[e.status]}>{e.status.replace('_', ' ')}</Badge>
|
|
35
|
+
<span className="text-muted-foreground text-xs">{e.minutesAgo}m ago</span>
|
|
36
|
+
</div>
|
|
37
|
+
</CardHeader>
|
|
38
|
+
</Card>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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 EQUIPMENT = [
|
|
6
|
+
{ id: 'eq_001', customer: 'Acme Property Mgmt', address: '120 Main St', kind: 'AC Condenser', model: 'Carrier 24ABC6', serial: 'A12345', installed: '2022-06-15', warrantyEnd: '2032-06-15' },
|
|
7
|
+
{ id: 'eq_002', customer: 'Acme Property Mgmt', address: '845 Oak Ave', kind: 'Furnace', model: 'Trane S9V2', serial: 'B22221', installed: '2021-11-08', warrantyEnd: '2031-11-08' },
|
|
8
|
+
{ id: 'eq_003', customer: 'Jamie Rodriguez', address: '32 Elm Ct', kind: 'Heat Pump', model: 'Lennox XP25', serial: 'C99012', installed: '2024-03-22', warrantyEnd: '2034-03-22' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export default function EquipmentPage() {
|
|
12
|
+
return (
|
|
13
|
+
<div className="space-y-6">
|
|
14
|
+
<div>
|
|
15
|
+
<h1 className="text-3xl font-bold tracking-tight">Equipment</h1>
|
|
16
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
17
|
+
HVAC units tracked per service address. Scan a serial barcode in the field app to log new equipment.
|
|
18
|
+
</p>
|
|
19
|
+
</div>
|
|
20
|
+
<Card>
|
|
21
|
+
<CardHeader><CardTitle>All equipment</CardTitle></CardHeader>
|
|
22
|
+
<CardContent className="p-0">
|
|
23
|
+
<table className="w-full text-sm">
|
|
24
|
+
<thead className="bg-muted/50 text-muted-foreground text-xs uppercase">
|
|
25
|
+
<tr>
|
|
26
|
+
<th className="px-4 py-3 text-left font-medium">Customer / Address</th>
|
|
27
|
+
<th className="px-4 py-3 text-left font-medium">Type</th>
|
|
28
|
+
<th className="px-4 py-3 text-left font-medium">Model</th>
|
|
29
|
+
<th className="px-4 py-3 text-left font-medium">Serial</th>
|
|
30
|
+
<th className="px-4 py-3 text-left font-medium">Installed</th>
|
|
31
|
+
<th className="px-4 py-3 text-left font-medium">Warranty</th>
|
|
32
|
+
</tr>
|
|
33
|
+
</thead>
|
|
34
|
+
<tbody className="divide-border divide-y">
|
|
35
|
+
{EQUIPMENT.map((e) => {
|
|
36
|
+
const expired = new Date(e.warrantyEnd) < new Date();
|
|
37
|
+
return (
|
|
38
|
+
<tr key={e.id}>
|
|
39
|
+
<td className="px-4 py-3">
|
|
40
|
+
<div className="font-medium">{e.customer}</div>
|
|
41
|
+
<div className="text-muted-foreground text-xs">{e.address}</div>
|
|
42
|
+
</td>
|
|
43
|
+
<td className="px-4 py-3">{e.kind}</td>
|
|
44
|
+
<td className="text-muted-foreground px-4 py-3">{e.model}</td>
|
|
45
|
+
<td className="text-muted-foreground px-4 py-3 font-mono text-xs">{e.serial}</td>
|
|
46
|
+
<td className="text-muted-foreground px-4 py-3">{formatDate(e.installed)}</td>
|
|
47
|
+
<td className="px-4 py-3">
|
|
48
|
+
<Badge variant={expired ? 'destructive' : 'default'}>
|
|
49
|
+
{expired ? 'Expired' : 'Active'} · {formatDate(e.warrantyEnd)}
|
|
50
|
+
</Badge>
|
|
51
|
+
</td>
|
|
52
|
+
</tr>
|
|
53
|
+
);
|
|
54
|
+
})}
|
|
55
|
+
</tbody>
|
|
56
|
+
</table>
|
|
57
|
+
</CardContent>
|
|
58
|
+
</Card>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/[id]/page.tsx
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
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 { getEstimate } from '@/lib/estimates/data';
|
|
6
|
+
import { ESTIMATE_STATUS_LABEL, ESTIMATE_STATUS_VARIANT } from '@/lib/estimates/types';
|
|
7
|
+
import { ConvertToJobButton } from '@/components/estimates/convert-to-job-button';
|
|
8
|
+
import { formatCurrency, formatDate } from '@/lib/utils';
|
|
9
|
+
|
|
10
|
+
interface PageProps { params: Promise<{ id: string }>; }
|
|
11
|
+
|
|
12
|
+
export default async function EstimateDetailPage({ params }: PageProps) {
|
|
13
|
+
const { id } = await params;
|
|
14
|
+
const estimate = await getEstimate(id);
|
|
15
|
+
if (!estimate) notFound();
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="space-y-6">
|
|
19
|
+
<div>
|
|
20
|
+
<Link href="/estimates" className="text-muted-foreground text-sm hover:underline">← Estimates</Link>
|
|
21
|
+
<div className="mt-1 flex items-center gap-3">
|
|
22
|
+
<h1 className="text-3xl font-bold tracking-tight">Estimate #{estimate.id.slice(-6)}</h1>
|
|
23
|
+
<Badge variant={ESTIMATE_STATUS_VARIANT[estimate.status]}>
|
|
24
|
+
{ESTIMATE_STATUS_LABEL[estimate.status]}
|
|
25
|
+
</Badge>
|
|
26
|
+
{estimate.status === 'approved' && !estimate.convertedJobId && (
|
|
27
|
+
<div className="ml-auto">
|
|
28
|
+
<ConvertToJobButton estimateId={estimate.id} />
|
|
29
|
+
</div>
|
|
30
|
+
)}
|
|
31
|
+
</div>
|
|
32
|
+
<Link href={`/customers/${estimate.customerId}`} className="text-muted-foreground hover:underline">
|
|
33
|
+
{estimate.customerName}
|
|
34
|
+
</Link>
|
|
35
|
+
</div>
|
|
36
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
|
37
|
+
<Card>
|
|
38
|
+
<CardHeader><CardTitle className="text-sm font-medium">Total</CardTitle></CardHeader>
|
|
39
|
+
<CardContent className="text-2xl font-bold">{formatCurrency(estimate.total)}</CardContent>
|
|
40
|
+
</Card>
|
|
41
|
+
<Card>
|
|
42
|
+
<CardHeader><CardTitle className="text-sm font-medium">Total cost</CardTitle></CardHeader>
|
|
43
|
+
<CardContent className="text-2xl font-bold text-muted-foreground">
|
|
44
|
+
{formatCurrency(estimate.totalCost)}
|
|
45
|
+
</CardContent>
|
|
46
|
+
</Card>
|
|
47
|
+
<Card>
|
|
48
|
+
<CardHeader><CardTitle className="text-sm font-medium">Margin</CardTitle></CardHeader>
|
|
49
|
+
<CardContent>
|
|
50
|
+
<div className="text-2xl font-bold">{formatCurrency(estimate.margin)}</div>
|
|
51
|
+
<div className={`text-sm mt-1 ${estimate.marginPct < 25 ? 'text-destructive' : 'text-muted-foreground'}`}>
|
|
52
|
+
{estimate.marginPct}%
|
|
53
|
+
</div>
|
|
54
|
+
</CardContent>
|
|
55
|
+
</Card>
|
|
56
|
+
<Card>
|
|
57
|
+
<CardHeader><CardTitle className="text-sm font-medium">Valid until</CardTitle></CardHeader>
|
|
58
|
+
<CardContent className="text-sm">
|
|
59
|
+
{estimate.validUntil ? (
|
|
60
|
+
<>
|
|
61
|
+
<div className="font-medium">{formatDate(estimate.validUntil)}</div>
|
|
62
|
+
{new Date(estimate.validUntil) < new Date() && (
|
|
63
|
+
<div className="text-destructive mt-1 text-xs">EXPIRED</div>
|
|
64
|
+
)}
|
|
65
|
+
</>
|
|
66
|
+
) : (
|
|
67
|
+
<span className="text-muted-foreground">No expiration</span>
|
|
68
|
+
)}
|
|
69
|
+
</CardContent>
|
|
70
|
+
</Card>
|
|
71
|
+
</div>
|
|
72
|
+
<Card>
|
|
73
|
+
<CardHeader><CardTitle>Line items</CardTitle></CardHeader>
|
|
74
|
+
<CardContent className="p-0">
|
|
75
|
+
<table className="w-full text-sm">
|
|
76
|
+
<thead className="text-muted-foreground bg-muted/50 text-xs uppercase">
|
|
77
|
+
<tr>
|
|
78
|
+
<th className="px-4 py-3 text-left font-medium">Description</th>
|
|
79
|
+
<th className="px-4 py-3 text-right font-medium">Qty</th>
|
|
80
|
+
<th className="px-4 py-3 text-right font-medium">Unit cost</th>
|
|
81
|
+
<th className="px-4 py-3 text-right font-medium">Unit price</th>
|
|
82
|
+
<th className="px-4 py-3 text-right font-medium">Subtotal</th>
|
|
83
|
+
</tr>
|
|
84
|
+
</thead>
|
|
85
|
+
<tbody className="divide-border divide-y">
|
|
86
|
+
{estimate.lineItems.map((li, i) => (
|
|
87
|
+
<tr key={i}>
|
|
88
|
+
<td className="px-4 py-3">
|
|
89
|
+
{li.description}
|
|
90
|
+
{!li.taxable && <span className="text-muted-foreground ml-2 text-xs">(non-taxable)</span>}
|
|
91
|
+
</td>
|
|
92
|
+
<td className="px-4 py-3 text-right">{li.qty}</td>
|
|
93
|
+
<td className="text-muted-foreground px-4 py-3 text-right">
|
|
94
|
+
{li.unitCost !== undefined ? formatCurrency(li.unitCost) : '—'}
|
|
95
|
+
</td>
|
|
96
|
+
<td className="px-4 py-3 text-right">{formatCurrency(li.unitPrice)}</td>
|
|
97
|
+
<td className="px-4 py-3 text-right font-medium">{formatCurrency(li.qty * li.unitPrice)}</td>
|
|
98
|
+
</tr>
|
|
99
|
+
))}
|
|
100
|
+
</tbody>
|
|
101
|
+
<tfoot className="bg-muted/30 text-sm">
|
|
102
|
+
<tr>
|
|
103
|
+
<td colSpan={4} className="px-4 py-3 text-right text-muted-foreground">Total</td>
|
|
104
|
+
<td className="px-4 py-3 text-right font-bold">{formatCurrency(estimate.total)}</td>
|
|
105
|
+
</tr>
|
|
106
|
+
</tfoot>
|
|
107
|
+
</table>
|
|
108
|
+
</CardContent>
|
|
109
|
+
</Card>
|
|
110
|
+
{estimate.notes && (
|
|
111
|
+
<Card>
|
|
112
|
+
<CardHeader><CardTitle>Notes</CardTitle></CardHeader>
|
|
113
|
+
<CardContent className="text-sm whitespace-pre-wrap">{estimate.notes}</CardContent>
|
|
114
|
+
</Card>
|
|
115
|
+
)}
|
|
116
|
+
{estimate.convertedJobId && (
|
|
117
|
+
<p className="text-muted-foreground text-sm">
|
|
118
|
+
Converted to <Link href={`/jobs/${estimate.convertedJobId}`} className="underline">job #{estimate.convertedJobId.slice(-6)}</Link>.
|
|
119
|
+
</p>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default stub. When you scaffolded with the Drizzle stack, this file is
|
|
5
|
+
* overwritten by a working server-action-backed form (in src/components/
|
|
6
|
+
* estimates/new-estimate-form.tsx).
|
|
7
|
+
*/
|
|
8
|
+
export default function NewEstimatePage() {
|
|
9
|
+
return (
|
|
10
|
+
<div className="mx-auto max-w-2xl space-y-6">
|
|
11
|
+
<h1 className="text-3xl font-bold tracking-tight">New estimate</h1>
|
|
12
|
+
<Card>
|
|
13
|
+
<CardHeader><CardTitle>Form goes here</CardTitle></CardHeader>
|
|
14
|
+
<CardContent className="text-muted-foreground text-sm">
|
|
15
|
+
Wire this to your stack to enable. See the Drizzle path in
|
|
16
|
+
<code className="bg-muted ml-1 rounded px-1 py-0.5">cli/template/extras/estimates-invoices/drizzle/</code>
|
|
17
|
+
for the reference implementation.
|
|
18
|
+
</CardContent>
|
|
19
|
+
</Card>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { Badge } from '@/components/ui/badge';
|
|
5
|
+
import { getEstimates } from '@/lib/estimates/data';
|
|
6
|
+
import { ESTIMATE_STATUS_LABEL, ESTIMATE_STATUS_VARIANT } from '@/lib/estimates/types';
|
|
7
|
+
import { formatCurrency, formatDate } from '@/lib/utils';
|
|
8
|
+
|
|
9
|
+
export default async function EstimatesPage() {
|
|
10
|
+
const estimates = await getEstimates();
|
|
11
|
+
const draftCount = estimates.filter((e) => e.status === 'draft').length;
|
|
12
|
+
const openValue = estimates
|
|
13
|
+
.filter((e) => ['sent', 'viewed'].includes(e.status))
|
|
14
|
+
.reduce((acc, e) => acc + e.total, 0);
|
|
15
|
+
const approvedValue = estimates
|
|
16
|
+
.filter((e) => e.status === 'approved')
|
|
17
|
+
.reduce((acc, e) => acc + e.total, 0);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className="space-y-6">
|
|
21
|
+
<div className="flex items-center justify-between">
|
|
22
|
+
<div>
|
|
23
|
+
<h1 className="text-3xl font-bold tracking-tight">Estimates</h1>
|
|
24
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
25
|
+
{estimates.length} total · {draftCount} draft
|
|
26
|
+
</p>
|
|
27
|
+
</div>
|
|
28
|
+
<Button asChild>
|
|
29
|
+
<Link href="/estimates/new">New estimate</Link>
|
|
30
|
+
</Button>
|
|
31
|
+
</div>
|
|
32
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
33
|
+
<Card>
|
|
34
|
+
<CardHeader><CardTitle className="text-base">Open</CardTitle></CardHeader>
|
|
35
|
+
<CardContent>
|
|
36
|
+
<div className="text-3xl font-bold">{formatCurrency(openValue)}</div>
|
|
37
|
+
<p className="text-muted-foreground mt-1 text-xs">Sent + viewed estimates awaiting decision</p>
|
|
38
|
+
</CardContent>
|
|
39
|
+
</Card>
|
|
40
|
+
<Card>
|
|
41
|
+
<CardHeader><CardTitle className="text-base">Approved (pending convert)</CardTitle></CardHeader>
|
|
42
|
+
<CardContent>
|
|
43
|
+
<div className="text-3xl font-bold">{formatCurrency(approvedValue)}</div>
|
|
44
|
+
<p className="text-muted-foreground mt-1 text-xs">Click an estimate to convert it to a job</p>
|
|
45
|
+
</CardContent>
|
|
46
|
+
</Card>
|
|
47
|
+
<Card>
|
|
48
|
+
<CardHeader><CardTitle className="text-base">Drafts</CardTitle></CardHeader>
|
|
49
|
+
<CardContent>
|
|
50
|
+
<div className="text-3xl font-bold">{draftCount}</div>
|
|
51
|
+
<p className="text-muted-foreground mt-1 text-xs">Not yet sent to customer</p>
|
|
52
|
+
</CardContent>
|
|
53
|
+
</Card>
|
|
54
|
+
</div>
|
|
55
|
+
<Card>
|
|
56
|
+
<CardHeader><CardTitle>All estimates</CardTitle></CardHeader>
|
|
57
|
+
<CardContent className="p-0">
|
|
58
|
+
<table className="w-full text-sm">
|
|
59
|
+
<thead className="bg-muted/50 text-muted-foreground text-xs uppercase">
|
|
60
|
+
<tr>
|
|
61
|
+
<th className="px-4 py-3 text-left font-medium">Customer</th>
|
|
62
|
+
<th className="px-4 py-3 text-left font-medium">Status</th>
|
|
63
|
+
<th className="px-4 py-3 text-left font-medium">Sent</th>
|
|
64
|
+
<th className="px-4 py-3 text-left font-medium">Valid until</th>
|
|
65
|
+
<th className="px-4 py-3 text-right font-medium">Margin %</th>
|
|
66
|
+
<th className="px-4 py-3 text-right font-medium">Total</th>
|
|
67
|
+
</tr>
|
|
68
|
+
</thead>
|
|
69
|
+
<tbody className="divide-border divide-y">
|
|
70
|
+
{estimates.map((e) => (
|
|
71
|
+
<tr key={e.id} className="hover:bg-muted/30">
|
|
72
|
+
<td className="px-4 py-3">
|
|
73
|
+
<Link href={`/estimates/${e.id}`} className="font-medium hover:underline">
|
|
74
|
+
{e.customerName}
|
|
75
|
+
</Link>
|
|
76
|
+
</td>
|
|
77
|
+
<td className="px-4 py-3">
|
|
78
|
+
<Badge variant={ESTIMATE_STATUS_VARIANT[e.status]}>
|
|
79
|
+
{ESTIMATE_STATUS_LABEL[e.status]}
|
|
80
|
+
</Badge>
|
|
81
|
+
</td>
|
|
82
|
+
<td className="text-muted-foreground px-4 py-3">
|
|
83
|
+
{e.sentAt ? formatDate(e.sentAt) : <span className="text-muted-foreground/50">—</span>}
|
|
84
|
+
</td>
|
|
85
|
+
<td className="text-muted-foreground px-4 py-3">
|
|
86
|
+
{e.validUntil ? formatDate(e.validUntil) : <span className="text-muted-foreground/50">—</span>}
|
|
87
|
+
</td>
|
|
88
|
+
<td className="px-4 py-3 text-right">
|
|
89
|
+
<span className={e.marginPct < 25 ? 'text-destructive font-medium' : ''}>
|
|
90
|
+
{e.marginPct}%
|
|
91
|
+
</span>
|
|
92
|
+
</td>
|
|
93
|
+
<td className="px-4 py-3 text-right font-medium">{formatCurrency(e.total)}</td>
|
|
94
|
+
</tr>
|
|
95
|
+
))}
|
|
96
|
+
</tbody>
|
|
97
|
+
</table>
|
|
98
|
+
</CardContent>
|
|
99
|
+
</Card>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|