Browse Source

add images and videos to the inspection. add "request qoutation" button and dowload pdf option

Bernt Christian Egeland 7 tháng trước cách đây
mục cha
commit
b9853714fb

+ 0 - 9
prisma/migrations/20260220161344_dvi_inspection_images/migration.sql

@@ -1,9 +0,0 @@
-/*
-  Warnings:
-
-  - You are about to drop the column `imageUrl` on the `inspection_items` table. All the data in the column will be lost.
-
-*/
--- AlterTable
-ALTER TABLE "inspection_items" DROP COLUMN "imageUrl",
-ADD COLUMN     "imageUrls" TEXT[] DEFAULT ARRAY[]::TEXT[];

+ 23 - 1
prisma/migrations/20260220155420_dvi_inspection/migration.sql → prisma/migrations/20260220164126_dvi_inspection/migration.sql

@@ -57,12 +57,25 @@ CREATE TABLE "inspection_items" (
     "sortOrder" INTEGER NOT NULL DEFAULT 0,
     "condition" TEXT NOT NULL DEFAULT 'not_inspected',
     "notes" TEXT,
-    "imageUrl" TEXT,
+    "imageUrls" TEXT[] DEFAULT ARRAY[]::TEXT[],
     "inspectionId" TEXT NOT NULL,
 
     CONSTRAINT "inspection_items_pkey" PRIMARY KEY ("id")
 );
 
+-- CreateTable
+CREATE TABLE "inspection_quote_requests" (
+    "id" TEXT NOT NULL,
+    "status" TEXT NOT NULL DEFAULT 'pending',
+    "message" TEXT,
+    "selectedItemIds" TEXT[],
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "inspectionId" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+
+    CONSTRAINT "inspection_quote_requests_pkey" PRIMARY KEY ("id")
+);
+
 -- CreateIndex
 CREATE INDEX "inspection_templates_organizationId_idx" ON "inspection_templates"("organizationId");
 
@@ -84,6 +97,12 @@ CREATE INDEX "inspections_organizationId_idx" ON "inspections"("organizationId")
 -- CreateIndex
 CREATE INDEX "inspection_items_inspectionId_idx" ON "inspection_items"("inspectionId");
 
+-- CreateIndex
+CREATE INDEX "inspection_quote_requests_organizationId_idx" ON "inspection_quote_requests"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "inspection_quote_requests_inspectionId_idx" ON "inspection_quote_requests"("inspectionId");
+
 -- AddForeignKey
 ALTER TABLE "inspection_templates" ADD CONSTRAINT "inspection_templates_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
 
@@ -101,3 +120,6 @@ ALTER TABLE "inspections" ADD CONSTRAINT "inspections_templateId_fkey" FOREIGN K
 
 -- AddForeignKey
 ALTER TABLE "inspection_items" ADD CONSTRAINT "inspection_items_inspectionId_fkey" FOREIGN KEY ("inspectionId") REFERENCES "inspections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "inspection_quote_requests" ADD CONSTRAINT "inspection_quote_requests_inspectionId_fkey" FOREIGN KEY ("inspectionId") REFERENCES "inspections"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 19 - 1
prisma/schema.prisma

@@ -724,7 +724,8 @@ model Inspection {
 
   organizationId String
 
-  items InspectionItem[]
+  items         InspectionItem[]
+  quoteRequests InspectionQuoteRequest[]
 
   @@index([vehicleId])
   @@index([organizationId])
@@ -746,3 +747,20 @@ model InspectionItem {
   @@index([inspectionId])
   @@map("inspection_items")
 }
+
+model InspectionQuoteRequest {
+  id              String   @id @default(cuid())
+  status          String   @default("pending")
+  message         String?
+  selectedItemIds String[]
+  createdAt       DateTime @default(now())
+
+  inspectionId String
+  inspection   Inspection @relation(fields: [inspectionId], references: [id], onDelete: Cascade)
+
+  organizationId String
+
+  @@index([organizationId])
+  @@index([inspectionId])
+  @@map("inspection_quote_requests")
+}

+ 103 - 0
src/app/(authenticated)/dashboard-client.tsx

@@ -23,6 +23,7 @@ import {
   ClipboardList,
   Clock,
   DollarSign,
+  FileText,
   Gauge,
   Loader2,
   Users,
@@ -30,6 +31,7 @@ import {
 } from "lucide-react";
 import { formatCurrency } from "@/lib/format";
 import { dismissMaintenance } from "@/features/vehicles/Actions/dismissMaintenance";
+import { updateQuoteRequestStatus } from "@/features/inspections/Actions/quoteRequestActions";
 
 interface ServiceItem {
   id: string;
@@ -103,6 +105,26 @@ interface DashboardInspection {
   items: { id: string; condition: string }[];
 }
 
+interface DashboardQuoteRequest {
+  id: string;
+  status: string;
+  message: string | null;
+  selectedItemIds: string[];
+  createdAt: Date;
+  inspection: {
+    id: string;
+    vehicle: {
+      id: string;
+      make: string;
+      model: string;
+      year: number;
+      licensePlate: string | null;
+      customer: { name: string } | null;
+    };
+    items: { id: string; name: string; section: string; condition: string; notes: string | null }[];
+  };
+}
+
 export function DashboardClient({
   stats,
   currencyCode = "USD",
@@ -111,6 +133,7 @@ export function DashboardClient({
   unitSystem = "imperial",
   inProgressInspections = [],
   completedInspections = [],
+  quoteRequests = [],
 }: {
   stats: DashboardStats;
   currencyCode?: string;
@@ -119,6 +142,7 @@ export function DashboardClient({
   unitSystem?: "metric" | "imperial";
   inProgressInspections?: DashboardInspection[];
   completedInspections?: DashboardInspection[];
+  quoteRequests?: DashboardQuoteRequest[];
 }) {
   const distUnit = unitSystem === "metric" ? "km" : "mi";
   const router = useRouter();
@@ -429,6 +453,85 @@ export function DashboardClient({
         </Card>
       )}
 
+      {/* Quote Requests */}
+      {quoteRequests.length > 0 && (
+        <Card className="border-0 shadow-sm">
+          <CardHeader className="pb-3">
+            <CardTitle className="flex items-center gap-2 text-base">
+              <FileText className="h-4 w-4" />
+              Quote Requests
+            </CardTitle>
+            <p className="text-xs text-muted-foreground">
+              Customers requesting quotes from shared inspections
+            </p>
+          </CardHeader>
+          <CardContent className="p-0">
+            <div className="divide-y">
+              {quoteRequests.map((req) => {
+                const selectedItems = req.inspection.items.filter((i) =>
+                  req.selectedItemIds.includes(i.id)
+                );
+                return (
+                  <div
+                    key={req.id}
+                    className="flex items-center justify-between px-5 py-3"
+                  >
+                    <div
+                      className="flex items-center gap-3 min-w-0 cursor-pointer hover:opacity-80"
+                      onClick={() => router.push(`/inspections/${req.inspection.id}`)}
+                    >
+                      <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
+                        <FileText className="h-4 w-4 text-primary" />
+                      </div>
+                      <div className="min-w-0">
+                        <p className="font-medium text-sm truncate">
+                          {req.inspection.vehicle.year} {req.inspection.vehicle.make} {req.inspection.vehicle.model}
+                        </p>
+                        <p className="text-xs text-muted-foreground truncate">
+                          {req.inspection.vehicle.customer?.name && `${req.inspection.vehicle.customer.name} · `}
+                          {selectedItems.length} item{selectedItems.length !== 1 ? "s" : ""} requested
+                          {req.message && ` · "${req.message}"`}
+                        </p>
+                      </div>
+                    </div>
+                    <div className="shrink-0 ml-3 flex items-center gap-1.5">
+                      <Badge className="bg-primary/10 text-primary border-primary/20 text-[10px]">
+                        Pending
+                      </Badge>
+                      <Button
+                        variant="outline"
+                        size="sm"
+                        className="h-7 text-xs"
+                        onClick={() => {
+                          startTransition(async () => {
+                            await updateQuoteRequestStatus(req.id, "quoted");
+                          });
+                          router.push(`/quotes/new?fromInspection=${req.inspection.id}`);
+                        }}
+                      >
+                        Create Quote
+                      </Button>
+                      <Button
+                        variant="ghost"
+                        size="icon"
+                        className="h-6 w-6 text-muted-foreground hover:text-foreground"
+                        onClick={() => {
+                          startTransition(async () => {
+                            await updateQuoteRequestStatus(req.id, "dismissed");
+                          });
+                        }}
+                      >
+                        <X className="h-3.5 w-3.5" />
+                      </Button>
+                    </div>
+                  </div>
+                );
+              })}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
       {/* Recent Completed table */}
       <Card className="border-0 shadow-sm">
         <CardHeader className="pb-3">

+ 4 - 1
src/app/(authenticated)/page.tsx

@@ -3,17 +3,19 @@ import { getSettings } from "@/features/settings/Actions/settingsActions";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import { getVehiclesDueForService } from "@/features/vehicles/Actions/predictedMaintenanceActions";
 import { getInspectionsPaginated } from "@/features/inspections/Actions/inspectionActions";
+import { getQuoteRequests } from "@/features/inspections/Actions/quoteRequestActions";
 import { DashboardClient } from "./dashboard-client";
 import { PageHeader } from "@/components/page-header";
 
 export default async function DashboardPage() {
-  const [result, settingsResult, remindersResult, maintenanceResult, inProgressResult, completedResult] = await Promise.all([
+  const [result, settingsResult, remindersResult, maintenanceResult, inProgressResult, completedResult, quoteRequestsResult] = await Promise.all([
     getDashboardStats(),
     getSettings([SETTING_KEYS.CURRENCY_CODE, SETTING_KEYS.UNIT_SYSTEM]),
     getUpcomingReminders(),
     getVehiclesDueForService(),
     getInspectionsPaginated({ status: "in_progress", pageSize: 5 }),
     getInspectionsPaginated({ status: "completed", pageSize: 5 }),
+    getQuoteRequests(),
   ]);
 
   if (!result.success || !result.data) {
@@ -45,6 +47,7 @@ export default async function DashboardPage() {
           unitSystem={unitSystem}
           inProgressInspections={inProgressResult.success && inProgressResult.data ? inProgressResult.data.records : []}
           completedInspections={completedResult.success && completedResult.data ? completedResult.data.records : []}
+          quoteRequests={quoteRequestsResult.success && quoteRequestsResult.data ? quoteRequestsResult.data : []}
         />
       </div>
     </>

+ 33 - 1
src/app/(authenticated)/quotes/new/page.tsx

@@ -4,8 +4,15 @@ import { PageHeader } from "@/components/page-header";
 import { QuoteForm } from "@/features/quotes/Components/QuoteForm";
 import { getCustomers } from "@/features/customers/Actions/customerActions";
 import { getVehicles } from "@/features/vehicles/Actions/vehicleActions";
+import { getInspection } from "@/features/inspections/Actions/inspectionActions";
+
+export default async function NewQuotePage({
+  searchParams,
+}: {
+  searchParams: Promise<{ fromInspection?: string }>;
+}) {
+  const params = await searchParams;
 
-export default async function NewQuotePage() {
   const [settingsResult, customersResult, vehiclesResult] = await Promise.all([
     getSettings([SETTING_KEYS.CURRENCY_CODE, SETTING_KEYS.DEFAULT_TAX_RATE, SETTING_KEYS.TAX_ENABLED, SETTING_KEYS.DEFAULT_LABOR_RATE, SETTING_KEYS.QUOTE_VALID_DAYS]),
     getCustomers(),
@@ -21,6 +28,30 @@ export default async function NewQuotePage() {
   const customers = customersResult.success && customersResult.data ? customersResult.data : [];
   const vehicles = vehiclesResult.success && vehiclesResult.data ? vehiclesResult.data : [];
 
+  // Build prefill data from inspection if provided
+  let prefill: { title?: string; vehicleId?: string; customerId?: string; laborItems?: { description: string; hours: number; rate: number; total: number }[] } | undefined;
+
+  if (params.fromInspection) {
+    const inspResult = await getInspection(params.fromInspection);
+    if (inspResult.success && inspResult.data) {
+      const insp = inspResult.data;
+      const issueItems = insp.items.filter(
+        (i: { condition: string }) => i.condition === "fail" || i.condition === "attention"
+      );
+      prefill = {
+        title: `Inspection Repairs — ${insp.vehicle.year} ${insp.vehicle.make} ${insp.vehicle.model}`,
+        vehicleId: insp.vehicle.id,
+        customerId: insp.vehicle.customer?.id || undefined,
+        laborItems: issueItems.map((item: { name: string; section: string; notes: string | null }) => ({
+          description: `${item.section}: ${item.name}${item.notes ? ` — ${item.notes}` : ""}`,
+          hours: 0,
+          rate: defaultLaborRate,
+          total: 0,
+        })),
+      };
+    }
+  }
+
   return (
     <>
       <PageHeader />
@@ -46,6 +77,7 @@ export default async function NewQuotePage() {
             customerId: v.customer?.id || null,
             customerName: v.customer?.name || null,
           }))}
+          prefill={prefill}
         />
       </div>
     </>

+ 3 - 1
src/app/(authenticated)/settings/templates/template-settings.tsx

@@ -509,7 +509,9 @@ export function TemplateSettings({
       <div>
         <h2 className="text-lg font-semibold">Templates</h2>
         <p className="text-sm text-muted-foreground">
-          Customize the appearance of your PDF invoices and quotes.
+          {tab === "inspections"
+            ? "Manage inspection checklists and multi-point templates."
+            : "Customize the appearance of your PDF invoices and quotes."}
         </p>
       </div>
 

+ 277 - 46
src/app/(public)/share/inspection/[orgId]/[token]/inspection-view.tsx

@@ -1,7 +1,11 @@
 "use client";
 
+import { useState, useCallback, useEffect, useRef } from "react";
 import { formatDate as fmtDate, DEFAULT_DATE_FORMAT } from "@/lib/format";
-import { Check, X, AlertTriangle, ClipboardCheck } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Check, CheckCircle2, Download, FileText, X, AlertTriangle, ClipboardCheck, ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
+import { QuoteRequestDialog } from "@/features/inspections/Components/QuoteRequestDialog";
+import { toast } from "sonner";
 
 interface InspectionItem {
   id: string;
@@ -68,6 +72,9 @@ export function InspectionView({
   showTorqvoiceBranding,
   dateFormat,
   timezone,
+  publicToken,
+  orgId,
+  hasExistingQuoteRequest,
 }: {
   inspection: InspectionRecord;
   workshop: { name: string; address: string; phone: string; email: string };
@@ -76,18 +83,107 @@ export function InspectionView({
   showTorqvoiceBranding: boolean;
   dateFormat?: string;
   timezone?: string;
+  publicToken: string;
+  orgId: string;
+  hasExistingQuoteRequest: boolean;
 }) {
   const fmt = dateFormat || DEFAULT_DATE_FORMAT;
   const tz = timezone || "America/New_York";
   const formatDate = (d: Date) => fmtDate(new Date(d), fmt, tz);
 
-  // Only show items that have been inspected (not "not_inspected")
-  const inspectedItems = inspection.items.filter((i) => i.condition !== "not_inspected");
+  const [showQuoteDialog, setShowQuoteDialog] = useState(false);
+  const [quoteRequested, setQuoteRequested] = useState(hasExistingQuoteRequest);
+  const [isCancelling, setIsCancelling] = useState(false);
+  const [carouselIndex, setCarouselIndex] = useState<number | null>(null);
 
-  // Group items by section
+  const handleCancelQuoteRequest = async () => {
+    setIsCancelling(true);
+    try {
+      const res = await fetch("/api/public/inspection-quote-request", {
+        method: "DELETE",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ inspectionId: inspection.id, publicToken }),
+      });
+      const data = await res.json();
+      if (data.success) {
+        toast.success("Quote request cancelled");
+        setQuoteRequested(false);
+      } else {
+        toast.error(data.error || "Failed to cancel request");
+      }
+    } catch {
+      toast.error("Failed to cancel request");
+    } finally {
+      setIsCancelling(false);
+    }
+  };
+
+  // Collect all image URLs across all inspected items for the carousel
+  const allImages: { url: string; itemName: string }[] = [];
+  for (const item of inspection.items) {
+    if (item.condition === "not_inspected") continue;
+    for (const url of item.imageUrls) {
+      if (!/\.(mp4|webm|mov)$/i.test(url)) {
+        allImages.push({ url, itemName: item.name });
+      }
+    }
+  }
+
+  const openCarousel = (globalIndex: number) => setCarouselIndex(globalIndex);
+  const closeCarousel = () => setCarouselIndex(null);
+  const prevImage = useCallback(
+    () => setCarouselIndex((i) => (i !== null && i > 0 ? i - 1 : i)),
+    []
+  );
+  const nextImage = useCallback(
+    () => setCarouselIndex((i) => (i !== null && i < allImages.length - 1 ? i + 1 : i)),
+    [allImages.length]
+  );
+
+  // Keyboard navigation for carousel
+  useEffect(() => {
+    if (carouselIndex === null) return;
+    const handler = (e: KeyboardEvent) => {
+      if (e.key === "Escape") closeCarousel();
+      else if (e.key === "ArrowLeft") prevImage();
+      else if (e.key === "ArrowRight") nextImage();
+    };
+    window.addEventListener("keydown", handler);
+    return () => window.removeEventListener("keydown", handler);
+  }, [carouselIndex, prevImage, nextImage]);
+
+  // Touch swipe for carousel
+  const touchStartX = useRef<number | null>(null);
+  const handleTouchStart = (e: React.TouchEvent) => {
+    touchStartX.current = e.touches[0].clientX;
+  };
+  const handleTouchEnd = (e: React.TouchEvent) => {
+    if (touchStartX.current === null) return;
+    const diff = e.changedTouches[0].clientX - touchStartX.current;
+    if (Math.abs(diff) > 50) {
+      if (diff > 0) prevImage();
+      else nextImage();
+    }
+    touchStartX.current = null;
+  };
+
+  const hasIssues = inspection.items.some(
+    (i) => i.condition === "fail" || i.condition === "attention"
+  );
+
+  // Only show items that have been inspected (not "not_inspected"), sorted by sortOrder
+  const inspectedItems = inspection.items
+    .filter((i) => i.condition !== "not_inspected")
+    .sort((a, b) => a.sortOrder - b.sortOrder);
+
+  // Group items by section preserving sort order
+  const sectionOrder: string[] = [];
   const sections: Record<string, InspectionItem[]> = {};
   for (const item of inspectedItems) {
-    if (!sections[item.section]) sections[item.section] = [];
+    if (!sections[item.section]) {
+      sections[item.section] = [];
+      sectionOrder.push(item.section);
+    }
     sections[item.section].push(item);
   }
 
@@ -100,7 +196,7 @@ export function InspectionView({
     <div className="mx-auto max-w-3xl p-4 sm:p-8">
       {/* Header with shop branding */}
       <div className="mb-8 rounded-xl border p-6" style={{ borderTopColor: primaryColor, borderTopWidth: "4px" }}>
-        <div className="flex items-start justify-between gap-4">
+        <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
           <div>
             {logoUrl && (
               <img src={logoUrl} alt={workshop.name} className="mb-3 h-12 object-contain" />
@@ -116,8 +212,8 @@ export function InspectionView({
               <p className="text-sm text-gray-500">{workshop.email}</p>
             )}
           </div>
-          <div className="flex items-center gap-2 text-right">
-            <ClipboardCheck className="h-6 w-6" style={{ color: primaryColor }} />
+          <div className="flex items-center gap-2 sm:text-right">
+            <ClipboardCheck className="hidden h-6 w-6 sm:block" style={{ color: primaryColor }} />
             <div>
               <p className="text-lg font-bold">Vehicle Inspection</p>
               <p className="text-sm text-gray-500">{formatDate(inspection.createdAt)}</p>
@@ -158,24 +254,26 @@ export function InspectionView({
       </div>
 
       {/* Summary bar */}
-      <div className="mb-6 flex items-center gap-4 rounded-lg border p-4">
-        <div className="text-sm text-gray-500">
-          <span className="font-semibold text-gray-900">{totalItems}</span> items inspected
-        </div>
-        <div className="flex items-center gap-1.5">
-          <div className="h-3 w-3 rounded-full bg-emerald-500" />
-          <span className="text-sm font-medium">{passCount} Pass</span>
-        </div>
-        <div className="flex items-center gap-1.5">
-          <div className="h-3 w-3 rounded-full bg-red-500" />
-          <span className="text-sm font-medium">{failCount} Fail</span>
-        </div>
-        <div className="flex items-center gap-1.5">
-          <div className="h-3 w-3 rounded-full bg-amber-500" />
-          <span className="text-sm font-medium">{attentionCount} Attention</span>
+      <div className="mb-6 rounded-lg border p-4">
+        <div className="flex flex-wrap items-center gap-3 sm:gap-4">
+          <div className="text-sm text-gray-500">
+            <span className="font-semibold text-gray-900">{totalItems}</span> inspected
+          </div>
+          <div className="flex items-center gap-1.5">
+            <div className="h-3 w-3 rounded-full bg-emerald-500" />
+            <span className="text-sm font-medium">{passCount} Pass</span>
+          </div>
+          <div className="flex items-center gap-1.5">
+            <div className="h-3 w-3 rounded-full bg-red-500" />
+            <span className="text-sm font-medium">{failCount} Fail</span>
+          </div>
+          <div className="flex items-center gap-1.5">
+            <div className="h-3 w-3 rounded-full bg-amber-500" />
+            <span className="text-sm font-medium">{attentionCount} Attention</span>
+          </div>
         </div>
         {/* Progress bar */}
-        <div className="ml-auto flex h-3 w-32 overflow-hidden rounded-full bg-gray-200">
+        <div className="mt-3 flex h-3 w-full overflow-hidden rounded-full bg-gray-200">
           {totalItems > 0 && (
             <>
               <div className="bg-emerald-500" style={{ width: `${(passCount / totalItems) * 100}%` }} />
@@ -186,15 +284,54 @@ export function InspectionView({
         </div>
       </div>
 
+      {/* Action buttons */}
+      <div className="mb-6 flex flex-wrap gap-3">
+        {hasIssues && (
+          quoteRequested ? (
+            <Button
+              variant="outline"
+              className="gap-2"
+              onClick={handleCancelQuoteRequest}
+              disabled={isCancelling}
+            >
+              {isCancelling ? (
+                <Loader2 className="h-4 w-4 animate-spin" />
+              ) : (
+                <CheckCircle2 className="h-4 w-4 text-emerald-500" />
+              )}
+              Quote Requested
+              <span className="text-muted-foreground">— Cancel</span>
+            </Button>
+          ) : (
+            <Button
+              onClick={() => setShowQuoteDialog(true)}
+              style={{ backgroundColor: primaryColor }}
+              className="gap-2 text-white hover:opacity-90"
+            >
+              <FileText className="h-4 w-4" />
+              Request a Quote
+            </Button>
+          )
+        )}
+        <Button
+          variant="outline"
+          className="gap-2"
+          onClick={() => window.open(`/api/share/inspection/${orgId}/${publicToken}/pdf`, "_blank")}
+        >
+          <Download className="h-4 w-4" />
+          Download PDF
+        </Button>
+      </div>
+
       {/* Inspection sections */}
       <div className="space-y-6">
-        {Object.entries(sections).map(([sectionName, items]) => (
+        {sectionOrder.map((sectionName) => (
           <div key={sectionName} className="rounded-lg border overflow-hidden">
             <div className="border-b px-4 py-3" style={{ backgroundColor: `${primaryColor}10` }}>
               <h3 className="font-semibold">{sectionName}</h3>
             </div>
             <div className="divide-y">
-              {items.map((item) => {
+              {sections[sectionName].map((item) => {
                 const config = conditionConfig[item.condition] || conditionConfig.not_inspected;
                 return (
                   <div key={item.id} className="px-4 py-3">
@@ -212,23 +349,35 @@ export function InspectionView({
                     )}
                     {item.imageUrls && item.imageUrls.length > 0 && (
                       <div className="mt-2 flex flex-wrap gap-2">
-                        {item.imageUrls.map((url, idx) => (
-                          /\.(mp4|webm|mov)$/i.test(url) ? (
-                            <video
-                              key={idx}
-                              src={url}
-                              controls
-                              className="h-48 max-w-sm rounded-lg border"
-                            />
-                          ) : (
-                            <img
+                        {item.imageUrls.map((url, idx) => {
+                          if (/\.(mp4|webm|mov)$/i.test(url)) {
+                            return (
+                              <video
+                                key={idx}
+                                src={url}
+                                controls
+                                className="h-48 max-w-sm rounded-lg border"
+                              />
+                            );
+                          }
+                          const imageIdx = allImages.findIndex(
+                            (img) => img.url === url && img.itemName === item.name
+                          );
+                          return (
+                            <button
                               key={idx}
-                              src={url}
-                              alt={`${item.name} ${idx + 1}`}
-                              className="h-32 rounded-lg object-cover border"
-                            />
-                          )
-                        ))}
+                              type="button"
+                              onClick={() => openCarousel(imageIdx >= 0 ? imageIdx : 0)}
+                              className="group overflow-hidden rounded-lg border"
+                            >
+                              <img
+                                src={url}
+                                alt={`${item.name} ${idx + 1}`}
+                                className="h-32 rounded-lg object-cover transition-transform group-hover:scale-105"
+                              />
+                            </button>
+                          );
+                        })}
                       </div>
                     )}
                   </div>
@@ -248,12 +397,94 @@ export function InspectionView({
       )}
 
       {showTorqvoiceBranding && (
-        <div className="mt-8 text-center">
-          <p className="text-xs text-gray-400">
-            Powered by TorqVoice
-          </p>
+        <div className="mt-8 flex items-center justify-center gap-1.5">
+          <span className="text-xs text-gray-400">Powered by</span>
+          <img src="/torqvoice_app_logo.png" alt="Torqvoice" className="h-4 w-4" />
+          <a
+            href="https://torqvoice.com"
+            target="_blank"
+            rel="noopener noreferrer"
+            className="text-xs font-semibold text-gray-500 hover:text-gray-700"
+          >
+            Torqvoice
+          </a>
+        </div>
+      )}
+
+      {/* Image Carousel Modal */}
+      {carouselIndex !== null && allImages[carouselIndex] && (
+        <div
+          className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
+          onTouchStart={handleTouchStart}
+          onTouchEnd={handleTouchEnd}
+        >
+          <button
+            type="button"
+            onClick={closeCarousel}
+            className="absolute top-3 right-3 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white transition-colors hover:bg-black/70 sm:top-4 sm:right-4"
+          >
+            <X className="h-5 w-5" />
+          </button>
+
+          {allImages.length > 1 && (
+            <div className="absolute top-3 left-1/2 z-10 -translate-x-1/2 rounded-full bg-black/50 px-3 py-1 text-sm font-medium text-white sm:top-4">
+              {carouselIndex + 1} / {allImages.length}
+            </div>
+          )}
+
+          {carouselIndex > 0 && (
+            <button
+              type="button"
+              onClick={(e) => {
+                e.stopPropagation();
+                prevImage();
+              }}
+              className="absolute left-2 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white transition-colors hover:bg-black/70 sm:left-4 sm:h-12 sm:w-12"
+            >
+              <ChevronLeft className="h-6 w-6" />
+            </button>
+          )}
+
+          {carouselIndex < allImages.length - 1 && (
+            <button
+              type="button"
+              onClick={(e) => {
+                e.stopPropagation();
+                nextImage();
+              }}
+              className="absolute right-2 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white transition-colors hover:bg-black/70 sm:right-4 sm:h-12 sm:w-12"
+            >
+              <ChevronRight className="h-6 w-6" />
+            </button>
+          )}
+
+          <div
+            className="flex max-h-[85vh] max-w-[90vw] flex-col items-center"
+            onClick={(e) => e.stopPropagation()}
+          >
+            <img
+              src={allImages[carouselIndex].url}
+              alt={allImages[carouselIndex].itemName}
+              className="max-h-[80vh] max-w-full rounded-lg object-contain"
+              draggable={false}
+            />
+            <p className="mt-2 max-w-md text-center text-sm text-white/80">
+              {allImages[carouselIndex].itemName}
+            </p>
+          </div>
         </div>
       )}
+
+      {hasIssues && !quoteRequested && (
+        <QuoteRequestDialog
+          open={showQuoteDialog}
+          onOpenChange={setShowQuoteDialog}
+          items={inspection.items}
+          inspectionId={inspection.id}
+          publicToken={publicToken}
+          onSuccess={() => setQuoteRequested(true)}
+        />
+      )}
     </div>
   );
 }

+ 22 - 2
src/app/(public)/share/inspection/[orgId]/[token]/page.tsx

@@ -40,7 +40,7 @@ export default async function PublicInspectionPage({
     notFound();
   }
 
-  const [settings, org, features] = await Promise.all([
+  const [settings, org, features, existingRequest] = await Promise.all([
     db.appSetting.findMany({
       where: {
         organizationId: orgId,
@@ -63,6 +63,10 @@ export default async function PublicInspectionPage({
       select: { name: true },
     }),
     getFeatures(orgId),
+    db.inspectionQuoteRequest.findFirst({
+      where: { inspectionId: inspection.id, status: "pending" },
+      select: { id: true },
+    }),
   ]);
 
   const settingsMap: Record<string, string> = {};
@@ -86,15 +90,31 @@ export default async function PublicInspectionPage({
 
   const primaryColor = settingsMap["invoice.primaryColor"] || "#d97706";
 
+  // Rewrite image URLs on inspection items for public access
+  const publicInspection = {
+    ...inspection,
+    items: inspection.items.map((item) => ({
+      ...item,
+      imageUrls: item.imageUrls.map((url) => {
+        const match = url.match(/^\/api\/files\/[^/]+\/(.+)$/);
+        if (match) return `/api/files/public/${token}/${match[1]}`;
+        return url;
+      }),
+    })),
+  };
+
   return (
     <InspectionView
-      inspection={inspection}
+      inspection={publicInspection}
       workshop={workshop}
       logoUrl={logoUrl}
       primaryColor={primaryColor}
       showTorqvoiceBranding={!features.brandingRemoved}
       dateFormat={settingsMap["workshop.dateFormat"] || undefined}
       timezone={settingsMap["workshop.timezone"] || undefined}
+      publicToken={token}
+      orgId={orgId}
+      hasExistingQuoteRequest={!!existingRequest}
     />
   );
 }

+ 112 - 0
src/app/api/inspections/[id]/pdf/route.ts

@@ -0,0 +1,112 @@
+import { NextResponse } from "next/server";
+import { renderToBuffer } from "@react-pdf/renderer";
+import { getAuthContext } from "@/lib/get-auth-context";
+import { db } from "@/lib/db";
+import { InspectionPDF } from "@/features/inspections/Components/InspectionPDF";
+import React from "react";
+import { readFile } from "fs/promises";
+import { resolveUploadPath } from "@/lib/resolve-upload-path";
+import { getFeatures } from "@/lib/features";
+import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
+
+export async function GET(
+  _request: Request,
+  { params }: { params: Promise<{ id: string }> }
+) {
+  try {
+    const ctx = await getAuthContext();
+    if (!ctx) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const { id } = await params;
+
+    const [inspection, settings, org] = await Promise.all([
+      db.inspection.findFirst({
+        where: { id, organizationId: ctx.organizationId },
+        include: {
+          vehicle: {
+            select: {
+              make: true, model: true, year: true, vin: true,
+              licensePlate: true, mileage: true,
+              customer: { select: { name: true, email: true, phone: true } },
+            },
+          },
+          template: { select: { name: true } },
+          items: { orderBy: { sortOrder: "asc" } },
+        },
+      }),
+      db.appSetting.findMany({ where: { organizationId: ctx.organizationId } }),
+      db.organization.findUnique({
+        where: { id: ctx.organizationId },
+        select: { name: true },
+      }),
+    ]);
+
+    if (!inspection) {
+      return NextResponse.json({ error: "Inspection not found" }, { status: 404 });
+    }
+
+    const settingsMap: Record<string, string> = {};
+    for (const s of settings) settingsMap[s.key] = s.value;
+
+    let logoDataUri: string | undefined;
+    const logoPath = settingsMap["workshop.logo"];
+    if (logoPath) {
+      try {
+        const fullPath = resolveUploadPath(logoPath);
+        const logoBuffer = await readFile(fullPath);
+        const ext = logoPath.split(".").pop()?.toLowerCase() || "png";
+        const mimeMap: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", svg: "image/svg+xml" };
+        const mime = mimeMap[ext] || "image/png";
+        logoDataUri = `data:${mime};base64,${logoBuffer.toString("base64")}`;
+      } catch {
+        // Skip
+      }
+    }
+
+    const features = await getFeatures(ctx.organizationId);
+    let torqvoiceLogoDataUri: string | undefined;
+    if (!features.brandingRemoved) {
+      torqvoiceLogoDataUri = await getTorqvoiceLogoDataUri();
+    }
+
+    const template = {
+      primaryColor: settingsMap["invoice.primaryColor"] || "#d97706",
+      fontFamily: settingsMap["invoice.fontFamily"] || "Helvetica",
+      showLogo: settingsMap["invoice.showLogo"] !== "false",
+      showCompanyName: settingsMap["invoice.showCompanyName"] !== "false",
+      headerStyle: settingsMap["invoice.headerStyle"] || "standard",
+    };
+
+    const element = React.createElement(InspectionPDF, {
+      data: inspection,
+      workshop: {
+        name: org?.name || "",
+        address: settingsMap["workshop.address"] || "",
+        phone: settingsMap["workshop.phone"] || "",
+        email: settingsMap["workshop.email"] || "",
+      },
+      logoDataUri,
+      torqvoiceLogoDataUri,
+      dateFormat: settingsMap["workshop.dateFormat"] || undefined,
+      timezone: settingsMap["workshop.timezone"] || undefined,
+      template,
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    }) as any;
+    const buffer = await renderToBuffer(element);
+
+    const vehicleName = `${inspection.vehicle.year}-${inspection.vehicle.make}-${inspection.vehicle.model}`;
+    const fileName = `Inspection-${vehicleName}.pdf`;
+
+    return new NextResponse(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer, {
+      headers: {
+        "Content-Type": "application/pdf",
+        "Content-Disposition": `attachment; filename="${fileName}"`,
+      },
+    });
+  } catch (error) {
+    console.error("[Inspection PDF] Error:", error);
+    return NextResponse.json({ error: "Failed to generate PDF" }, { status: 500 });
+  }
+}

+ 24 - 0
src/app/api/public/inspection-quote-request/route.ts

@@ -0,0 +1,24 @@
+import { NextResponse } from "next/server";
+import { createQuoteRequest, cancelQuoteRequest } from "@/features/inspections/Actions/quoteRequestActions";
+
+export async function POST(request: Request) {
+  try {
+    const body = await request.json();
+    const result = await createQuoteRequest(body);
+    return NextResponse.json(result, { status: result.success ? 200 : 400 });
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to create quote request";
+    return NextResponse.json({ success: false, error: message }, { status: 400 });
+  }
+}
+
+export async function DELETE(request: Request) {
+  try {
+    const body = await request.json();
+    const result = await cancelQuoteRequest(body);
+    return NextResponse.json(result, { status: result.success ? 200 : 400 });
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to cancel quote request";
+    return NextResponse.json({ success: false, error: message }, { status: 400 });
+  }
+}

+ 122 - 0
src/app/api/share/inspection/[orgId]/[token]/pdf/route.ts

@@ -0,0 +1,122 @@
+import { NextResponse } from "next/server";
+import { renderToBuffer } from "@react-pdf/renderer";
+import { db } from "@/lib/db";
+import { InspectionPDF } from "@/features/inspections/Components/InspectionPDF";
+import React from "react";
+import { readFile } from "fs/promises";
+import { resolveUploadPath } from "@/lib/resolve-upload-path";
+import { getFeatures } from "@/lib/features";
+import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
+
+export async function GET(
+  _request: Request,
+  { params }: { params: Promise<{ orgId: string; token: string }> }
+) {
+  try {
+    const { orgId, token } = await params;
+
+    const inspection = await db.inspection.findFirst({
+      where: { publicToken: token, organizationId: orgId },
+      include: {
+        vehicle: {
+          select: {
+            make: true, model: true, year: true, vin: true,
+            licensePlate: true, mileage: true,
+            customer: { select: { name: true, email: true, phone: true } },
+          },
+        },
+        template: { select: { name: true } },
+        items: { orderBy: { sortOrder: "asc" } },
+      },
+    });
+
+    if (!inspection) {
+      return NextResponse.json({ error: "Inspection not found" }, { status: 404 });
+    }
+
+    const [settings, org] = await Promise.all([
+      db.appSetting.findMany({ where: { organizationId: orgId } }),
+      db.organization.findUnique({
+        where: { id: orgId },
+        select: { name: true },
+      }),
+    ]);
+
+    const settingsMap: Record<string, string> = {};
+    for (const s of settings) settingsMap[s.key] = s.value;
+
+    let logoDataUri: string | undefined;
+    const logoPath = settingsMap["workshop.logo"];
+    if (logoPath) {
+      try {
+        const fullPath = resolveUploadPath(logoPath);
+        const logoBuffer = await readFile(fullPath);
+        const ext = logoPath.split(".").pop()?.toLowerCase() || "png";
+        const mimeMap: Record<string, string> = {
+          png: "image/png",
+          jpg: "image/jpeg",
+          jpeg: "image/jpeg",
+          webp: "image/webp",
+          svg: "image/svg+xml",
+        };
+        const mime = mimeMap[ext] || "image/png";
+        logoDataUri = `data:${mime};base64,${logoBuffer.toString("base64")}`;
+      } catch {
+        // Skip
+      }
+    }
+
+    const features = await getFeatures(orgId);
+    let torqvoiceLogoDataUri: string | undefined;
+    if (!features.brandingRemoved) {
+      torqvoiceLogoDataUri = await getTorqvoiceLogoDataUri();
+    }
+
+    const template = {
+      primaryColor: settingsMap["invoice.primaryColor"] || "#d97706",
+      fontFamily: settingsMap["invoice.fontFamily"] || "Helvetica",
+      showLogo: settingsMap["invoice.showLogo"] !== "false",
+      showCompanyName: settingsMap["invoice.showCompanyName"] !== "false",
+      headerStyle: settingsMap["invoice.headerStyle"] || "standard",
+    };
+
+    const element = React.createElement(InspectionPDF, {
+      data: inspection,
+      workshop: {
+        name: org?.name || "",
+        address: settingsMap["workshop.address"] || "",
+        phone: settingsMap["workshop.phone"] || "",
+        email: settingsMap["workshop.email"] || "",
+      },
+      logoDataUri,
+      torqvoiceLogoDataUri,
+      dateFormat: settingsMap["workshop.dateFormat"] || undefined,
+      timezone: settingsMap["workshop.timezone"] || undefined,
+      template,
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    }) as any;
+    const buffer = await renderToBuffer(element);
+
+    const vehicleName = `${inspection.vehicle.year}-${inspection.vehicle.make}-${inspection.vehicle.model}`;
+    const fileName = `Inspection-${vehicleName}.pdf`;
+
+    return new NextResponse(
+      buffer.buffer.slice(
+        buffer.byteOffset,
+        buffer.byteOffset + buffer.byteLength
+      ) as ArrayBuffer,
+      {
+        headers: {
+          "Content-Type": "application/pdf",
+          "Content-Disposition": `attachment; filename="${fileName}"`,
+        },
+      }
+    );
+  } catch (error) {
+    console.error("[Public Inspection PDF] Error:", error);
+    return NextResponse.json(
+      { error: "Failed to generate PDF" },
+      { status: 500 }
+    );
+  }
+}

+ 1 - 1
src/components/ui/dialog.tsx

@@ -61,7 +61,7 @@ function DialogContent({
       <DialogPrimitive.Content
         data-slot="dialog-content"
         className={cn(
-          "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
+          "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-3rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
           className
         )}
         {...props}

+ 4 - 4
src/features/inspections/Actions/inspectionActions.ts

@@ -137,12 +137,13 @@ export async function createInspection(input: unknown) {
         },
       });
 
-      // Copy template items into inspection items
-      const items = template.sections.flatMap((section) =>
+      // Copy template items into inspection items with globally unique sortOrder
+      // so sections always appear in a stable order when sorted by sortOrder
+      const items = template.sections.flatMap((section, sIdx) =>
         section.items.map((item) => ({
           name: item.name,
           section: section.name,
-          sortOrder: item.sortOrder,
+          sortOrder: sIdx * 1000 + item.sortOrder,
           inspectionId: created.id,
         }))
       );
@@ -178,7 +179,6 @@ export async function updateInspectionItem(itemId: string, input: unknown) {
       },
     });
 
-    revalidatePath(`/inspections/${item.inspectionId}`);
     return updated;
   }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.INSPECTIONS }] });
 }

+ 126 - 0
src/features/inspections/Actions/quoteRequestActions.ts

@@ -0,0 +1,126 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { z } from "zod";
+
+const createQuoteRequestSchema = z.object({
+  inspectionId: z.string().min(1),
+  publicToken: z.string().min(1),
+  selectedItemIds: z.array(z.string()).min(1, "Select at least one item"),
+  message: z.string().optional(),
+});
+
+/**
+ * Public action — no auth required.
+ * Customer submits a quote request from the shared inspection page.
+ */
+export async function createQuoteRequest(input: unknown) {
+  const data = createQuoteRequestSchema.parse(input);
+
+  // Verify the inspection exists and the token matches
+  const inspection = await db.inspection.findFirst({
+    where: { id: data.inspectionId, publicToken: data.publicToken },
+  });
+  if (!inspection) {
+    return { success: false, error: "Inspection not found" };
+  }
+
+  // Check for existing pending request
+  const existing = await db.inspectionQuoteRequest.findFirst({
+    where: { inspectionId: data.inspectionId, status: "pending" },
+  });
+  if (existing) {
+    return { success: false, error: "A quote request is already pending for this inspection" };
+  }
+
+  await db.inspectionQuoteRequest.create({
+    data: {
+      inspectionId: data.inspectionId,
+      selectedItemIds: data.selectedItemIds,
+      message: data.message,
+      organizationId: inspection.organizationId,
+    },
+  });
+
+  return { success: true };
+}
+
+/**
+ * Public action — no auth required.
+ * Customer cancels a pending quote request from the shared inspection page.
+ */
+export async function cancelQuoteRequest(input: { inspectionId: string; publicToken: string }) {
+  const { inspectionId, publicToken } = input;
+
+  // Verify the inspection exists and the token matches
+  const inspection = await db.inspection.findFirst({
+    where: { id: inspectionId, publicToken },
+  });
+  if (!inspection) {
+    return { success: false, error: "Inspection not found" };
+  }
+
+  // Find the pending request
+  const existing = await db.inspectionQuoteRequest.findFirst({
+    where: { inspectionId, status: "pending" },
+  });
+  if (!existing) {
+    return { success: false, error: "No pending quote request found" };
+  }
+
+  await db.inspectionQuoteRequest.delete({ where: { id: existing.id } });
+
+  return { success: true };
+}
+
+/**
+ * Authenticated — fetch pending quote requests for the dashboard.
+ */
+export async function getQuoteRequests() {
+  return withAuth(async ({ organizationId }) => {
+    const requests = await db.inspectionQuoteRequest.findMany({
+      where: { organizationId, status: "pending" },
+      include: {
+        inspection: {
+          include: {
+            vehicle: {
+              select: {
+                id: true,
+                make: true,
+                model: true,
+                year: true,
+                licensePlate: true,
+                customer: { select: { name: true } },
+              },
+            },
+            items: { select: { id: true, name: true, section: true, condition: true, notes: true } },
+          },
+        },
+      },
+      orderBy: { createdAt: "desc" },
+      take: 20,
+    });
+    return requests;
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.INSPECTIONS }] });
+}
+
+/**
+ * Authenticated — update quote request status (quoted / dismissed).
+ */
+export async function updateQuoteRequestStatus(id: string, status: "quoted" | "dismissed") {
+  return withAuth(async ({ organizationId }) => {
+    const request = await db.inspectionQuoteRequest.findFirst({
+      where: { id, organizationId },
+    });
+    if (!request) throw new Error("Quote request not found");
+
+    await db.inspectionQuoteRequest.update({
+      where: { id },
+      data: { status },
+    });
+
+    return { success: true };
+  }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.INSPECTIONS }] });
+}

+ 454 - 0
src/features/inspections/Components/InspectionPDF.tsx

@@ -0,0 +1,454 @@
+import { Document, Page, Text, View, Image } from '@react-pdf/renderer'
+import { formatDateForPdf, DEFAULT_DATE_FORMAT } from '@/lib/format'
+import { createStyles, gray, getFontBold } from '@/features/vehicles/Components/invoice-pdf/styles'
+import type { TemplateConfig } from '@/features/vehicles/Components/invoice-pdf/types'
+
+interface InspectionItem {
+  name: string
+  section: string
+  condition: string
+  notes: string | null
+  sortOrder: number
+}
+
+interface InspectionData {
+  id: string
+  status: string
+  mileage: number | null
+  createdAt: Date
+  completedAt: Date | null
+  vehicle: {
+    make: string
+    model: string
+    year: number
+    vin: string | null
+    licensePlate: string | null
+    mileage: number | null
+    customer?: {
+      name: string
+      email?: string | null
+      phone?: string | null
+    } | null
+  }
+  template: { name: string }
+  items: InspectionItem[]
+}
+
+interface WorkshopInfo {
+  name: string
+  address: string
+  phone: string
+  email: string
+}
+
+const conditionColors: Record<string, { bg: string; text: string; label: string }> = {
+  pass: { bg: '#dcfce7', text: '#166534', label: 'Pass' },
+  attention: { bg: '#fef9c3', text: '#854d0e', label: 'Attention' },
+  fail: { bg: '#fee2e2', text: '#991b1b', label: 'Fail' },
+}
+
+export function InspectionPDF({
+  data,
+  workshop,
+  logoDataUri,
+  torqvoiceLogoDataUri,
+  dateFormat,
+  timezone,
+  template,
+}: {
+  data: InspectionData
+  workshop?: WorkshopInfo
+  logoDataUri?: string
+  torqvoiceLogoDataUri?: string
+  dateFormat?: string
+  timezone?: string
+  template?: TemplateConfig
+}) {
+  const primaryColor = template?.primaryColor || '#d97706'
+  const fontFamily = template?.fontFamily || 'Helvetica'
+  const headerStyle = template?.headerStyle || 'standard'
+  const showLogo = template?.showLogo !== false
+  const showCompanyName = template?.showCompanyName !== false
+  const styles = createStyles(primaryColor, fontFamily)
+  const fontBold = getFontBold(fontFamily)
+
+  const df = dateFormat || DEFAULT_DATE_FORMAT
+  const tz = timezone || undefined
+  const createdDate = formatDateForPdf(data.createdAt, df, tz)
+  const shopName = workshop?.name || 'Torqvoice'
+
+  // Filter out not_inspected items and group by section
+  const inspectedItems = data.items
+    .filter((i) => i.condition !== 'not_inspected')
+    .sort((a, b) => a.sortOrder - b.sortOrder)
+
+  const sectionOrder: string[] = []
+  const sections: Record<string, InspectionItem[]> = {}
+  for (const item of inspectedItems) {
+    if (!sections[item.section]) {
+      sections[item.section] = []
+      sectionOrder.push(item.section)
+    }
+    sections[item.section].push(item)
+  }
+
+  // Counts
+  const totalInspected = inspectedItems.length
+  const passCount = inspectedItems.filter((i) => i.condition === 'pass').length
+  const attentionCount = inspectedItems.filter((i) => i.condition === 'attention').length
+  const failCount = inspectedItems.filter((i) => i.condition === 'fail').length
+
+  const renderCompactHeader = () => (
+    <View style={{ marginBottom: 20 }}>
+      <View
+        style={{
+          flexDirection: 'row',
+          alignItems: 'center',
+          justifyContent: 'space-between',
+          paddingBottom: 10,
+          borderBottomWidth: 1,
+          borderBottomColor: '#e5e7eb',
+        }}
+      >
+        <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
+          {showLogo && logoDataUri && (
+            <Image
+              src={logoDataUri}
+              style={{ maxWidth: 40, maxHeight: 40, borderRadius: 4, objectFit: 'contain' }}
+            />
+          )}
+          {showCompanyName && (
+            <View>
+              <Text style={{ fontSize: 16, fontFamily: fontBold, color: primaryColor }}>
+                {shopName}
+              </Text>
+              {workshop?.address && (
+                <Text style={{ fontSize: 8, color: gray }}>{workshop.address}</Text>
+              )}
+            </View>
+          )}
+        </View>
+        <View style={{ alignItems: 'flex-end' }}>
+          <Text style={{ fontSize: 14, fontFamily: fontBold, color: primaryColor }}>
+            VEHICLE INSPECTION
+          </Text>
+          <Text style={{ fontSize: 9, color: gray, marginTop: 2 }}>{createdDate}</Text>
+        </View>
+      </View>
+      <View
+        style={{
+          flexDirection: 'row',
+          justifyContent: 'space-between',
+          marginTop: 6,
+          paddingHorizontal: 2,
+        }}
+      >
+        <View style={{ flexDirection: 'row', gap: 12 }}>
+          {workshop?.phone && (
+            <Text style={{ fontSize: 8, color: gray }}>Tel: {workshop.phone}</Text>
+          )}
+          {workshop?.email && (
+            <Text style={{ fontSize: 8, color: gray }}>{workshop.email}</Text>
+          )}
+        </View>
+        {torqvoiceLogoDataUri && (
+          <View style={{ flexDirection: 'row', alignItems: 'center', gap: 3 }}>
+            <Image src={torqvoiceLogoDataUri} style={{ width: 12, height: 12 }} />
+            <Text style={{ fontSize: 7, fontFamily: fontBold, color: gray }}>Torqvoice</Text>
+          </View>
+        )}
+      </View>
+    </View>
+  )
+
+  const renderModernHeader = () => (
+    <View style={{ marginBottom: 24 }}>
+      <View
+        style={{
+          backgroundColor: primaryColor,
+          padding: 20,
+          borderRadius: 4,
+          marginHorizontal: -10,
+        }}
+      >
+        <View
+          style={{
+            flexDirection: 'row',
+            alignItems: 'center',
+            justifyContent: 'center',
+            gap: 12,
+          }}
+        >
+          {showLogo && logoDataUri && (
+            <Image
+              src={logoDataUri}
+              style={{ maxWidth: 50, maxHeight: 50, borderRadius: 4, objectFit: 'contain' }}
+            />
+          )}
+          <View style={{ alignItems: 'center' }}>
+            {showCompanyName && (
+              <Text style={{ fontSize: 22, fontFamily: fontBold, color: 'white' }}>
+                {shopName}
+              </Text>
+            )}
+            {workshop?.address && (
+              <Text style={{ fontSize: 9, color: 'rgba(255,255,255,0.8)', marginTop: 2 }}>
+                {workshop.address}
+              </Text>
+            )}
+            <View style={{ flexDirection: 'row', gap: 12, marginTop: 4 }}>
+              {workshop?.phone && (
+                <Text style={{ fontSize: 8, color: 'rgba(255,255,255,0.7)' }}>
+                  Tel: {workshop.phone}
+                </Text>
+              )}
+              {workshop?.email && (
+                <Text style={{ fontSize: 8, color: 'rgba(255,255,255,0.7)' }}>
+                  {workshop.email}
+                </Text>
+              )}
+            </View>
+          </View>
+        </View>
+        {torqvoiceLogoDataUri && (
+          <View
+            style={{
+              flexDirection: 'row',
+              alignItems: 'center',
+              justifyContent: 'center',
+              gap: 3,
+              marginTop: 8,
+            }}
+          >
+            <Image src={torqvoiceLogoDataUri} style={{ width: 12, height: 12 }} />
+            <Text style={{ fontSize: 7, fontFamily: fontBold, color: 'rgba(255,255,255,0.7)' }}>
+              Torqvoice
+            </Text>
+          </View>
+        )}
+      </View>
+      <View
+        style={{
+          flexDirection: 'row',
+          justifyContent: 'space-between',
+          alignItems: 'center',
+          marginTop: 12,
+          paddingBottom: 8,
+        }}
+      >
+        <Text style={{ fontSize: 18, fontFamily: fontBold, color: primaryColor }}>
+          VEHICLE INSPECTION
+        </Text>
+        <Text style={{ fontSize: 9, color: gray }}>{createdDate}</Text>
+      </View>
+    </View>
+  )
+
+  const renderStandardHeader = () => (
+    <View style={styles.header}>
+      <View>
+        {showLogo && logoDataUri && (
+          <Image
+            src={logoDataUri}
+            style={{ width: 60, height: 60, marginBottom: 6, borderRadius: 4 }}
+          />
+        )}
+        {showCompanyName && <Text style={styles.brandName}>{shopName}</Text>}
+        {workshop?.address && <Text style={styles.brandSub}>{workshop.address}</Text>}
+        {workshop?.phone && <Text style={styles.brandContact}>Tel: {workshop.phone}</Text>}
+        {workshop?.email && <Text style={styles.brandContact}>{workshop.email}</Text>}
+      </View>
+      <View>
+        {torqvoiceLogoDataUri && (
+          <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 3, marginBottom: 6 }}>
+            <Image src={torqvoiceLogoDataUri} style={{ width: 16, height: 16 }} />
+            <Text style={{ fontSize: 9, fontFamily: fontBold, color: gray }}>Torqvoice</Text>
+          </View>
+        )}
+        <Text style={{ ...styles.invoiceTitle, color: primaryColor }}>VEHICLE INSPECTION</Text>
+        <Text style={styles.invoiceNumber}>{data.template.name}</Text>
+        <Text style={styles.invoiceNumber}>{createdDate}</Text>
+      </View>
+    </View>
+  )
+
+  return (
+    <Document>
+      <Page size="A4" style={styles.page}>
+        {headerStyle === 'compact'
+          ? renderCompactHeader()
+          : headerStyle === 'modern'
+            ? renderModernHeader()
+            : renderStandardHeader()}
+
+        {/* Vehicle & Customer info */}
+        <View style={styles.infoRow}>
+          <View style={styles.infoBox}>
+            <Text style={styles.infoLabel}>Vehicle</Text>
+            <Text style={styles.infoTextBold}>
+              {data.vehicle.year} {data.vehicle.make} {data.vehicle.model}
+            </Text>
+            {data.vehicle.vin && (
+              <Text style={styles.infoTextSmall}>VIN: {data.vehicle.vin}</Text>
+            )}
+            {data.vehicle.licensePlate && (
+              <Text style={styles.infoTextSmall}>Plate: {data.vehicle.licensePlate}</Text>
+            )}
+            {data.mileage && (
+              <Text style={styles.infoTextSmall}>Mileage: {data.mileage.toLocaleString()}</Text>
+            )}
+          </View>
+          {data.vehicle.customer && (
+            <View style={styles.infoBox}>
+              <Text style={styles.infoLabel}>Customer</Text>
+              <Text style={styles.infoTextBold}>{data.vehicle.customer.name}</Text>
+              {data.vehicle.customer.email && (
+                <Text style={styles.infoTextSmall}>{data.vehicle.customer.email}</Text>
+              )}
+              {data.vehicle.customer.phone && (
+                <Text style={styles.infoTextSmall}>{data.vehicle.customer.phone}</Text>
+              )}
+            </View>
+          )}
+          <View style={styles.infoBox}>
+            <Text style={styles.infoLabel}>Inspection Details</Text>
+            <Text style={styles.infoTextBold}>{data.template.name}</Text>
+            <Text style={styles.infoTextSmall}>
+              Status: {data.status === 'completed' ? 'Completed' : 'In Progress'}
+            </Text>
+          </View>
+        </View>
+
+        {/* Summary bar */}
+        <View
+          style={{
+            flexDirection: 'row',
+            gap: 12,
+            marginBottom: 16,
+            marginTop: 4,
+          }}
+        >
+          <View
+            style={{
+              flex: 1,
+              padding: 8,
+              backgroundColor: '#f3f4f6',
+              borderRadius: 4,
+              alignItems: 'center',
+            }}
+          >
+            <Text style={{ fontSize: 16, fontFamily: fontBold }}>{totalInspected}</Text>
+            <Text style={{ fontSize: 7, color: gray }}>Inspected</Text>
+          </View>
+          <View
+            style={{
+              flex: 1,
+              padding: 8,
+              backgroundColor: '#dcfce7',
+              borderRadius: 4,
+              alignItems: 'center',
+            }}
+          >
+            <Text style={{ fontSize: 16, fontFamily: fontBold, color: '#166534' }}>
+              {passCount}
+            </Text>
+            <Text style={{ fontSize: 7, color: '#166534' }}>Pass</Text>
+          </View>
+          <View
+            style={{
+              flex: 1,
+              padding: 8,
+              backgroundColor: '#fef9c3',
+              borderRadius: 4,
+              alignItems: 'center',
+            }}
+          >
+            <Text style={{ fontSize: 16, fontFamily: fontBold, color: '#854d0e' }}>
+              {attentionCount}
+            </Text>
+            <Text style={{ fontSize: 7, color: '#854d0e' }}>Attention</Text>
+          </View>
+          <View
+            style={{
+              flex: 1,
+              padding: 8,
+              backgroundColor: '#fee2e2',
+              borderRadius: 4,
+              alignItems: 'center',
+            }}
+          >
+            <Text style={{ fontSize: 16, fontFamily: fontBold, color: '#991b1b' }}>
+              {failCount}
+            </Text>
+            <Text style={{ fontSize: 7, color: '#991b1b' }}>Fail</Text>
+          </View>
+        </View>
+
+        {/* Sections with items */}
+        {sectionOrder.map((sectionName) => (
+          <View key={sectionName} style={{ marginBottom: 12 }} wrap={false}>
+            <Text style={styles.sectionTitle}>{sectionName}</Text>
+            <View style={styles.table}>
+              <View style={styles.tableHeader}>
+                <Text style={{ ...styles.tableHeaderCell, width: '45%' }}>Item</Text>
+                <Text style={{ ...styles.tableHeaderCell, width: '15%' }}>Status</Text>
+                <Text style={{ ...styles.tableHeaderCell, width: '40%' }}>Notes</Text>
+              </View>
+              {sections[sectionName].map((item, i) => {
+                const cond = conditionColors[item.condition]
+                return (
+                  <View key={i} style={styles.tableRow}>
+                    <Text style={{ ...styles.tableCell, width: '45%' }}>{item.name}</Text>
+                    <View style={{ width: '15%', flexDirection: 'row' }}>
+                      {cond ? (
+                        <View
+                          style={{
+                            backgroundColor: cond.bg,
+                            paddingHorizontal: 6,
+                            paddingVertical: 2,
+                            borderRadius: 3,
+                          }}
+                        >
+                          <Text style={{ fontSize: 7, color: cond.text, fontFamily: fontBold }}>
+                            {cond.label}
+                          </Text>
+                        </View>
+                      ) : (
+                        <Text style={{ fontSize: 9, color: gray }}>—</Text>
+                      )}
+                    </View>
+                    <Text style={{ ...styles.tableCell, width: '40%', color: gray }}>
+                      {item.notes || ''}
+                    </Text>
+                  </View>
+                )
+              })}
+            </View>
+          </View>
+        ))}
+
+        {/* Footer */}
+        {torqvoiceLogoDataUri ? (
+          <View
+            style={{
+              ...styles.footer,
+              flexDirection: 'row',
+              alignItems: 'center',
+              justifyContent: 'center',
+              gap: 4,
+            }}
+          >
+            <Text style={{ fontSize: 8, color: gray }}>
+              Vehicle Inspection — {shopName} ·{' '}
+            </Text>
+            <Text style={{ fontSize: 7, color: gray }}>Powered by</Text>
+            <Image src={torqvoiceLogoDataUri} style={{ width: 14, height: 14 }} />
+            <Text style={{ fontSize: 7, color: gray, fontFamily: fontBold }}>Torqvoice</Text>
+          </View>
+        ) : (
+          <Text style={styles.footer}>Vehicle Inspection — {shopName}</Text>
+        )}
+      </Page>
+    </Document>
+  )
+}

+ 129 - 20
src/features/inspections/Components/InspectionPageClient.tsx

@@ -16,7 +16,7 @@ import {
   DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,
 } from "@/components/ui/dropdown-menu";
 import {
-  ArrowLeft, Camera, Check, CheckCircle2, ClipboardCheck, Loader2,
+  ArrowLeft, Camera, Check, CheckCircle2, ClipboardCheck, Download, FileText, Loader2,
   MoreVertical, Share2, Trash2, X,
 } from "lucide-react";
 import { toast } from "sonner";
@@ -125,21 +125,21 @@ function InspectionItemRow({
   const [notes, setNotes] = useState(item.notes || "");
   const [imageUrls, setImageUrls] = useState<string[]>(item.imageUrls || []);
   const [showNotes, setShowNotes] = useState(!!item.notes);
+  const [notesRequired, setNotesRequired] = useState(false);
+  const [showClearNotesDialog, setShowClearNotesDialog] = useState(false);
   const [isSaving, startSaving] = useTransition();
   const [isUploading, setIsUploading] = useState(false);
   const fileInputRef = useRef<HTMLInputElement>(null);
+  const notesRef = useRef<HTMLTextAreaElement>(null);
 
-  const handleConditionChange = (newCondition: Condition) => {
-    if (isCompleted) return;
-    setCondition(newCondition);
+  const saveItem = (cond: Condition, n: string) => {
     startSaving(async () => {
       const result = await updateInspectionItem(item.id, {
-        condition: newCondition,
-        notes: notes || undefined,
+        condition: cond,
+        notes: n || undefined,
         imageUrls,
       });
       if (!result.success) {
-        setCondition(item.condition as Condition);
         toast.error("Failed to update item");
       } else {
         toast.success("Saved");
@@ -147,8 +147,61 @@ function InspectionItemRow({
     });
   };
 
+  const handleConditionChange = (newCondition: Condition) => {
+    if (isCompleted) return;
+
+    // Toggle off: clicking the same condition resets to not_inspected
+    if (condition === newCondition) {
+      if ((newCondition === "attention" || newCondition === "fail") && notes.trim()) {
+        setShowClearNotesDialog(true);
+        return;
+      }
+      setCondition("not_inspected");
+      setNotesRequired(false);
+      setShowNotes(false);
+      setNotes("");
+      saveItem("not_inspected", "");
+      return;
+    }
+
+    setCondition(newCondition);
+
+    // Attention/fail require notes — show notes field and wait for input
+    if (newCondition === "attention" || newCondition === "fail") {
+      setShowNotes(true);
+      setNotesRequired(true);
+      if (notes.trim()) {
+        // Notes already exist, save immediately
+        setNotesRequired(false);
+        saveItem(newCondition, notes);
+      } else {
+        // Focus the notes field so the tech can type right away
+        setTimeout(() => notesRef.current?.focus(), 50);
+      }
+      return;
+    }
+
+    // Pass: save immediately, no notes required
+    setNotesRequired(false);
+    saveItem(newCondition, notes);
+  };
+
   const handleNotesBlur = () => {
-    if (notes === (item.notes || "") || isCompleted) return;
+    if (isCompleted) return;
+
+    // If notes were required (attention/fail selected without notes)
+    if (notesRequired) {
+      if (notes.trim()) {
+        setNotesRequired(false);
+        saveItem(condition, notes);
+      } else {
+        toast.error("Notes are required for this status");
+      }
+      return;
+    }
+
+    // Normal notes update
+    if (notes === (item.notes || "")) return;
     startSaving(async () => {
       const result = await updateInspectionItem(item.id, {
         condition,
@@ -277,14 +330,20 @@ function InspectionItemRow({
       </div>
 
       {showNotes && (
-        <Textarea
-          value={notes}
-          onChange={(e) => setNotes(e.target.value)}
-          onBlur={handleNotesBlur}
-          placeholder="Add notes..."
-          className="text-sm min-h-[60px]"
-          disabled={isCompleted}
-        />
+        <div className="space-y-1">
+          <Textarea
+            ref={notesRef}
+            value={notes}
+            onChange={(e) => setNotes(e.target.value)}
+            onBlur={handleNotesBlur}
+            placeholder={notesRequired ? "Notes required for this status..." : "Add notes..."}
+            className={`text-sm min-h-[60px] ${notesRequired ? "border-red-400 focus-visible:ring-red-400" : ""}`}
+            disabled={isCompleted}
+          />
+          {notesRequired && (
+            <p className="text-xs text-red-500">Notes are required for attention/fail items</p>
+          )}
+        </div>
       )}
 
       {imageUrls.length > 0 && (
@@ -317,6 +376,33 @@ function InspectionItemRow({
           ))}
         </div>
       )}
+
+      <AlertDialog open={showClearNotesDialog} onOpenChange={setShowClearNotesDialog}>
+        <AlertDialogContent>
+          <AlertDialogHeader>
+            <AlertDialogTitle>Remove status?</AlertDialogTitle>
+            <AlertDialogDescription>
+              The notes for this item will be removed. This action cannot be undone.
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel>Cancel</AlertDialogCancel>
+            <AlertDialogAction
+              className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+              onClick={() => {
+                setCondition("not_inspected");
+                setNotesRequired(false);
+                setShowNotes(false);
+                setNotes("");
+                setShowClearNotesDialog(false);
+                saveItem("not_inspected", "");
+              }}
+            >
+              Remove
+            </AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
     </div>
   );
 }
@@ -335,10 +421,15 @@ export function InspectionPageClient({
 
   const isCompleted = inspection.status === "completed";
 
-  // Group items by section
+  // Sort items by sortOrder first, then group by section preserving order
+  const sortedItems = [...inspection.items].sort((a, b) => a.sortOrder - b.sortOrder);
+  const sectionOrder: string[] = [];
   const sections: Record<string, InspectionItem[]> = {};
-  for (const item of inspection.items) {
-    if (!sections[item.section]) sections[item.section] = [];
+  for (const item of sortedItems) {
+    if (!sections[item.section]) {
+      sections[item.section] = [];
+      sectionOrder.push(item.section);
+    }
     sections[item.section].push(item);
   }
 
@@ -347,6 +438,7 @@ export function InspectionPageClient({
   const passCount = inspection.items.filter((i) => i.condition === "pass").length;
   const failCount = inspection.items.filter((i) => i.condition === "fail").length;
   const attentionCount = inspection.items.filter((i) => i.condition === "attention").length;
+  const hasIssueItems = failCount > 0 || attentionCount > 0;
 
   const handleComplete = () => {
     startTransition(async () => {
@@ -392,6 +484,16 @@ export function InspectionPageClient({
                 Complete Inspection
               </Button>
             )}
+            {hasIssueItems && (
+              <Button
+                variant="outline"
+                size="sm"
+                onClick={() => router.push(`/quotes/new?fromInspection=${inspection.id}`)}
+              >
+                <FileText className="mr-1 h-3.5 w-3.5" />
+                Create Quote
+              </Button>
+            )}
             <Button variant="outline" size="sm" onClick={() => setShowShareDialog(true)}>
               <Share2 className="mr-1 h-3.5 w-3.5" />
               Share
@@ -403,6 +505,12 @@ export function InspectionPageClient({
                 </Button>
               </DropdownMenuTrigger>
               <DropdownMenuContent align="end">
+                <DropdownMenuItem
+                  onClick={() => window.open(`/api/inspections/${inspection.id}/pdf`, "_blank")}
+                >
+                  <Download className="mr-2 h-4 w-4" />
+                  Download PDF
+                </DropdownMenuItem>
                 <DropdownMenuSeparator />
                 <DropdownMenuItem
                   className="text-destructive"
@@ -483,7 +591,8 @@ export function InspectionPageClient({
 
       {/* Inspection sections */}
       <div className="space-y-4">
-        {Object.entries(sections).map(([sectionName, items]) => {
+        {sectionOrder.map((sectionName) => {
+          const items = sections[sectionName];
           const sectionPass = items.filter((i) => i.condition === "pass").length;
           const sectionTotal = items.length;
           return (

+ 191 - 0
src/features/inspections/Components/QuoteRequestDialog.tsx

@@ -0,0 +1,191 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { AlertTriangle, Loader2, X } from "lucide-react";
+import { toast } from "sonner";
+
+interface InspectionItem {
+  id: string;
+  name: string;
+  section: string;
+  condition: string;
+  notes: string | null;
+}
+
+export function QuoteRequestDialog({
+  open,
+  onOpenChange,
+  items,
+  inspectionId,
+  publicToken,
+  onSuccess,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  items: InspectionItem[];
+  inspectionId: string;
+  publicToken: string;
+  onSuccess: () => void;
+}) {
+  const issueItems = items.filter((i) => i.condition === "fail" || i.condition === "attention");
+  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set(issueItems.map((i) => i.id)));
+  const [message, setMessage] = useState("");
+  const [isSubmitting, setIsSubmitting] = useState(false);
+
+  const toggleItem = (id: string) => {
+    setSelectedIds((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) next.delete(id);
+      else next.add(id);
+      return next;
+    });
+  };
+
+  const toggleAll = () => {
+    if (selectedIds.size === issueItems.length) {
+      setSelectedIds(new Set());
+    } else {
+      setSelectedIds(new Set(issueItems.map((i) => i.id)));
+    }
+  };
+
+  const handleSubmit = async () => {
+    if (selectedIds.size === 0) {
+      toast.error("Select at least one item");
+      return;
+    }
+
+    setIsSubmitting(true);
+    try {
+      const res = await fetch("/api/public/inspection-quote-request", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          inspectionId,
+          publicToken,
+          selectedItemIds: Array.from(selectedIds),
+          message: message.trim() || undefined,
+        }),
+      });
+      const data = await res.json();
+      if (data.success) {
+        toast.success("Quote request submitted!");
+        onOpenChange(false);
+        onSuccess();
+      } else {
+        toast.error(data.error || "Failed to submit request");
+      }
+    } catch {
+      toast.error("Failed to submit request");
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  // Group by section
+  const sections: Record<string, InspectionItem[]> = {};
+  for (const item of issueItems) {
+    if (!sections[item.section]) sections[item.section] = [];
+    sections[item.section].push(item);
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>Request a Quote</DialogTitle>
+          <DialogDescription>
+            Select the items you&apos;d like a repair quote for.
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className="space-y-4 max-h-[400px] overflow-y-auto">
+          <div className="flex items-center justify-between">
+            <button
+              type="button"
+              onClick={toggleAll}
+              className="text-xs text-primary hover:underline"
+            >
+              {selectedIds.size === issueItems.length ? "Deselect all" : "Select all"}
+            </button>
+            <span className="text-xs text-muted-foreground">
+              {selectedIds.size} of {issueItems.length} selected
+            </span>
+          </div>
+
+          {Object.entries(sections).map(([sectionName, sectionItems]) => (
+            <div key={sectionName}>
+              <p className="text-xs font-semibold uppercase text-muted-foreground mb-2">
+                {sectionName}
+              </p>
+              <div className="space-y-2">
+                {sectionItems.map((item) => (
+                  <label
+                    key={item.id}
+                    className="flex items-start gap-3 rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
+                  >
+                    <Checkbox
+                      checked={selectedIds.has(item.id)}
+                      onCheckedChange={() => toggleItem(item.id)}
+                      className="mt-0.5"
+                    />
+                    <div className="flex-1 min-w-0">
+                      <div className="flex items-center gap-2">
+                        <span className="text-sm font-medium">{item.name}</span>
+                        {item.condition === "fail" ? (
+                          <span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-red-600 bg-red-100 rounded-full px-1.5 py-0.5">
+                            <X className="h-2.5 w-2.5" /> Fail
+                          </span>
+                        ) : (
+                          <span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-amber-600 bg-amber-100 rounded-full px-1.5 py-0.5">
+                            <AlertTriangle className="h-2.5 w-2.5" /> Attention
+                          </span>
+                        )}
+                      </div>
+                      {item.notes && (
+                        <p className="text-xs text-muted-foreground mt-0.5">{item.notes}</p>
+                      )}
+                    </div>
+                  </label>
+                ))}
+              </div>
+            </div>
+          ))}
+
+          <div className="space-y-2">
+            <label className="text-sm font-medium">
+              Message <span className="text-muted-foreground font-normal">(optional)</span>
+            </label>
+            <Textarea
+              value={message}
+              onChange={(e) => setMessage(e.target.value)}
+              placeholder="Any additional details or questions..."
+              className="min-h-[80px]"
+            />
+          </div>
+        </div>
+
+        <DialogFooter>
+          <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
+            Cancel
+          </Button>
+          <Button onClick={handleSubmit} disabled={isSubmitting || selectedIds.size === 0}>
+            {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+            Submit Request
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 13 - 4
src/features/quotes/Components/QuoteForm.tsx

@@ -92,6 +92,13 @@ function useIsLargeScreen() {
   return isLarge;
 }
 
+interface PrefillData {
+  title?: string;
+  vehicleId?: string;
+  customerId?: string;
+  laborItems?: QuoteLaborInput[];
+}
+
 export function QuoteForm({
   currencyCode = "USD",
   defaultTaxRate = 0,
@@ -101,6 +108,7 @@ export function QuoteForm({
   customers = [],
   vehicles = [],
   initialData,
+  prefill,
 }: {
   currencyCode?: string;
   defaultTaxRate?: number;
@@ -110,6 +118,7 @@ export function QuoteForm({
   customers?: CustomerOption[];
   vehicles?: VehicleOption[];
   initialData?: InitialData;
+  prefill?: PrefillData;
 }) {
   const cs = getCurrencySymbol(currencyCode);
   const isEdit = !!initialData;
@@ -118,10 +127,10 @@ export function QuoteForm({
   const isLarge = useIsLargeScreen();
   const [loading, setLoading] = useState(false);
   const [status, setStatus] = useState(initialData?.status || "draft");
-  const [customerId, setCustomerId] = useState(initialData?.customerId || "");
-  const [vehicleId, setVehicleId] = useState(initialData?.vehicleId || "");
+  const [customerId, setCustomerId] = useState(initialData?.customerId || prefill?.customerId || "");
+  const [vehicleId, setVehicleId] = useState(initialData?.vehicleId || prefill?.vehicleId || "");
   const [partItems, setPartItems] = useState<QuotePartInput[]>(initialData?.partItems || []);
-  const [laborItems, setLaborItems] = useState<QuoteLaborInput[]>(initialData?.laborItems || []);
+  const [laborItems, setLaborItems] = useState<QuoteLaborInput[]>(initialData?.laborItems || prefill?.laborItems || []);
   const [taxRate, setTaxRate] = useState(initialData?.taxRate ?? defaultTaxRate);
   const [discountType, setDiscountType] = useState<string>(initialData?.discountType || "none");
   const [discountValue, setDiscountValue] = useState(initialData?.discountValue ?? 0);
@@ -430,7 +439,7 @@ export function QuoteForm({
         <h3 className="text-sm font-semibold">Quote Details</h3>
         <div className="space-y-1">
           <Label htmlFor="title" className="text-xs">Title *</Label>
-          <Input id="title" name="title" placeholder="Vehicle repair estimate" defaultValue={initialData?.title || ""} required />
+          <Input id="title" name="title" placeholder="Vehicle repair estimate" defaultValue={initialData?.title || prefill?.title || ""} required />
         </div>
         <div className="grid grid-cols-2 gap-2">
           <div className="space-y-1">

+ 1 - 1
src/proxy.ts

@@ -1,7 +1,7 @@
 import { NextRequest, NextResponse } from "next/server";
 import { getSessionCookie } from "better-auth/cookies";
 
-const publicPaths = ["/auth/sign-in", "/auth/sign-up", "/api/auth", "/api/share", "/api/webhooks", "/invoice", "/share", "/terms"];
+const publicPaths = ["/auth", "/api/auth", "/api/share", "/api/public", "/api/files/public", "/api/webhooks", "/share", "/terms"];
 
 export async function proxy(request: NextRequest) {
   const { pathname } = request.nextUrl;