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,18 @@
|
|
|
1
|
+
import { asc } from 'drizzle-orm';
|
|
2
|
+
import { db } from '@/db/client';
|
|
3
|
+
import { priceBookCategories } from '@/db/schema';
|
|
4
|
+
import { NewPriceBookItemForm } from '@/components/price-book/new-item-form';
|
|
5
|
+
|
|
6
|
+
export default async function NewPriceBookItemPage() {
|
|
7
|
+
const cats = await db
|
|
8
|
+
.select({ id: priceBookCategories.id, name: priceBookCategories.name })
|
|
9
|
+
.from(priceBookCategories)
|
|
10
|
+
.orderBy(asc(priceBookCategories.sortOrder), asc(priceBookCategories.name));
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className="mx-auto max-w-2xl space-y-6">
|
|
14
|
+
<h1 className="text-3xl font-bold tracking-tight">New price book item</h1>
|
|
15
|
+
<NewPriceBookItemForm categories={cats} />
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState, useTransition } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
7
|
+
import { Input } from '@/components/ui/input';
|
|
8
|
+
import { Label } from '@/components/ui/label';
|
|
9
|
+
import { createPriceBookCategory, createPriceBookItem } from '@/lib/price-book/actions';
|
|
10
|
+
import { PRICE_BOOK_KINDS, PRICE_BOOK_KIND_LABEL, type PriceBookKind } from '@/lib/price-book/types';
|
|
11
|
+
import { formatCurrency } from '@/lib/utils';
|
|
12
|
+
|
|
13
|
+
interface CategoryOption { id: string; name: string; }
|
|
14
|
+
|
|
15
|
+
export function NewPriceBookItemForm({ categories }: { categories: CategoryOption[] }) {
|
|
16
|
+
const [pending, start] = useTransition();
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
|
|
19
|
+
const [categoryId, setCategoryId] = useState(categories[0]?.id ?? '');
|
|
20
|
+
const [showNewCategory, setShowNewCategory] = useState(categories.length === 0);
|
|
21
|
+
const [newCategoryName, setNewCategoryName] = useState('');
|
|
22
|
+
|
|
23
|
+
const [kind, setKind] = useState<PriceBookKind>('service');
|
|
24
|
+
const [name, setName] = useState('');
|
|
25
|
+
const [description, setDescription] = useState('');
|
|
26
|
+
const [sku, setSku] = useState('');
|
|
27
|
+
const [defaultQty, setDefaultQty] = useState(1);
|
|
28
|
+
const [unitCost, setUnitCost] = useState(0);
|
|
29
|
+
const [unitPrice, setUnitPrice] = useState(0);
|
|
30
|
+
const [taxable, setTaxable] = useState(true);
|
|
31
|
+
const [durationMinutes, setDurationMinutes] = useState<number | ''>('');
|
|
32
|
+
|
|
33
|
+
const marginPct = useMemo(() => {
|
|
34
|
+
if (unitPrice <= 0) return 0;
|
|
35
|
+
return Math.round(((unitPrice - unitCost) / unitPrice) * 100);
|
|
36
|
+
}, [unitCost, unitPrice]);
|
|
37
|
+
const marginColor =
|
|
38
|
+
marginPct < 0 ? 'text-destructive'
|
|
39
|
+
: marginPct < 25 ? 'text-yellow-600'
|
|
40
|
+
: 'text-emerald-600';
|
|
41
|
+
|
|
42
|
+
function handleSubmit(e: React.FormEvent) {
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
setError(null);
|
|
45
|
+
if (!name.trim()) { setError('Name is required.'); return; }
|
|
46
|
+
|
|
47
|
+
start(async () => {
|
|
48
|
+
try {
|
|
49
|
+
let resolvedCategoryId = categoryId;
|
|
50
|
+
if (showNewCategory) {
|
|
51
|
+
if (!newCategoryName.trim()) {
|
|
52
|
+
setError('Enter a category name or pick an existing category.');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
resolvedCategoryId = await createPriceBookCategory({ name: newCategoryName });
|
|
56
|
+
}
|
|
57
|
+
if (!resolvedCategoryId) {
|
|
58
|
+
setError('Pick a category.');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
await createPriceBookItem({
|
|
62
|
+
categoryId: resolvedCategoryId,
|
|
63
|
+
kind,
|
|
64
|
+
name,
|
|
65
|
+
description: description || undefined,
|
|
66
|
+
sku: sku || undefined,
|
|
67
|
+
defaultQty,
|
|
68
|
+
unitCost,
|
|
69
|
+
unitPrice,
|
|
70
|
+
taxable,
|
|
71
|
+
durationMinutes: durationMinutes === '' ? undefined : Number(durationMinutes),
|
|
72
|
+
});
|
|
73
|
+
} catch (err) {
|
|
74
|
+
setError((err as Error).message);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<form onSubmit={handleSubmit} className="space-y-6">
|
|
81
|
+
<Card>
|
|
82
|
+
<CardHeader>
|
|
83
|
+
<CardTitle>Category</CardTitle>
|
|
84
|
+
<CardDescription>Pick an existing category or create a new one.</CardDescription>
|
|
85
|
+
</CardHeader>
|
|
86
|
+
<CardContent className="space-y-3">
|
|
87
|
+
{!showNewCategory && categories.length > 0 && (
|
|
88
|
+
<>
|
|
89
|
+
<select
|
|
90
|
+
value={categoryId}
|
|
91
|
+
onChange={(e) => setCategoryId(e.target.value)}
|
|
92
|
+
className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm"
|
|
93
|
+
>
|
|
94
|
+
{categories.map((c) => (
|
|
95
|
+
<option key={c.id} value={c.id}>{c.name}</option>
|
|
96
|
+
))}
|
|
97
|
+
</select>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={() => setShowNewCategory(true)}
|
|
101
|
+
className="text-brand text-xs underline"
|
|
102
|
+
>
|
|
103
|
+
+ New category
|
|
104
|
+
</button>
|
|
105
|
+
</>
|
|
106
|
+
)}
|
|
107
|
+
{showNewCategory && (
|
|
108
|
+
<div className="space-y-2">
|
|
109
|
+
<Input
|
|
110
|
+
value={newCategoryName}
|
|
111
|
+
onChange={(e) => setNewCategoryName(e.target.value)}
|
|
112
|
+
placeholder="e.g. Plumbing — Drain"
|
|
113
|
+
autoFocus
|
|
114
|
+
/>
|
|
115
|
+
{categories.length > 0 && (
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
onClick={() => setShowNewCategory(false)}
|
|
119
|
+
className="text-muted-foreground text-xs underline"
|
|
120
|
+
>
|
|
121
|
+
Pick an existing category instead
|
|
122
|
+
</button>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
</CardContent>
|
|
127
|
+
</Card>
|
|
128
|
+
|
|
129
|
+
<Card>
|
|
130
|
+
<CardHeader><CardTitle>Item</CardTitle></CardHeader>
|
|
131
|
+
<CardContent className="space-y-4">
|
|
132
|
+
<div className="space-y-2">
|
|
133
|
+
<Label htmlFor="kind">Kind</Label>
|
|
134
|
+
<select
|
|
135
|
+
id="kind"
|
|
136
|
+
value={kind}
|
|
137
|
+
onChange={(e) => setKind(e.target.value as PriceBookKind)}
|
|
138
|
+
className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm"
|
|
139
|
+
>
|
|
140
|
+
{PRICE_BOOK_KINDS.map((k) => (
|
|
141
|
+
<option key={k} value={k}>{PRICE_BOOK_KIND_LABEL[k]}</option>
|
|
142
|
+
))}
|
|
143
|
+
</select>
|
|
144
|
+
</div>
|
|
145
|
+
<div className="space-y-2">
|
|
146
|
+
<Label htmlFor="name">Name *</Label>
|
|
147
|
+
<Input id="name" required value={name} onChange={(e) => setName(e.target.value)} />
|
|
148
|
+
</div>
|
|
149
|
+
<div className="space-y-2">
|
|
150
|
+
<Label htmlFor="description">Description</Label>
|
|
151
|
+
<textarea
|
|
152
|
+
id="description"
|
|
153
|
+
rows={2}
|
|
154
|
+
value={description}
|
|
155
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
156
|
+
className="border-input bg-background focus-visible:ring-ring flex w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2"
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
160
|
+
<div className="space-y-2">
|
|
161
|
+
<Label htmlFor="sku">SKU</Label>
|
|
162
|
+
<Input id="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
|
|
163
|
+
</div>
|
|
164
|
+
<div className="space-y-2">
|
|
165
|
+
<Label htmlFor="defaultQty">Default qty</Label>
|
|
166
|
+
<Input
|
|
167
|
+
id="defaultQty"
|
|
168
|
+
type="number"
|
|
169
|
+
min={1}
|
|
170
|
+
value={defaultQty}
|
|
171
|
+
onChange={(e) => setDefaultQty(Number(e.target.value) || 1)}
|
|
172
|
+
/>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</CardContent>
|
|
176
|
+
</Card>
|
|
177
|
+
|
|
178
|
+
<Card>
|
|
179
|
+
<CardHeader className="flex flex-row items-start justify-between space-y-0">
|
|
180
|
+
<div>
|
|
181
|
+
<CardTitle>Pricing</CardTitle>
|
|
182
|
+
<CardDescription>Values in cents. e.g. 12500 = $125.00</CardDescription>
|
|
183
|
+
</div>
|
|
184
|
+
<div className="text-right">
|
|
185
|
+
<div className="text-muted-foreground text-xs uppercase">Margin</div>
|
|
186
|
+
<div className={`text-2xl font-bold ${marginColor}`}>{marginPct}%</div>
|
|
187
|
+
<div className="text-muted-foreground text-xs">
|
|
188
|
+
{formatCurrency(unitPrice - unitCost)}
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</CardHeader>
|
|
192
|
+
<CardContent className="space-y-4">
|
|
193
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
194
|
+
<div className="space-y-2">
|
|
195
|
+
<Label htmlFor="unitCost">Unit cost (¢)</Label>
|
|
196
|
+
<Input
|
|
197
|
+
id="unitCost"
|
|
198
|
+
type="number"
|
|
199
|
+
min={0}
|
|
200
|
+
value={unitCost}
|
|
201
|
+
onChange={(e) => setUnitCost(Number(e.target.value) || 0)}
|
|
202
|
+
/>
|
|
203
|
+
</div>
|
|
204
|
+
<div className="space-y-2">
|
|
205
|
+
<Label htmlFor="unitPrice">Unit price (¢)</Label>
|
|
206
|
+
<Input
|
|
207
|
+
id="unitPrice"
|
|
208
|
+
type="number"
|
|
209
|
+
min={0}
|
|
210
|
+
value={unitPrice}
|
|
211
|
+
onChange={(e) => setUnitPrice(Number(e.target.value) || 0)}
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
216
|
+
<div className="space-y-2">
|
|
217
|
+
<Label htmlFor="durationMinutes">Duration (minutes) — optional</Label>
|
|
218
|
+
<Input
|
|
219
|
+
id="durationMinutes"
|
|
220
|
+
type="number"
|
|
221
|
+
min={0}
|
|
222
|
+
value={durationMinutes}
|
|
223
|
+
onChange={(e) => setDurationMinutes(e.target.value === '' ? '' : Number(e.target.value))}
|
|
224
|
+
placeholder="e.g. 60"
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
<div className="flex items-end space-y-2">
|
|
228
|
+
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
|
229
|
+
<input
|
|
230
|
+
type="checkbox"
|
|
231
|
+
checked={taxable}
|
|
232
|
+
onChange={(e) => setTaxable(e.target.checked)}
|
|
233
|
+
className="h-4 w-4"
|
|
234
|
+
/>
|
|
235
|
+
Taxable
|
|
236
|
+
</label>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
</CardContent>
|
|
240
|
+
</Card>
|
|
241
|
+
|
|
242
|
+
{error && <p className="text-destructive text-sm">{error}</p>}
|
|
243
|
+
|
|
244
|
+
<div className="flex justify-end gap-3">
|
|
245
|
+
<Button type="button" variant="outline" asChild>
|
|
246
|
+
<Link href="/price-book">Cancel</Link>
|
|
247
|
+
</Button>
|
|
248
|
+
<Button type="submit" disabled={pending}>
|
|
249
|
+
{pending ? 'Creating...' : 'Create item'}
|
|
250
|
+
</Button>
|
|
251
|
+
</div>
|
|
252
|
+
</form>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { boolean, integer, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
|
2
|
+
|
|
3
|
+
export const priceBookKind = pgEnum('price_book_kind', ['service', 'material', 'labor']);
|
|
4
|
+
|
|
5
|
+
export const priceBookCategories = pgTable('price_book_categories', {
|
|
6
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
7
|
+
name: text('name').notNull(),
|
|
8
|
+
sortOrder: integer('sort_order').notNull().default(0),
|
|
9
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
10
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const priceBookItems = pgTable('price_book_items', {
|
|
14
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
15
|
+
categoryId: uuid('category_id').notNull().references(() => priceBookCategories.id, { onDelete: 'restrict' }),
|
|
16
|
+
kind: priceBookKind('kind').notNull().default('service'),
|
|
17
|
+
name: text('name').notNull(),
|
|
18
|
+
description: text('description'),
|
|
19
|
+
sku: text('sku'),
|
|
20
|
+
defaultQty: integer('default_qty').notNull().default(1),
|
|
21
|
+
unitCost: integer('unit_cost').notNull().default(0), // cents
|
|
22
|
+
unitPrice: integer('unit_price').notNull().default(0), // cents
|
|
23
|
+
taxable: boolean('taxable').notNull().default(true),
|
|
24
|
+
durationMinutes: integer('duration_minutes'),
|
|
25
|
+
archivedAt: timestamp('archived_at'),
|
|
26
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
27
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export type PriceBookCategoryRow = typeof priceBookCategories.$inferSelect;
|
|
31
|
+
export type NewPriceBookCategory = typeof priceBookCategories.$inferInsert;
|
|
32
|
+
export type PriceBookItemRow = typeof priceBookItems.$inferSelect;
|
|
33
|
+
export type NewPriceBookItem = typeof priceBookItems.$inferInsert;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { eq } from 'drizzle-orm';
|
|
4
|
+
import { revalidatePath } from 'next/cache';
|
|
5
|
+
import { redirect } from 'next/navigation';
|
|
6
|
+
import { db } from '@/db/client';
|
|
7
|
+
import { priceBookCategories, priceBookItems } from '@/db/schema';
|
|
8
|
+
import { searchPriceBookItems } from './data';
|
|
9
|
+
import type { PriceBookItem, PriceBookKind } from './types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Server-callable search wrapper used by the autocomplete picker on the
|
|
13
|
+
* new-job and new-estimate forms. Imported by client components.
|
|
14
|
+
*/
|
|
15
|
+
export async function searchPriceBookItemsAction(query: string): Promise<PriceBookItem[]> {
|
|
16
|
+
return searchPriceBookItems(query);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CreateCategoryInput {
|
|
20
|
+
name: string;
|
|
21
|
+
sortOrder?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function createPriceBookCategory(input: CreateCategoryInput): Promise<string> {
|
|
25
|
+
if (!input.name.trim()) throw new Error('Category name is required');
|
|
26
|
+
const [row] = await db
|
|
27
|
+
.insert(priceBookCategories)
|
|
28
|
+
.values({ name: input.name.trim(), sortOrder: input.sortOrder ?? 999 })
|
|
29
|
+
.returning({ id: priceBookCategories.id });
|
|
30
|
+
revalidatePath('/price-book');
|
|
31
|
+
return row.id;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CreatePriceBookItemInput {
|
|
35
|
+
categoryId: string;
|
|
36
|
+
kind: PriceBookKind;
|
|
37
|
+
name: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
sku?: string;
|
|
40
|
+
defaultQty: number;
|
|
41
|
+
unitCost: number; // cents
|
|
42
|
+
unitPrice: number; // cents
|
|
43
|
+
taxable: boolean;
|
|
44
|
+
durationMinutes?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function createPriceBookItem(input: CreatePriceBookItemInput): Promise<void> {
|
|
48
|
+
if (!input.categoryId) throw new Error('Category is required');
|
|
49
|
+
if (!input.name.trim()) throw new Error('Item name is required');
|
|
50
|
+
await db.insert(priceBookItems).values({
|
|
51
|
+
categoryId: input.categoryId,
|
|
52
|
+
kind: input.kind,
|
|
53
|
+
name: input.name.trim(),
|
|
54
|
+
description: input.description?.trim() || null,
|
|
55
|
+
sku: input.sku?.trim() || null,
|
|
56
|
+
defaultQty: input.defaultQty,
|
|
57
|
+
unitCost: input.unitCost,
|
|
58
|
+
unitPrice: input.unitPrice,
|
|
59
|
+
taxable: input.taxable,
|
|
60
|
+
durationMinutes: input.durationMinutes ?? null,
|
|
61
|
+
});
|
|
62
|
+
revalidatePath('/price-book');
|
|
63
|
+
redirect('/price-book');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function archivePriceBookItem(id: string): Promise<void> {
|
|
67
|
+
await db
|
|
68
|
+
.update(priceBookItems)
|
|
69
|
+
.set({ archivedAt: new Date(), updatedAt: new Date() })
|
|
70
|
+
.where(eq(priceBookItems.id, id));
|
|
71
|
+
revalidatePath('/price-book');
|
|
72
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { and, asc, eq, ilike, isNull, or } from 'drizzle-orm';
|
|
2
|
+
import { db } from '@/db/client';
|
|
3
|
+
import { priceBookCategories, priceBookItems } from '@/db/schema';
|
|
4
|
+
import type { PriceBookCategoryWithItems, PriceBookItem, PriceBookKind } from './types';
|
|
5
|
+
|
|
6
|
+
function toItem(
|
|
7
|
+
row: typeof priceBookItems.$inferSelect,
|
|
8
|
+
categoryName: string,
|
|
9
|
+
): PriceBookItem {
|
|
10
|
+
const marginPct = row.unitPrice > 0
|
|
11
|
+
? Math.round(((row.unitPrice - row.unitCost) / row.unitPrice) * 100)
|
|
12
|
+
: 0;
|
|
13
|
+
return {
|
|
14
|
+
id: row.id,
|
|
15
|
+
categoryId: row.categoryId,
|
|
16
|
+
categoryName,
|
|
17
|
+
kind: row.kind as PriceBookKind,
|
|
18
|
+
name: row.name,
|
|
19
|
+
description: row.description ?? undefined,
|
|
20
|
+
sku: row.sku ?? undefined,
|
|
21
|
+
defaultQty: row.defaultQty,
|
|
22
|
+
unitCost: row.unitCost,
|
|
23
|
+
unitPrice: row.unitPrice,
|
|
24
|
+
taxable: row.taxable,
|
|
25
|
+
durationMinutes: row.durationMinutes ?? undefined,
|
|
26
|
+
marginPct,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function getPriceBook(): Promise<PriceBookCategoryWithItems[]> {
|
|
31
|
+
const cats = await db
|
|
32
|
+
.select()
|
|
33
|
+
.from(priceBookCategories)
|
|
34
|
+
.orderBy(asc(priceBookCategories.sortOrder), asc(priceBookCategories.name));
|
|
35
|
+
|
|
36
|
+
const items = await db
|
|
37
|
+
.select()
|
|
38
|
+
.from(priceBookItems)
|
|
39
|
+
.where(isNull(priceBookItems.archivedAt))
|
|
40
|
+
.orderBy(asc(priceBookItems.name));
|
|
41
|
+
|
|
42
|
+
return cats.map((c) => ({
|
|
43
|
+
id: c.id,
|
|
44
|
+
name: c.name,
|
|
45
|
+
sortOrder: c.sortOrder,
|
|
46
|
+
items: items.filter((i) => i.categoryId === c.id).map((i) => toItem(i, c.name)),
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function getPriceBookItems(): Promise<PriceBookItem[]> {
|
|
51
|
+
const rows = await db
|
|
52
|
+
.select({ item: priceBookItems, categoryName: priceBookCategories.name })
|
|
53
|
+
.from(priceBookItems)
|
|
54
|
+
.leftJoin(priceBookCategories, eq(priceBookItems.categoryId, priceBookCategories.id))
|
|
55
|
+
.where(isNull(priceBookItems.archivedAt))
|
|
56
|
+
.orderBy(asc(priceBookItems.name));
|
|
57
|
+
return rows.map((r) => toItem(r.item, r.categoryName ?? ''));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function searchPriceBookItems(query: string): Promise<PriceBookItem[]> {
|
|
61
|
+
const q = query.trim();
|
|
62
|
+
if (!q) return [];
|
|
63
|
+
const pattern = `%${q}%`;
|
|
64
|
+
const rows = await db
|
|
65
|
+
.select({ item: priceBookItems, categoryName: priceBookCategories.name })
|
|
66
|
+
.from(priceBookItems)
|
|
67
|
+
.leftJoin(priceBookCategories, eq(priceBookItems.categoryId, priceBookCategories.id))
|
|
68
|
+
.where(
|
|
69
|
+
and(
|
|
70
|
+
isNull(priceBookItems.archivedAt),
|
|
71
|
+
or(
|
|
72
|
+
ilike(priceBookItems.name, pattern),
|
|
73
|
+
ilike(priceBookItems.description, pattern),
|
|
74
|
+
ilike(priceBookItems.sku, pattern),
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
.orderBy(asc(priceBookItems.name))
|
|
79
|
+
.limit(20);
|
|
80
|
+
return rows.map((r) => toItem(r.item, r.categoryName ?? ''));
|
|
81
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
2
|
+
import { formatCurrency } from '@/lib/utils';
|
|
3
|
+
|
|
4
|
+
const KPI = [
|
|
5
|
+
{ label: 'Revenue this month', value: 1287400, trend: '+12%' },
|
|
6
|
+
{ label: 'Open A/R', value: 248500, trend: '-4%' },
|
|
7
|
+
{ label: 'Jobs completed', value: null, raw: 47, trend: '+8%' },
|
|
8
|
+
{ label: 'Avg ticket', value: 27400, trend: '+3%' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const TECH = [
|
|
12
|
+
{ name: 'Carlos M.', jobs: 18, revenue: 412000 },
|
|
13
|
+
{ name: 'Sarah T.', jobs: 15, revenue: 387000 },
|
|
14
|
+
{ name: 'Alex K.', jobs: 14, revenue: 488400 },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export default function ReportsPage() {
|
|
18
|
+
return (
|
|
19
|
+
<div className="space-y-6">
|
|
20
|
+
<div>
|
|
21
|
+
<h1 className="text-3xl font-bold tracking-tight">Reports</h1>
|
|
22
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
23
|
+
KPIs at a glance. Add Recharts visualizations in <code>src/components/charts/</code>.
|
|
24
|
+
</p>
|
|
25
|
+
</div>
|
|
26
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
|
27
|
+
{KPI.map((k) => (
|
|
28
|
+
<Card key={k.label}>
|
|
29
|
+
<CardHeader>
|
|
30
|
+
<CardDescription>{k.label}</CardDescription>
|
|
31
|
+
<CardTitle className="text-3xl">
|
|
32
|
+
{k.value !== null ? formatCurrency(k.value) : k.raw}
|
|
33
|
+
</CardTitle>
|
|
34
|
+
</CardHeader>
|
|
35
|
+
<CardContent className="text-muted-foreground text-xs">{k.trend} vs last month</CardContent>
|
|
36
|
+
</Card>
|
|
37
|
+
))}
|
|
38
|
+
</div>
|
|
39
|
+
<Card>
|
|
40
|
+
<CardHeader><CardTitle>Technician performance</CardTitle></CardHeader>
|
|
41
|
+
<CardContent className="p-0">
|
|
42
|
+
<table className="w-full text-sm">
|
|
43
|
+
<thead className="bg-muted/50 text-muted-foreground text-xs uppercase">
|
|
44
|
+
<tr>
|
|
45
|
+
<th className="px-4 py-3 text-left font-medium">Technician</th>
|
|
46
|
+
<th className="px-4 py-3 text-right font-medium">Jobs</th>
|
|
47
|
+
<th className="px-4 py-3 text-right font-medium">Revenue</th>
|
|
48
|
+
<th className="px-4 py-3 text-right font-medium">Avg ticket</th>
|
|
49
|
+
</tr>
|
|
50
|
+
</thead>
|
|
51
|
+
<tbody className="divide-border divide-y">
|
|
52
|
+
{TECH.map((t) => (
|
|
53
|
+
<tr key={t.name}>
|
|
54
|
+
<td className="px-4 py-3 font-medium">{t.name}</td>
|
|
55
|
+
<td className="px-4 py-3 text-right">{t.jobs}</td>
|
|
56
|
+
<td className="px-4 py-3 text-right">{formatCurrency(t.revenue)}</td>
|
|
57
|
+
<td className="px-4 py-3 text-right">{formatCurrency(Math.round(t.revenue / t.jobs))}</td>
|
|
58
|
+
</tr>
|
|
59
|
+
))}
|
|
60
|
+
</tbody>
|
|
61
|
+
</table>
|
|
62
|
+
</CardContent>
|
|
63
|
+
</Card>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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 REVIEWS = [
|
|
6
|
+
{ id: 'r1', customer: 'Jamie Rodriguez', stars: 5, text: 'Showed up on time, fixed the AC in 30 minutes.', source: 'Google', at: '2026-05-18' },
|
|
7
|
+
{ id: 'r2', customer: 'Acme Property Mgmt', stars: 5, text: 'Reliable maintenance, great communication.', source: 'Internal', at: '2026-05-10' },
|
|
8
|
+
{ id: 'r3', customer: 'Priya Patel', stars: 4, text: 'Honest estimate, professional.', source: 'Yelp', at: '2026-05-02' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const REQUESTS = [
|
|
12
|
+
{ customer: 'Mason Hardware Co.', status: 'sent', at: '2026-05-22' },
|
|
13
|
+
{ customer: 'Acme Property Mgmt', status: 'opened', at: '2026-05-20' },
|
|
14
|
+
{ customer: 'Jamie Rodriguez', status: 'submitted', at: '2026-05-18' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export default function ReviewsPage() {
|
|
18
|
+
return (
|
|
19
|
+
<div className="space-y-6">
|
|
20
|
+
<div>
|
|
21
|
+
<h1 className="text-3xl font-bold tracking-tight">Reviews</h1>
|
|
22
|
+
<p className="text-muted-foreground mt-1 text-sm">Auto-request after job completion. Aggregate across Google, Yelp, Facebook.</p>
|
|
23
|
+
</div>
|
|
24
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
25
|
+
<Card>
|
|
26
|
+
<CardHeader><CardTitle>Recent reviews</CardTitle></CardHeader>
|
|
27
|
+
<CardContent className="space-y-3">
|
|
28
|
+
{REVIEWS.map((r) => (
|
|
29
|
+
<div key={r.id} className="border-b pb-3 last:border-b-0">
|
|
30
|
+
<div className="flex items-center justify-between">
|
|
31
|
+
<div className="font-medium">{r.customer}</div>
|
|
32
|
+
<Badge variant="outline">{r.source}</Badge>
|
|
33
|
+
</div>
|
|
34
|
+
<div className="text-brand mt-1 text-sm">{'★'.repeat(r.stars)}{'☆'.repeat(5 - r.stars)}</div>
|
|
35
|
+
<p className="mt-1 text-sm">{r.text}</p>
|
|
36
|
+
<p className="text-muted-foreground mt-1 text-xs">{formatDate(r.at)}</p>
|
|
37
|
+
</div>
|
|
38
|
+
))}
|
|
39
|
+
</CardContent>
|
|
40
|
+
</Card>
|
|
41
|
+
<Card>
|
|
42
|
+
<CardHeader><CardTitle>Pending requests</CardTitle></CardHeader>
|
|
43
|
+
<CardContent className="space-y-2 text-sm">
|
|
44
|
+
{REQUESTS.map((r, i) => (
|
|
45
|
+
<div key={i} className="flex items-center justify-between border-b pb-2 last:border-b-0">
|
|
46
|
+
<div>{r.customer}</div>
|
|
47
|
+
<div className="flex items-center gap-2">
|
|
48
|
+
<Badge variant="secondary">{r.status}</Badge>
|
|
49
|
+
<span className="text-muted-foreground text-xs">{formatDate(r.at)}</span>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
))}
|
|
53
|
+
</CardContent>
|
|
54
|
+
</Card>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed script — populates demo data into your DB.
|
|
3
|
+
*
|
|
4
|
+
* For Convex stack: this file is a placeholder. Run `npx convex import` with
|
|
5
|
+
* the JSON fixtures in `convex/_fixtures/`, or write a Convex mutation that
|
|
6
|
+
* imports faker data.
|
|
7
|
+
*
|
|
8
|
+
* For Drizzle stack: imports the db client and inserts directly. Run with
|
|
9
|
+
* pnpm tsx scripts/seed.ts
|
|
10
|
+
*/
|
|
11
|
+
import { faker } from '@faker-js/faker';
|
|
12
|
+
|
|
13
|
+
faker.seed(42); // deterministic so screenshots stay stable
|
|
14
|
+
|
|
15
|
+
async function main() {
|
|
16
|
+
console.log('[seed] Generating demo data with faker.seed(42)...');
|
|
17
|
+
// TODO: wire to your DB client here.
|
|
18
|
+
// Example (Drizzle):
|
|
19
|
+
// import { db } from '../src/db/client';
|
|
20
|
+
// import { customers } from '../src/db/schema';
|
|
21
|
+
// for (let i = 0; i < 20; i++) {
|
|
22
|
+
// await db.insert(customers).values({
|
|
23
|
+
// name: faker.company.name(),
|
|
24
|
+
// phones: [faker.phone.number()],
|
|
25
|
+
// emails: [faker.internet.email()],
|
|
26
|
+
// ...
|
|
27
|
+
// });
|
|
28
|
+
// }
|
|
29
|
+
console.log('[seed] Done. (This is a stub — fill in inserts for your stack.)');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
main().catch((err) => {
|
|
33
|
+
console.error(err);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
});
|