Jelajahi Sumber

Fix numeric input clearing and add vehicle/customer links in quotes (#19)

* allow smaler labor decimala

* allow earasing all values in input forms

* link to vehicle and customer in qoutes

* input forms

* allow null value

* allow null

* qoute attachments

* sms qoute template

* add separated labor and parts lines in qoute

* qoute images

* tests
Bernt Christian Egeland 7 bulan lalu
induk
melakukan
a6b39288ea
37 mengubah file dengan 1529 tambahan dan 72 penghapusan
  1. 21 0
      prisma/migrations/20260226201811_qoute_attachments/migration.sql
  2. 21 2
      prisma/schema.prisma
  3. 9 2
      src/__tests__/features/public/quote-view.test.tsx
  4. 1 1
      src/__tests__/lib/with-auth.test.ts
  5. 13 0
      src/app/(authenticated)/quotes/[id]/page.tsx
  6. 2 0
      src/app/(authenticated)/settings/templates/page.tsx
  7. 6 0
      src/app/(authenticated)/settings/templates/template-settings.tsx
  8. 23 0
      src/app/(public)/share/quote/[orgId]/[token]/page.tsx
  9. 178 2
      src/app/(public)/share/quote/[orgId]/[token]/quote-view.tsx
  10. 1 1
      src/app/api/protected/backup/import/route.ts
  11. 1 1
      src/app/api/protected/files/[...path]/route.ts
  12. 69 2
      src/app/api/protected/quotes/[id]/pdf/route.ts
  13. 64 0
      src/app/api/protected/upload/quote-files/route.ts
  14. 1 1
      src/app/api/public/files/[token]/[...path]/route.ts
  15. 73 12
      src/app/api/public/share/quote/[orgId]/[token]/pdf/route.ts
  16. 3 0
      src/features/customers/Actions/customerActions.ts
  17. 11 1
      src/features/inventory/Actions/inventoryActions.ts
  18. 75 0
      src/features/quotes/Actions/addQuoteAttachment.ts
  19. 46 0
      src/features/quotes/Actions/deleteQuoteAttachment.ts
  20. 37 1
      src/features/quotes/Actions/quoteActions.ts
  21. 56 0
      src/features/quotes/Actions/updateQuoteAttachment.ts
  22. 274 0
      src/features/quotes/Components/QuoteDocumentsManager.tsx
  23. 6 6
      src/features/quotes/Components/QuoteForm.tsx
  24. 299 0
      src/features/quotes/Components/QuoteImagesManager.tsx
  25. 79 0
      src/features/quotes/Components/QuotePDF.tsx
  26. 111 32
      src/features/quotes/Components/QuotePageClient.tsx
  27. 14 2
      src/features/quotes/Components/QuoteShareDialog.tsx
  28. 11 0
      src/features/quotes/Schema/quoteSchema.ts
  29. 1 0
      src/features/settings/Schema/settingsSchema.ts
  30. 1 0
      src/features/sms/Actions/smsActions.ts
  31. 2 0
      src/features/vehicles/Actions/reminderActions.ts
  32. 6 0
      src/features/vehicles/Actions/serviceActions.ts
  33. 6 0
      src/features/vehicles/Actions/vehicleActions.ts
  34. 2 2
      src/features/vehicles/Components/service-edit/LaborEditor.tsx
  35. 2 2
      src/features/vehicles/Components/service-edit/PartsEditor.tsx
  36. 2 2
      src/features/vehicles/Components/service-edit/TotalsSection.tsx
  37. 2 0
      src/lib/sms-templates.ts

+ 21 - 0
prisma/migrations/20260226201811_qoute_attachments/migration.sql

@@ -0,0 +1,21 @@
+-- CreateTable
+CREATE TABLE "quote_attachments" (
+    "id" TEXT NOT NULL,
+    "fileName" TEXT NOT NULL,
+    "fileUrl" TEXT NOT NULL,
+    "fileType" TEXT NOT NULL,
+    "fileSize" INTEGER NOT NULL,
+    "category" TEXT NOT NULL DEFAULT 'image',
+    "description" TEXT,
+    "includeInInvoice" BOOLEAN NOT NULL DEFAULT true,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "quoteId" TEXT NOT NULL,
+
+    CONSTRAINT "quote_attachments_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "quote_attachments_quoteId_idx" ON "quote_attachments"("quoteId");
+
+-- AddForeignKey
+ALTER TABLE "quote_attachments" ADD CONSTRAINT "quote_attachments_quoteId_fkey" FOREIGN KEY ("quoteId") REFERENCES "quotes"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 21 - 2
prisma/schema.prisma

@@ -404,13 +404,32 @@ model Quote {
   inspectionId String?
   inspection   Inspection? @relation(fields: [inspectionId], references: [id], onDelete: SetNull)
 
-  partItems  QuotePart[]
-  laborItems QuoteLabor[]
+  partItems   QuotePart[]
+  laborItems  QuoteLabor[]
+  attachments QuoteAttachment[]
 
   @@index([organizationId])
   @@map("quotes")
 }
 
+model QuoteAttachment {
+  id               String   @id @default(cuid())
+  fileName         String
+  fileUrl          String
+  fileType         String
+  fileSize         Int
+  category         String   @default("image")
+  description      String?
+  includeInInvoice Boolean  @default(true)
+  createdAt        DateTime @default(now())
+
+  quoteId String
+  quote   Quote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
+
+  @@index([quoteId])
+  @@map("quote_attachments")
+}
+
 model QuotePart {
   id         String  @id @default(cuid())
   partNumber String?

+ 9 - 2
src/__tests__/features/public/quote-view.test.tsx

@@ -3,10 +3,15 @@ import { render, screen, waitFor } from "@testing-library/react";
 import userEvent from "@testing-library/user-event";
 
 vi.mock("lucide-react", () => ({
+  Camera: () => <span data-testid="icon-camera" />,
   Check: () => <span data-testid="icon-check" />,
+  ChevronLeft: () => <span data-testid="icon-chevron-left" />,
+  ChevronRight: () => <span data-testid="icon-chevron-right" />,
   Download: () => <span data-testid="icon-download" />,
+  FileText: () => <span data-testid="icon-file-text" />,
   Loader2: () => <span data-testid="icon-loader" />,
   MessageSquare: () => <span data-testid="icon-message" />,
+  X: () => <span data-testid="icon-x" />,
 }));
 
 import { QuoteView } from "@/app/(public)/share/quote/[orgId]/[token]/quote-view";
@@ -119,7 +124,8 @@ describe("QuoteView", () => {
         },
       };
       render(<QuoteView {...props} />);
-      expect(screen.getByText("Parts")).toBeInTheDocument();
+      // "Parts" appears in both the table header and the totals row
+      expect(screen.getAllByText("Parts").length).toBeGreaterThanOrEqual(1);
       expect(screen.getByText("Oil Filter")).toBeInTheDocument();
       expect(screen.getByText("P-001")).toBeInTheDocument();
     });
@@ -138,7 +144,8 @@ describe("QuoteView", () => {
         },
       };
       render(<QuoteView {...props} />);
-      expect(screen.getByText("Labor")).toBeInTheDocument();
+      // "Labor" appears in both the table header and the totals row
+      expect(screen.getAllByText("Labor").length).toBeGreaterThanOrEqual(1);
       expect(screen.getByText("Labor - Oil Change")).toBeInTheDocument();
     });
 

+ 1 - 1
src/__tests__/lib/with-auth.test.ts

@@ -154,7 +154,7 @@ describe("withAuth", () => {
     mockGetCachedMembership.mockResolvedValue(MEMBERSHIP as any);
 
     const zodErr = new ZodError([
-      { code: "too_small", path: ["email"], message: "Required", minimum: 1, type: "string", inclusive: true },
+      { code: "too_small", path: ["email"], message: "Required", minimum: 1, origin: "string", inclusive: true },
     ]);
     const result = await withAuth(async () => { throw zodErr; });
     expect(result.success).toBe(false);

+ 13 - 0
src/app/(authenticated)/quotes/[id]/page.tsx

@@ -64,12 +64,25 @@ export default async function QuoteDetailPage({
       : [];
   const organizationId = authContext?.organizationId || "";
 
+  // Separate attachments by category
+  const allAttachments = result.data.attachments || [];
+  const imageAttachments = allAttachments.filter(
+    (a: { category: string }) => a.category === "image"
+  );
+  const documentAttachments = allAttachments.filter(
+    (a: { category: string }) => a.category === "document"
+  );
+
   return (
     <div className="flex h-svh flex-col overflow-hidden">
       <PageHeader />
       <QuotePageClient
         quote={result.data}
         organizationId={organizationId}
+        imageAttachments={imageAttachments}
+        documentAttachments={documentAttachments}
+        maxImages={features?.maxImagesPerService}
+        maxDocuments={features?.maxDocumentsPerService}
         currencyCode={currencyCode}
         defaultTaxRate={defaultTaxRate}
         taxEnabled={taxEnabled}

+ 2 - 0
src/app/(authenticated)/settings/templates/page.tsx

@@ -38,6 +38,7 @@ export default async function TemplatePage() {
       SETTING_KEYS.QUOTE_FONT_FAMILY,
       SETTING_KEYS.QUOTE_HEADER_STYLE,
       SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY,
+      SETTING_KEYS.SMS_TEMPLATE_QUOTE_READY,
       SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY,
       SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS,
       SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS,
@@ -56,6 +57,7 @@ export default async function TemplatePage() {
   const smsTemplates: Record<string, string> = {};
   const smsKeys = [
     SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY,
+    SETTING_KEYS.SMS_TEMPLATE_QUOTE_READY,
     SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY,
     SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS,
     SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS,

+ 6 - 0
src/app/(authenticated)/settings/templates/template-settings.tsx

@@ -460,6 +460,12 @@ const smsTemplateFields = [
     description: "Sent when sharing an invoice with a customer.",
     variables: ["{share_link}", "{company_name}", "{customer_name}", "{current_user}"],
   },
+  {
+    key: SETTING_KEYS.SMS_TEMPLATE_QUOTE_READY,
+    label: "Quote Ready",
+    description: "Sent when sharing a quote with a customer.",
+    variables: ["{share_link}", "{company_name}", "{customer_name}", "{current_user}"],
+  },
   {
     key: SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY,
     label: "Inspection Ready",

+ 23 - 0
src/app/(public)/share/quote/[orgId]/[token]/page.tsx

@@ -27,6 +27,7 @@ export default async function PublicQuotePage({
     include: {
       partItems: true,
       laborItems: true,
+      attachments: true,
       customer: {
         select: {
           name: true,
@@ -102,6 +103,26 @@ export default async function PublicQuotePage({
     else logoUrl = rawLogoUrl;
   }
 
+  // Rewrite attachment URLs for public access
+  const imageAttachments = (quote.attachments || [])
+    .filter((a) => a.category === "image")
+    .map((a) => ({
+      ...a,
+      fileUrl: a.fileUrl.replace(
+        /^\/api\/protected\/files\/[^/]+\//,
+        `/api/public/files/${token}/`
+      ),
+    }));
+  const documentAttachments = (quote.attachments || [])
+    .filter((a) => a.category === "document")
+    .map((a) => ({
+      ...a,
+      fileUrl: a.fileUrl.replace(
+        /^\/api\/protected\/files\/[^/]+\//,
+        `/api/public/files/${token}/`
+      ),
+    }));
+
   const primaryColor = settingsMap["quote.primaryColor"] || settingsMap["invoice.primaryColor"] || "#d97706";
   const headerStyle = settingsMap["quote.headerStyle"] || settingsMap["invoice.headerStyle"] || "standard";
 
@@ -127,6 +148,8 @@ export default async function PublicQuotePage({
       primaryColor={primaryColor}
       headerStyle={headerStyle}
       portalUrl={portalUrl}
+      imageAttachments={imageAttachments}
+      documentAttachments={documentAttachments}
     />
   );
 }

+ 178 - 2
src/app/(public)/share/quote/[orgId]/[token]/quote-view.tsx

@@ -1,7 +1,7 @@
 "use client";
 
-import { Check, Download, Loader2, MessageSquare } from "lucide-react";
-import { useState } from "react";
+import { Camera, Check, ChevronLeft, ChevronRight, Download, FileText, Loader2, MessageSquare, X } from "lucide-react";
+import { useState, useEffect, useCallback, useRef } from "react";
 import { formatCurrency, formatDate as fmtDate, DEFAULT_DATE_FORMAT } from "@/lib/format";
 import { sanitizeHtml } from "@/lib/sanitize-html";
 
@@ -50,6 +50,17 @@ interface QuoteRecord {
   } | null;
 }
 
+interface QuoteAttachmentView {
+  id: string;
+  fileName: string;
+  fileUrl: string;
+  fileType: string;
+  fileSize: number;
+  category: string;
+  description: string | null;
+  includeInInvoice: boolean;
+}
+
 const statusLabels: Record<string, string> = {
   draft: "Draft",
   sent: "Sent",
@@ -73,6 +84,8 @@ export function QuoteView({
   primaryColor = "#d97706",
   headerStyle = "standard",
   portalUrl,
+  imageAttachments = [],
+  documentAttachments = [],
 }: {
   quote: QuoteRecord;
   workshop: { name: string; address: string; phone: string; email: string };
@@ -86,12 +99,15 @@ export function QuoteView({
   primaryColor?: string;
   headerStyle?: string;
   portalUrl?: string;
+  imageAttachments?: QuoteAttachmentView[];
+  documentAttachments?: QuoteAttachmentView[];
 }) {
   const [downloading, setDownloading] = useState(false);
   const [status, setStatus] = useState(quote.status);
   const [submitting, setSubmitting] = useState(false);
   const [showChangesForm, setShowChangesForm] = useState(false);
   const [changeMessage, setChangeMessage] = useState("");
+  const [carouselIndex, setCarouselIndex] = useState<number | null>(null);
 
   const quoteNum = quote.quoteNumber || `QT-${quote.id.slice(-8).toUpperCase()}`;
   const df = dateFormat || DEFAULT_DATE_FORMAT;
@@ -100,6 +116,43 @@ export function QuoteView({
   const validUntilDate = quote.validUntil ? fmtDate(quote.validUntil, df, tz) : null;
   const shopName = workshop.name || "Torqvoice";
 
+  // Image carousel
+  const openCarousel = (index: number) => setCarouselIndex(index);
+  const closeCarousel = () => setCarouselIndex(null);
+  const prevImage = useCallback(
+    () => setCarouselIndex((i) => (i !== null && i > 0 ? i - 1 : i)),
+    []
+  );
+  const nextImage = useCallback(
+    () => setCarouselIndex((i) => (i !== null && i < imageAttachments.length - 1 ? i + 1 : i)),
+    [imageAttachments.length]
+  );
+
+  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]);
+
+  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 handleDownloadPDF = async () => {
     setDownloading(true);
     try {
@@ -368,6 +421,18 @@ export function QuoteView({
 
         {/* Totals */}
         <div className="mt-6 ml-auto max-w-xs space-y-2">
+          {quote.laborItems.length > 0 && (
+            <div className="flex justify-between text-sm">
+              <span className="text-gray-500">Labor</span>
+              <span>{formatCurrency(quote.laborItems.reduce((sum, l) => sum + l.total, 0), currencyCode)}</span>
+            </div>
+          )}
+          {quote.partItems.length > 0 && (
+            <div className="flex justify-between text-sm">
+              <span className="text-gray-500">Parts</span>
+              <span>{formatCurrency(quote.partItems.reduce((sum, p) => sum + p.total, 0), currencyCode)}</span>
+            </div>
+          )}
           {quote.subtotal > 0 && (
             <div className="flex justify-between text-sm">
               <span className="text-gray-500">Subtotal</span>
@@ -396,6 +461,57 @@ export function QuoteView({
           </div>
         </div>
 
+        {/* Image Attachments */}
+        {imageAttachments.length > 0 && (
+          <div className="mt-6">
+            <h4 className="mb-3 flex items-center gap-2 font-semibold">
+              <Camera className="h-4 w-4" />
+              Images ({imageAttachments.length})
+            </h4>
+            <div className="grid grid-cols-3 gap-3 sm:grid-cols-4">
+              {imageAttachments.map((att, idx) => (
+                <button
+                  key={att.id}
+                  type="button"
+                  onClick={() => openCarousel(idx)}
+                  className="group flex flex-col overflow-hidden rounded-lg border"
+                >
+                  <img
+                    src={att.fileUrl}
+                    alt={att.description || att.fileName}
+                    className="aspect-square w-full object-cover transition-transform group-hover:scale-105"
+                  />
+                  <p className="truncate px-1.5 py-1 text-xs text-gray-500">
+                    {att.description || "\u00A0"}
+                  </p>
+                </button>
+              ))}
+            </div>
+          </div>
+        )}
+
+        {/* Document Attachments */}
+        {documentAttachments.length > 0 && (
+          <div className="mt-6">
+            <h4 className="mb-3 font-semibold">Documents</h4>
+            <div className="space-y-2">
+              {documentAttachments.map((att) => (
+                <a
+                  key={att.id}
+                  href={att.fileUrl}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="flex items-center gap-3 rounded-md border p-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
+                >
+                  <FileText className="h-4 w-4 shrink-0 text-red-500" />
+                  <span className="min-w-0 flex-1 truncate text-sm font-medium">{att.fileName}</span>
+                  <Download className="h-3.5 w-3.5 shrink-0 text-gray-400" />
+                </a>
+              ))}
+            </div>
+          </div>
+        )}
+
         {/* Customer Actions */}
         {canRespond && !showChangesForm && (
           <div className="mt-8 flex flex-col gap-3 sm:flex-row sm:justify-end">
@@ -515,6 +631,66 @@ export function QuoteView({
           <p className="text-center text-xs text-gray-400">{shopName}</p>
         )}
       </div>
+
+      {/* Image Carousel Modal */}
+      {carouselIndex !== null && imageAttachments[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>
+
+          {imageAttachments.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} / {imageAttachments.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 < imageAttachments.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={imageAttachments[carouselIndex].fileUrl}
+              alt={imageAttachments[carouselIndex].description || imageAttachments[carouselIndex].fileName}
+              className="max-h-[80vh] max-w-full rounded-lg object-contain"
+              draggable={false}
+            />
+            {imageAttachments[carouselIndex].description && (
+              <p className="mt-2 max-w-md text-center text-sm text-white/80">
+                {imageAttachments[carouselIndex].description}
+              </p>
+            )}
+          </div>
+        </div>
+      )}
     </div>
   );
 }

+ 1 - 1
src/app/api/protected/backup/import/route.ts

@@ -64,7 +64,7 @@ async function restoreFiles(zip: JSZip, organizationId: string) {
     // Directory may not exist
   }
 
-  const allowedCategories = ["logos", "vehicles", "inventory", "services"];
+  const allowedCategories = ["logos", "vehicles", "inventory", "services", "quotes"];
 
   const fileEntries = Object.keys(zip.files).filter(
     (name) => !zip.files[name].dir && (name.startsWith("files/") || name.startsWith("uploads/"))

+ 1 - 1
src/app/api/protected/files/[...path]/route.ts

@@ -40,7 +40,7 @@ export async function GET(
     return NextResponse.json({ error: "Forbidden" }, { status: 403 });
   }
 
-  const allowedCategories = ["vehicles", "inventory", "services", "logos"];
+  const allowedCategories = ["vehicles", "inventory", "services", "logos", "quotes"];
   if (!allowedCategories.includes(category)) {
     return NextResponse.json({ error: "Invalid category" }, { status: 400 });
   }

+ 69 - 2
src/app/api/protected/quotes/[id]/pdf/route.ts

@@ -5,6 +5,7 @@ import { db } from "@/lib/db";
 import { QuotePDF } from "@/features/quotes/Components/QuotePDF";
 import React from "react";
 import { readFile } from "fs/promises";
+import { PDFDocument } from "pdf-lib";
 import { resolveUploadPath } from "@/lib/resolve-upload-path";
 import { getFeatures } from "@/lib/features";
 import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
@@ -27,6 +28,7 @@ export async function GET(
         include: {
           partItems: true,
           laborItems: true,
+          attachments: true,
           customer: { select: { name: true, email: true, phone: true, address: true, company: true } },
           vehicle: { select: { make: true, model: true, year: true, vin: true, licensePlate: true } },
         },
@@ -60,6 +62,47 @@ export async function GET(
       }
     }
 
+    // Process attachments for PDF
+    const imageAttachments: { fileName: string; dataUri: string; description?: string }[] = [];
+    const otherAttachments: { fileName: string; fileType: string }[] = [];
+    const pdfAttachments: { fileName: string; buffer: Buffer }[] = [];
+
+    const seenNames = new Set<string>();
+    const uniqueAttachments = (quote.attachments || [])
+      .filter((att) => att.includeInInvoice !== false)
+      .filter((att) => {
+        if (seenNames.has(att.fileName)) return false;
+        seenNames.add(att.fileName);
+        return true;
+      });
+
+    for (const att of uniqueAttachments) {
+      if (att.fileType.startsWith("image/")) {
+        try {
+          const filePath = resolveUploadPath(att.fileUrl);
+          const buffer = await readFile(filePath);
+          const base64 = buffer.toString("base64");
+          imageAttachments.push({
+            fileName: att.fileName,
+            dataUri: `data:${att.fileType};base64,${base64}`,
+            description: att.description || undefined,
+          });
+        } catch {
+          otherAttachments.push({ fileName: att.fileName, fileType: att.fileType });
+        }
+      } else if (att.fileType === "application/pdf") {
+        try {
+          const filePath = resolveUploadPath(att.fileUrl);
+          const buffer = await readFile(filePath);
+          pdfAttachments.push({ fileName: att.fileName, buffer });
+        } catch {
+          otherAttachments.push({ fileName: att.fileName, fileType: att.fileType });
+        }
+      } else {
+        otherAttachments.push({ fileName: att.fileName, fileType: att.fileType });
+      }
+    }
+
     // Check if Torqvoice branding should be shown
     const features = await getFeatures(ctx.organizationId);
     let torqvoiceLogoDataUri: string | undefined;
@@ -89,13 +132,37 @@ export async function GET(
       dateFormat: settingsMap["workshop.dateFormat"] || undefined,
       timezone: settingsMap["workshop.timezone"] || undefined,
       template,
+      imageAttachments,
+      otherAttachments,
+      pdfAttachmentNames: pdfAttachments.map((a) => a.fileName),
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
     }) as any;
-    const buffer = await renderToBuffer(element);
+    const quoteBuffer = await renderToBuffer(element);
 
     const quoteNum = quote.quoteNumber || `QT-${quote.id.slice(-8).toUpperCase()}`;
 
-    return new NextResponse(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer, {
+    // Merge attached PDFs into the quote PDF
+    let finalBuffer: ArrayBuffer;
+    if (pdfAttachments.length > 0) {
+      const mergedPdf = await PDFDocument.load(quoteBuffer);
+      for (const att of pdfAttachments) {
+        try {
+          const attachedPdf = await PDFDocument.load(att.buffer);
+          const pages = await mergedPdf.copyPages(attachedPdf, attachedPdf.getPageIndices());
+          for (const page of pages) {
+            mergedPdf.addPage(page);
+          }
+        } catch {
+          // Skip corrupted/unreadable PDFs silently
+        }
+      }
+      const saved = await mergedPdf.save();
+      finalBuffer = saved.buffer.slice(saved.byteOffset, saved.byteOffset + saved.byteLength) as ArrayBuffer;
+    } else {
+      finalBuffer = quoteBuffer.buffer.slice(quoteBuffer.byteOffset, quoteBuffer.byteOffset + quoteBuffer.byteLength) as ArrayBuffer;
+    }
+
+    return new NextResponse(finalBuffer, {
       headers: {
         "Content-Type": "application/pdf",
         "Content-Disposition": `attachment; filename="${quoteNum}.pdf"`,

+ 64 - 0
src/app/api/protected/upload/quote-files/route.ts

@@ -0,0 +1,64 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getAuthContext } from "@/lib/get-auth-context";
+import { writeFile, mkdir } from "fs/promises";
+import path from "path";
+import crypto from "crypto";
+
+const ALLOWED_TYPES = [
+  "image/jpeg",
+  "image/png",
+  "image/webp",
+  "application/pdf",
+  "text/csv",
+  "text/plain",
+  "video/mp4",
+  "video/webm",
+  "video/quicktime",
+];
+
+const MAX_SIZE = 10 * 1024 * 1024; // 10MB
+
+export async function POST(request: NextRequest) {
+  const ctx = await getAuthContext();
+
+  if (!ctx) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
+  const formData = await request.formData();
+  const file = formData.get("file") as File | null;
+
+  if (!file) {
+    return NextResponse.json({ error: "No file provided" }, { status: 400 });
+  }
+
+  if (!ALLOWED_TYPES.includes(file.type)) {
+    return NextResponse.json(
+      { error: "File type not allowed. Supported: JPEG, PNG, WebP, PDF, CSV, TXT, MP4, WebM, MOV" },
+      { status: 400 }
+    );
+  }
+
+  if (file.size > MAX_SIZE) {
+    return NextResponse.json(
+      { error: "File size must be under 10MB" },
+      { status: 400 }
+    );
+  }
+
+  const ext = file.name.split(".").pop() || "bin";
+  const filename = `${crypto.randomUUID()}.${ext}`;
+  const uploadDir = path.join(process.cwd(), "data", "uploads", ctx.organizationId, "quotes");
+
+  await mkdir(uploadDir, { recursive: true });
+
+  const bytes = new Uint8Array(await file.arrayBuffer());
+  await writeFile(path.join(uploadDir, filename), bytes);
+
+  return NextResponse.json({
+    url: `/api/protected/files/${ctx.organizationId}/quotes/${filename}`,
+    fileName: file.name,
+    fileType: file.type,
+    fileSize: file.size,
+  });
+}

+ 1 - 1
src/app/api/public/files/[token]/[...path]/route.ts

@@ -31,7 +31,7 @@ export async function GET(
 
   const [category, filename] = segments;
 
-  const allowedCategories = ["vehicles", "inventory", "services", "logos"];
+  const allowedCategories = ["vehicles", "inventory", "services", "logos", "quotes"];
   if (!allowedCategories.includes(category)) {
     return NextResponse.json({ error: "Invalid category" }, { status: 400 });
   }

+ 73 - 12
src/app/api/public/share/quote/[orgId]/[token]/pdf/route.ts

@@ -4,6 +4,7 @@ import { db } from "@/lib/db";
 import { QuotePDF } from "@/features/quotes/Components/QuotePDF";
 import React from "react";
 import { readFile } from "fs/promises";
+import { PDFDocument } from "pdf-lib";
 import { resolveUploadPath } from "@/lib/resolve-upload-path";
 import { getFeatures } from "@/lib/features";
 import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
@@ -25,6 +26,7 @@ export async function GET(
       include: {
         partItems: true,
         laborItems: true,
+        attachments: true,
         customer: {
           select: { name: true, email: true, phone: true, address: true, company: true },
         },
@@ -70,6 +72,47 @@ export async function GET(
       }
     }
 
+    // Process attachments for PDF
+    const imageAttachments: { fileName: string; dataUri: string; description?: string }[] = [];
+    const otherAttachments: { fileName: string; fileType: string }[] = [];
+    const pdfAttachments: { fileName: string; buffer: Buffer }[] = [];
+
+    const seenNames = new Set<string>();
+    const uniqueAttachments = (quote.attachments || [])
+      .filter((att) => att.includeInInvoice !== false)
+      .filter((att) => {
+        if (seenNames.has(att.fileName)) return false;
+        seenNames.add(att.fileName);
+        return true;
+      });
+
+    for (const att of uniqueAttachments) {
+      if (att.fileType.startsWith("image/")) {
+        try {
+          const filePath = resolveUploadPath(att.fileUrl);
+          const buffer = await readFile(filePath);
+          const base64 = buffer.toString("base64");
+          imageAttachments.push({
+            fileName: att.fileName,
+            dataUri: `data:${att.fileType};base64,${base64}`,
+            description: att.description || undefined,
+          });
+        } catch {
+          otherAttachments.push({ fileName: att.fileName, fileType: att.fileType });
+        }
+      } else if (att.fileType === "application/pdf") {
+        try {
+          const filePath = resolveUploadPath(att.fileUrl);
+          const buffer = await readFile(filePath);
+          pdfAttachments.push({ fileName: att.fileName, buffer });
+        } catch {
+          otherAttachments.push({ fileName: att.fileName, fileType: att.fileType });
+        }
+      } else {
+        otherAttachments.push({ fileName: att.fileName, fileType: att.fileType });
+      }
+    }
+
     const features = await getFeatures(orgId);
     let torqvoiceLogoDataUri: string | undefined;
     if (!features.brandingRemoved) {
@@ -107,25 +150,43 @@ export async function GET(
       timezone: settingsMap["workshop.timezone"] || undefined,
       template,
       portalUrl,
+      imageAttachments,
+      otherAttachments,
+      pdfAttachmentNames: pdfAttachments.map((a) => a.fileName),
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
     }) as any;
-    const buffer = await renderToBuffer(element);
+    const quoteBuffer = await renderToBuffer(element);
 
     const quoteNum =
       quote.quoteNumber || `QT-${quote.id.slice(-8).toUpperCase()}`;
 
-    return new NextResponse(
-      buffer.buffer.slice(
-        buffer.byteOffset,
-        buffer.byteOffset + buffer.byteLength
-      ) as ArrayBuffer,
-      {
-        headers: {
-          "Content-Type": "application/pdf",
-          "Content-Disposition": `attachment; filename="${quoteNum}.pdf"`,
-        },
+    // Merge attached PDFs into the quote PDF
+    let finalBuffer: ArrayBuffer;
+    if (pdfAttachments.length > 0) {
+      const mergedPdf = await PDFDocument.load(quoteBuffer);
+      for (const att of pdfAttachments) {
+        try {
+          const attachedPdf = await PDFDocument.load(att.buffer);
+          const pages = await mergedPdf.copyPages(attachedPdf, attachedPdf.getPageIndices());
+          for (const page of pages) {
+            mergedPdf.addPage(page);
+          }
+        } catch {
+          // Skip corrupted/unreadable PDFs silently
+        }
       }
-    );
+      const saved = await mergedPdf.save();
+      finalBuffer = saved.buffer.slice(saved.byteOffset, saved.byteOffset + saved.byteLength) as ArrayBuffer;
+    } else {
+      finalBuffer = quoteBuffer.buffer.slice(quoteBuffer.byteOffset, quoteBuffer.byteOffset + quoteBuffer.byteLength) as ArrayBuffer;
+    }
+
+    return new NextResponse(finalBuffer, {
+      headers: {
+        "Content-Type": "application/pdf",
+        "Content-Disposition": `attachment; filename="${quoteNum}.pdf"`,
+      },
+    });
   } catch (error) {
     console.error("[Public Quote PDF] Error:", error);
     return NextResponse.json(

+ 3 - 0
src/features/customers/Actions/customerActions.ts

@@ -76,6 +76,9 @@ export async function updateCustomer(input: unknown) {
       data: {
         ...data,
         email: data.email || null,
+        company: data.company || null,
+        phone: data.phone || null,
+        address: data.address || null,
       },
     });
     if (result.count === 0) throw new Error("Customer not found");

+ 11 - 1
src/features/inventory/Actions/inventoryActions.ts

@@ -116,7 +116,17 @@ export async function updateInventoryPart(input: unknown) {
 
     const result = await db.inventoryPart.updateMany({
       where: { id, organizationId },
-      data: updateData,
+      data: {
+        ...updateData,
+        partNumber: updateData.partNumber !== undefined ? (updateData.partNumber || null) : undefined,
+        description: updateData.description !== undefined ? (updateData.description || null) : undefined,
+        category: updateData.category !== undefined ? (updateData.category || null) : undefined,
+        supplier: updateData.supplier !== undefined ? (updateData.supplier || null) : undefined,
+        supplierPhone: updateData.supplierPhone !== undefined ? (updateData.supplierPhone || null) : undefined,
+        supplierEmail: updateData.supplierEmail !== undefined ? (updateData.supplierEmail || null) : undefined,
+        supplierUrl: updateData.supplierUrl !== undefined ? (updateData.supplierUrl || null) : undefined,
+        location: updateData.location !== undefined ? (updateData.location || null) : undefined,
+      },
     });
     if (result.count === 0) throw new Error("Part not found");
     revalidatePath("/inventory");

+ 75 - 0
src/features/quotes/Actions/addQuoteAttachment.ts

@@ -0,0 +1,75 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { quoteAttachmentSchema } from "../Schema/quoteSchema";
+import { revalidatePath } from "next/cache";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { getFeatures, type PlanFeatures } from "@/lib/features";
+import { z } from "zod";
+
+const addAttachmentSchema = z.object({
+  quoteId: z.string(),
+  attachment: quoteAttachmentSchema,
+});
+
+const CATEGORY_LIMIT_MAP: Record<string, keyof PlanFeatures | undefined> = {
+  image: "maxImagesPerService",
+  document: "maxDocumentsPerService",
+};
+
+export async function addQuoteAttachment(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = addAttachmentSchema.parse(input);
+
+      const quote = await db.quote.findFirst({
+        where: {
+          id: data.quoteId,
+          organizationId,
+        },
+        select: { id: true },
+      });
+      if (!quote) throw new Error("Quote not found");
+
+      const limitKey = CATEGORY_LIMIT_MAP[data.attachment.category];
+      if (limitKey) {
+        const features = await getFeatures(organizationId);
+        const maxAllowed = features[limitKey] as number;
+        const currentCount = await db.quoteAttachment.count({
+          where: {
+            quoteId: quote.id,
+            category: data.attachment.category,
+          },
+        });
+        if (currentCount >= maxAllowed) {
+          const label =
+            data.attachment.category.charAt(0).toUpperCase() +
+            data.attachment.category.slice(1);
+          throw new Error(
+            `${label} limit reached (${currentCount}/${maxAllowed}). Upgrade your plan for more.`
+          );
+        }
+      }
+
+      const attachment = await db.quoteAttachment.create({
+        data: {
+          ...data.attachment,
+          quoteId: quote.id,
+        },
+      });
+
+      revalidatePath(`/quotes/${quote.id}`);
+
+      return attachment;
+    },
+    {
+      requiredPermissions: [
+        {
+          action: PermissionAction.UPDATE,
+          subject: PermissionSubject.QUOTES,
+        },
+      ],
+    }
+  );
+}

+ 46 - 0
src/features/quotes/Actions/deleteQuoteAttachment.ts

@@ -0,0 +1,46 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { revalidatePath } from "next/cache";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { unlink } from "fs/promises";
+import { resolveUploadPath } from "@/lib/resolve-upload-path";
+
+export async function deleteQuoteAttachment(attachmentId: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const attachment = await db.quoteAttachment.findFirst({
+        where: {
+          id: attachmentId,
+          quote: { organizationId },
+        },
+        include: {
+          quote: { select: { id: true } },
+        },
+      });
+      if (!attachment) throw new Error("Attachment not found");
+
+      // Delete file from disk
+      const filePath = resolveUploadPath(attachment.fileUrl);
+      try {
+        await unlink(filePath);
+      } catch (err) {
+        console.warn(`[deleteQuoteAttachment] Failed to delete file "${filePath}":`, err);
+      }
+
+      await db.quoteAttachment.delete({ where: { id: attachmentId } });
+
+      revalidatePath(`/quotes/${attachment.quote.id}`);
+      return { deleted: true };
+    },
+    {
+      requiredPermissions: [
+        {
+          action: PermissionAction.UPDATE,
+          subject: PermissionSubject.QUOTES,
+        },
+      ],
+    }
+  );
+}

+ 37 - 1
src/features/quotes/Actions/quoteActions.ts

@@ -6,6 +6,8 @@ import { createQuoteSchema, updateQuoteSchema } from "../Schema/quoteSchema";
 import { revalidatePath } from "next/cache";
 import { resolveInvoicePrefix } from "@/lib/invoice-utils";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { copyFile, mkdir } from "fs/promises";
+import path from "path";
 
 export async function getQuotesPaginated(params: {
   page?: number;
@@ -75,6 +77,7 @@ export async function getQuote(quoteId: string) {
       include: {
         partItems: true,
         laborItems: true,
+        attachments: true,
         customer: {
           select: { id: true, name: true, email: true, phone: true, address: true, company: true },
         },
@@ -230,7 +233,7 @@ export async function convertQuoteToServiceRecord(quoteId: string, vehicleId: st
   return withAuth(async ({ userId, organizationId }) => {
     const quote = await db.quote.findFirst({
       where: { id: quoteId, organizationId },
-      include: { partItems: true, laborItems: true },
+      include: { partItems: true, laborItems: true, attachments: true },
     });
     if (!quote) throw new Error("Quote not found");
 
@@ -312,6 +315,39 @@ export async function convertQuoteToServiceRecord(quoteId: string, vehicleId: st
         });
       }
 
+      // Copy attachments from quote to service record
+      if (quote.attachments.length > 0) {
+        const quotesDir = path.join(process.cwd(), "data", "uploads", organizationId, "quotes");
+        const servicesDir = path.join(process.cwd(), "data", "uploads", organizationId, "services");
+        await mkdir(servicesDir, { recursive: true });
+
+        for (const att of quote.attachments) {
+          try {
+            // Extract filename from URL and build paths
+            const filename = att.fileUrl.split("/").pop()!;
+            const srcPath = path.join(quotesDir, filename);
+            const destPath = path.join(servicesDir, filename);
+            await copyFile(srcPath, destPath);
+
+            const newUrl = att.fileUrl.replace("/quotes/", "/services/");
+            await tx.serviceAttachment.create({
+              data: {
+                fileName: att.fileName,
+                fileUrl: newUrl,
+                fileType: att.fileType,
+                fileSize: att.fileSize,
+                category: att.category === "document" ? "document" : "image",
+                description: att.description,
+                includeInInvoice: att.includeInInvoice,
+                serviceRecordId: created.id,
+              },
+            });
+          } catch (err) {
+            console.warn(`[convertQuote] Failed to copy attachment "${att.fileName}":`, err);
+          }
+        }
+      }
+
       // Mark quote as converted
       await tx.quote.updateMany({
         where: { id: quoteId, organizationId },

+ 56 - 0
src/features/quotes/Actions/updateQuoteAttachment.ts

@@ -0,0 +1,56 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { revalidatePath } from "next/cache";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { z } from "zod";
+
+const updateAttachmentSchema = z.object({
+  id: z.string(),
+  description: z.string().optional(),
+  includeInInvoice: z.boolean().optional(),
+});
+
+export async function updateQuoteAttachment(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = updateAttachmentSchema.parse(input);
+
+      const attachment = await db.quoteAttachment.findFirst({
+        where: {
+          id: data.id,
+          quote: { organizationId },
+        },
+        include: {
+          quote: { select: { id: true } },
+        },
+      });
+      if (!attachment) throw new Error("Attachment not found");
+
+      const updated = await db.quoteAttachment.update({
+        where: { id: data.id },
+        data: {
+          ...(data.description !== undefined && {
+            description: data.description,
+          }),
+          ...(data.includeInInvoice !== undefined && {
+            includeInInvoice: data.includeInInvoice,
+          }),
+        },
+      });
+
+      revalidatePath(`/quotes/${attachment.quote.id}`);
+
+      return updated;
+    },
+    {
+      requiredPermissions: [
+        {
+          action: PermissionAction.UPDATE,
+          subject: PermissionSubject.QUOTES,
+        },
+      ],
+    }
+  );
+}

+ 274 - 0
src/features/quotes/Components/QuoteDocumentsManager.tsx

@@ -0,0 +1,274 @@
+"use client";
+
+import { useState, useCallback, useRef } from "react";
+import { Button } from "@/components/ui/button";
+import { Switch } from "@/components/ui/switch";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { toast } from "sonner";
+import {
+  FileText,
+  Image as ImageIcon,
+  Loader2,
+  Paperclip,
+  Upload,
+  X,
+} from "lucide-react";
+import { addQuoteAttachment } from "@/features/quotes/Actions/addQuoteAttachment";
+import { updateQuoteAttachment } from "@/features/quotes/Actions/updateQuoteAttachment";
+import { deleteQuoteAttachment } from "@/features/quotes/Actions/deleteQuoteAttachment";
+
+interface Attachment {
+  id: string;
+  fileName: string;
+  fileUrl: string;
+  fileType: string;
+  fileSize: number;
+  category: string;
+  description: string | null;
+  includeInInvoice: boolean;
+}
+
+interface QuoteDocumentsManagerProps {
+  quoteId: string;
+  initialDocuments: Attachment[];
+  maxDocuments?: number;
+}
+
+function formatFileSize(bytes: number) {
+  if (bytes < 1024) return `${bytes} B`;
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function getFileIcon(type: string) {
+  if (type === "application/pdf")
+    return <FileText className="h-4 w-4 text-red-500" />;
+  if (type.startsWith("image/"))
+    return <ImageIcon className="h-4 w-4 text-blue-500" />;
+  return <Paperclip className="h-4 w-4 text-muted-foreground" />;
+}
+
+export function QuoteDocumentsManager({
+  quoteId,
+  initialDocuments,
+  maxDocuments,
+}: QuoteDocumentsManagerProps) {
+  const [files, setFiles] = useState<Attachment[]>(initialDocuments);
+  const [uploading, setUploading] = useState(false);
+  const inputRef = useRef<HTMLInputElement>(null);
+  const atLimit = maxDocuments !== undefined && files.length >= maxDocuments;
+
+  const handleUpload = useCallback(
+    async (fileList: FileList | File[]) => {
+      let fileArr = Array.from(fileList);
+      if (maxDocuments !== undefined) {
+        const remaining = maxDocuments - files.length;
+        if (remaining <= 0) {
+          toast.error(`Document limit reached (${files.length}/${maxDocuments}). Upgrade your plan for more.`);
+          return;
+        }
+        if (fileArr.length > remaining) {
+          fileArr = fileArr.slice(0, remaining);
+          toast.warning(`Only uploading ${remaining} file${remaining > 1 ? "s" : ""} to stay within limit.`);
+        }
+      }
+      setUploading(true);
+      const toastId = toast.loading(
+        `Uploading ${fileArr.length} file${fileArr.length > 1 ? "s" : ""}...`
+      );
+      let successCount = 0;
+
+      for (const file of fileArr) {
+        const formData = new FormData();
+        formData.append("file", file);
+
+        try {
+          const res = await fetch("/api/protected/upload/quote-files", {
+            method: "POST",
+            body: formData,
+          });
+          if (!res.ok) {
+            const err = await res.json();
+            toast.error(err.error || `Failed to upload ${file.name}`);
+            continue;
+          }
+          const data = await res.json();
+
+          const result = await addQuoteAttachment({
+            quoteId,
+            attachment: {
+              fileName: data.fileName,
+              fileUrl: data.url,
+              fileType: data.fileType,
+              fileSize: data.fileSize,
+              category: "document",
+              includeInInvoice: true,
+            },
+          });
+
+          if (result.success && result.data) {
+            setFiles((prev) => [...prev, result.data as Attachment]);
+            successCount++;
+          } else {
+            toast.error(result.error || `Failed to save ${file.name}`);
+          }
+        } catch {
+          toast.error(`Failed to upload ${file.name}`);
+        }
+      }
+
+      if (successCount > 0) {
+        toast.success(
+          `${successCount} file${successCount > 1 ? "s" : ""} uploaded`,
+          { id: toastId }
+        );
+      } else {
+        toast.error("Upload failed", { id: toastId });
+      }
+      setUploading(false);
+    },
+    [quoteId, maxDocuments, files.length]
+  );
+
+  const handleDelete = useCallback(async (attachmentId: string) => {
+    const result = await deleteQuoteAttachment(attachmentId);
+    if (result.success) {
+      setFiles((prev) => prev.filter((f) => f.id !== attachmentId));
+      toast.success("File deleted");
+    } else {
+      toast.error(result.error || "Failed to delete file");
+    }
+  }, []);
+
+  const handleToggleInvoice = useCallback(
+    async (attachmentId: string, checked: boolean) => {
+      setFiles((prev) =>
+        prev.map((f) =>
+          f.id === attachmentId ? { ...f, includeInInvoice: checked } : f
+        )
+      );
+      const result = await updateQuoteAttachment({
+        id: attachmentId,
+        includeInInvoice: checked,
+      });
+      if (!result.success) {
+        setFiles((prev) =>
+          prev.map((f) =>
+            f.id === attachmentId
+              ? { ...f, includeInInvoice: !checked }
+              : f
+          )
+        );
+        toast.error(result.error || "Failed to update");
+      }
+    },
+    []
+  );
+
+  return (
+    <Card className="border-0 shadow-sm">
+      <CardHeader>
+        <CardTitle className="flex items-center gap-2 text-base">
+          <Paperclip className="h-4 w-4" />
+          Documents
+          {maxDocuments !== undefined && (
+            <span className="ml-auto text-xs font-normal text-muted-foreground">
+              {files.length} / {maxDocuments}
+            </span>
+          )}
+        </CardTitle>
+      </CardHeader>
+      <CardContent className="space-y-4">
+        {atLimit ? (
+          <div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-6">
+            <p className="text-sm font-medium text-muted-foreground">
+              Document limit reached ({files.length}/{maxDocuments})
+            </p>
+            <p className="mt-1 text-xs text-muted-foreground">
+              Upgrade your plan to upload more.
+            </p>
+          </div>
+        ) : (
+          <div
+            onDrop={(e) => {
+              e.preventDefault();
+              if (e.dataTransfer.files.length > 0)
+                handleUpload(e.dataTransfer.files);
+            }}
+            onDragOver={(e) => e.preventDefault()}
+            onClick={() => inputRef.current?.click()}
+            className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-6 transition-colors hover:border-muted-foreground/50"
+          >
+            <Upload className="mb-2 h-8 w-8 text-muted-foreground/50" />
+            <p className="text-sm font-medium">
+              {uploading
+                ? "Uploading..."
+                : "Drop files here or click to browse"}
+            </p>
+            <p className="mt-1 text-xs text-muted-foreground">
+              PDF, CSV, TXT — max 10MB each
+            </p>
+            <input
+              ref={inputRef}
+              type="file"
+              multiple
+              accept=".pdf,.csv,.txt"
+              className="hidden"
+              onChange={(e) => {
+                if (e.target.files && e.target.files.length > 0) {
+                  handleUpload(e.target.files);
+                  e.target.value = "";
+                }
+              }}
+            />
+          </div>
+        )}
+
+        {uploading && (
+          <div className="flex items-center gap-2 text-sm text-muted-foreground">
+            <Loader2 className="h-4 w-4 animate-spin" />
+            Uploading documents...
+          </div>
+        )}
+
+        {files.length > 0 && (
+          <div className="space-y-2">
+            {files.map((file) => (
+              <div
+                key={file.id}
+                className="flex items-center gap-3 rounded-md border p-2.5"
+              >
+                {getFileIcon(file.fileType)}
+                <div className="min-w-0 flex-1">
+                  <p className="truncate text-sm font-medium">{file.fileName}</p>
+                  <p className="text-xs text-muted-foreground">
+                    {formatFileSize(file.fileSize)}
+                  </p>
+                </div>
+                <label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
+                  <Switch
+                    checked={file.includeInInvoice}
+                    onCheckedChange={(checked) =>
+                      handleToggleInvoice(file.id, checked)
+                    }
+                    className="scale-75"
+                  />
+                  PDF
+                </label>
+                <Button
+                  type="button"
+                  variant="ghost"
+                  size="icon"
+                  className="h-7 w-7 text-muted-foreground hover:text-destructive"
+                  onClick={() => handleDelete(file.id)}
+                >
+                  <X className="h-3.5 w-3.5" />
+                </Button>
+              </div>
+            ))}
+          </div>
+        )}
+      </CardContent>
+    </Card>
+  );
+}

+ 6 - 6
src/features/quotes/Components/QuoteForm.tsx

@@ -253,8 +253,8 @@ export function QuoteForm({
               <div key={i} className="grid grid-cols-2 gap-2 sm:grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto]">
                 <Input placeholder="Part #" value={part.partNumber ?? ""} onChange={(e) => updatePart(i, "partNumber", e.target.value)} />
                 <Input placeholder="Name *" value={part.name} onChange={(e) => updatePart(i, "name", e.target.value)} />
-                <Input type="number" min="0" step="1" value={part.quantity} onChange={(e) => updatePart(i, "quantity", Number(e.target.value))} />
-                <Input type="number" min="0" step="0.01" value={part.unitPrice} onChange={(e) => updatePart(i, "unitPrice", Number(e.target.value))} />
+                <Input type="number" min="0" step="1" value={part.quantity} onChange={(e) => updatePart(i, "quantity", e.target.value)} />
+                <Input type="number" min="0" step="0.01" value={part.unitPrice} onChange={(e) => updatePart(i, "unitPrice", e.target.value)} />
                 <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(part.total, currencyCode)}</div>
                 <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => setPartItems(partItems.filter((_, j) => j !== i))}>
                   <Trash2 className="h-4 w-4" />
@@ -301,8 +301,8 @@ export function QuoteForm({
             {laborItems.map((labor, i) => (
               <div key={i} className="grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]">
                 <Input placeholder="Description *" value={labor.description} onChange={(e) => updateLabor(i, "description", e.target.value)} className="col-span-2 sm:col-span-1" />
-                <Input type="number" min="0" step="0.5" value={labor.hours} onChange={(e) => updateLabor(i, "hours", Number(e.target.value))} />
-                <Input type="number" min="0" step="0.01" value={labor.rate} onChange={(e) => updateLabor(i, "rate", Number(e.target.value))} />
+                <Input type="number" min="0" step="0.1" value={labor.hours} onChange={(e) => updateLabor(i, "hours", e.target.value)} />
+                <Input type="number" min="0" step="0.01" value={labor.rate} onChange={(e) => updateLabor(i, "rate", e.target.value)} />
                 <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(labor.total, currencyCode)}</div>
                 <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => setLaborItems(laborItems.filter((_, j) => j !== i))}>
                   <Trash2 className="h-4 w-4" />
@@ -503,7 +503,7 @@ export function QuoteForm({
                 <Input
                   type="number" min="0" step="0.01"
                   value={discountValue}
-                  onChange={(e) => setDiscountValue(Number(e.target.value))}
+                  onChange={(e) => setDiscountValue(e.target.value === "" ? 0 : Number(e.target.value))}
                   className="h-7 w-20 text-right text-xs"
                 />
               )}
@@ -520,7 +520,7 @@ export function QuoteForm({
                 <Input
                   type="number" min="0" step="0.1"
                   value={taxRate}
-                  onChange={(e) => setTaxRate(Number(e.target.value))}
+                  onChange={(e) => setTaxRate(e.target.value === "" ? 0 : Number(e.target.value))}
                   className="h-7 w-20 text-right text-xs"
                 />
                 <span className="text-muted-foreground">%</span>

+ 299 - 0
src/features/quotes/Components/QuoteImagesManager.tsx

@@ -0,0 +1,299 @@
+"use client";
+
+import { useState, useRef, useCallback } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { toast } from "sonner";
+import { Camera, Image as ImageIcon, Loader2, X } from "lucide-react";
+import { compressImage } from "@/lib/compress-image";
+import { addQuoteAttachment } from "@/features/quotes/Actions/addQuoteAttachment";
+import { updateQuoteAttachment } from "@/features/quotes/Actions/updateQuoteAttachment";
+import { deleteQuoteAttachment } from "@/features/quotes/Actions/deleteQuoteAttachment";
+
+interface Attachment {
+  id: string;
+  fileName: string;
+  fileUrl: string;
+  fileType: string;
+  fileSize: number;
+  category: string;
+  description: string | null;
+  includeInInvoice: boolean;
+}
+
+interface QuoteImagesManagerProps {
+  quoteId: string;
+  initialImages: Attachment[];
+  maxImages?: number;
+}
+
+export function QuoteImagesManager({
+  quoteId,
+  initialImages,
+  maxImages,
+}: QuoteImagesManagerProps) {
+  const [images, setImages] = useState<Attachment[]>(initialImages);
+  const atLimit = maxImages !== undefined && images.length >= maxImages;
+  const [uploading, setUploading] = useState(false);
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  const handleUpload = useCallback(
+    async (files: FileList | File[]) => {
+      const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
+      let fileArr = Array.from(files);
+      const rejected = fileArr.filter((f) => !allowedTypes.includes(f.type));
+      if (rejected.length > 0) {
+        toast.error(
+          `Only JPG, PNG, and WebP images are allowed. ${rejected.map((f) => f.name).join(", ")} skipped.`
+        );
+        fileArr = fileArr.filter((f) => allowedTypes.includes(f.type));
+        if (fileArr.length === 0) return;
+      }
+      if (maxImages !== undefined) {
+        const remaining = maxImages - images.length;
+        if (remaining <= 0) {
+          toast.error(`Image limit reached (${images.length}/${maxImages}). Upgrade your plan for more.`);
+          return;
+        }
+        if (fileArr.length > remaining) {
+          fileArr = fileArr.slice(0, remaining);
+          toast.warning(`Only uploading ${remaining} image${remaining > 1 ? "s" : ""} to stay within limit.`);
+        }
+      }
+      setUploading(true);
+      const toastId = toast.loading(
+        `Uploading ${fileArr.length} image${fileArr.length > 1 ? "s" : ""}...`
+      );
+      let successCount = 0;
+
+      for (let file of fileArr) {
+        if (file.type.startsWith("image/")) {
+          file = await compressImage(file);
+        }
+        const formData = new FormData();
+        formData.append("file", file);
+
+        try {
+          const res = await fetch("/api/protected/upload/quote-files", {
+            method: "POST",
+            body: formData,
+          });
+          if (!res.ok) {
+            const err = await res.json();
+            toast.error(err.error || `Failed to upload ${file.name}`);
+            continue;
+          }
+          const data = await res.json();
+
+          const result = await addQuoteAttachment({
+            quoteId,
+            attachment: {
+              fileName: data.fileName,
+              fileUrl: data.url,
+              fileType: data.fileType,
+              fileSize: data.fileSize,
+              category: "image",
+              includeInInvoice: true,
+            },
+          });
+
+          if (result.success && result.data) {
+            setImages((prev) => [...prev, result.data as Attachment]);
+            successCount++;
+          } else {
+            toast.error(result.error || `Failed to save ${file.name}`);
+          }
+        } catch {
+          toast.error(`Failed to upload ${file.name}`);
+        }
+      }
+
+      if (successCount > 0) {
+        toast.success(
+          `${successCount} image${successCount > 1 ? "s" : ""} uploaded`,
+          { id: toastId }
+        );
+      } else {
+        toast.error("Upload failed", { id: toastId });
+      }
+      setUploading(false);
+    },
+    [quoteId, maxImages, images.length]
+  );
+
+  const handleDrop = useCallback(
+    (e: React.DragEvent) => {
+      e.preventDefault();
+      if (e.dataTransfer.files.length > 0) {
+        handleUpload(e.dataTransfer.files);
+      }
+    },
+    [handleUpload]
+  );
+
+  const handleDelete = useCallback(async (attachmentId: string) => {
+    const result = await deleteQuoteAttachment(attachmentId);
+    if (result.success) {
+      setImages((prev) => prev.filter((img) => img.id !== attachmentId));
+      toast.success("Image deleted");
+    } else {
+      toast.error(result.error || "Failed to delete image");
+    }
+  }, []);
+
+  const handleToggleInvoice = useCallback(
+    async (attachmentId: string, checked: boolean) => {
+      setImages((prev) =>
+        prev.map((img) =>
+          img.id === attachmentId ? { ...img, includeInInvoice: checked } : img
+        )
+      );
+      const result = await updateQuoteAttachment({
+        id: attachmentId,
+        includeInInvoice: checked,
+      });
+      if (!result.success) {
+        setImages((prev) =>
+          prev.map((img) =>
+            img.id === attachmentId
+              ? { ...img, includeInInvoice: !checked }
+              : img
+          )
+        );
+        toast.error(result.error || "Failed to update");
+      }
+    },
+    []
+  );
+
+  const handleDescriptionChange = useCallback(
+    async (attachmentId: string, description: string) => {
+      setImages((prev) =>
+        prev.map((img) =>
+          img.id === attachmentId ? { ...img, description } : img
+        )
+      );
+    },
+    []
+  );
+
+  const handleDescriptionBlur = useCallback(
+    async (attachmentId: string, description: string) => {
+      await updateQuoteAttachment({ id: attachmentId, description });
+    },
+    []
+  );
+
+  return (
+    <Card className="border-0 shadow-sm">
+      <CardHeader>
+        <CardTitle className="flex items-center gap-2 text-base">
+          <Camera className="h-4 w-4" />
+          Quote Images
+          {maxImages !== undefined && (
+            <span className="ml-auto text-xs font-normal text-muted-foreground">
+              {images.length} / {maxImages}
+            </span>
+          )}
+        </CardTitle>
+      </CardHeader>
+      <CardContent className="space-y-4">
+        {atLimit ? (
+          <div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-6">
+            <p className="text-sm font-medium text-muted-foreground">
+              Image limit reached ({images.length}/{maxImages})
+            </p>
+            <p className="mt-1 text-xs text-muted-foreground">
+              Upgrade your plan to upload more images.
+            </p>
+          </div>
+        ) : (
+          <div
+            onDrop={handleDrop}
+            onDragOver={(e) => e.preventDefault()}
+            onClick={() => inputRef.current?.click()}
+            className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-6 transition-colors hover:border-muted-foreground/50"
+          >
+            <ImageIcon className="mb-2 h-8 w-8 text-muted-foreground/50" />
+            <p className="text-sm font-medium">
+              {uploading ? "Uploading..." : "Drop images here or click to browse"}
+            </p>
+            <p className="mt-1 text-xs text-muted-foreground">
+              JPG, PNG, WebP — max 10MB each
+            </p>
+            <input
+              ref={inputRef}
+              type="file"
+              multiple
+              accept=".jpg,.jpeg,.png,.webp"
+              className="hidden"
+              onChange={(e) => {
+                if (e.target.files && e.target.files.length > 0) {
+                  handleUpload(e.target.files);
+                  e.target.value = "";
+                }
+              }}
+            />
+          </div>
+        )}
+
+        {uploading && (
+          <div className="flex items-center gap-2 text-sm text-muted-foreground">
+            <Loader2 className="h-4 w-4 animate-spin" />
+            Uploading images...
+          </div>
+        )}
+
+        {images.length > 0 && (
+          <div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-6">
+            {images.map((file) => (
+              <div key={file.id} className="group overflow-hidden rounded-lg border">
+                <div className="relative">
+                  <img
+                    src={file.fileUrl}
+                    alt={file.description || file.fileName}
+                    className="aspect-square w-full object-cover"
+                  />
+                  <Button
+                    type="button"
+                    variant="secondary"
+                    size="icon"
+                    className="absolute right-1 top-1 h-6 w-6 opacity-0 transition-opacity group-hover:opacity-100"
+                    onClick={() => handleDelete(file.id)}
+                  >
+                    <X className="h-3 w-3" />
+                  </Button>
+                </div>
+                <div className="space-y-1 p-1.5">
+                  <Input
+                    placeholder="Description..."
+                    value={file.description || ""}
+                    onChange={(e) =>
+                      handleDescriptionChange(file.id, e.target.value)
+                    }
+                    onBlur={(e) =>
+                      handleDescriptionBlur(file.id, e.target.value)
+                    }
+                    className="h-7 text-xs"
+                  />
+                  <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
+                    <Switch
+                      checked={file.includeInInvoice}
+                      onCheckedChange={(checked) =>
+                        handleToggleInvoice(file.id, checked)
+                      }
+                      className="scale-75"
+                    />
+                    PDF
+                  </label>
+                </div>
+              </div>
+            ))}
+          </div>
+        )}
+      </CardContent>
+    </Card>
+  );
+}

+ 79 - 0
src/features/quotes/Components/QuotePDF.tsx

@@ -48,6 +48,17 @@ interface WorkshopInfo {
   email: string
 }
 
+interface ImageAttachmentPDF {
+  fileName: string
+  dataUri: string
+  description?: string
+}
+
+interface OtherAttachmentPDF {
+  fileName: string
+  fileType: string
+}
+
 export function QuotePDF({
   data,
   workshop,
@@ -58,6 +69,9 @@ export function QuotePDF({
   timezone,
   template,
   portalUrl,
+  imageAttachments = [],
+  otherAttachments = [],
+  pdfAttachmentNames = [],
 }: {
   data: QuoteData
   workshop?: WorkshopInfo
@@ -68,6 +82,9 @@ export function QuotePDF({
   timezone?: string
   template?: TemplateConfig
   portalUrl?: string
+  imageAttachments?: ImageAttachmentPDF[]
+  otherAttachments?: OtherAttachmentPDF[]
+  pdfAttachmentNames?: string[]
 }) {
   const primaryColor = template?.primaryColor || '#d97706'
   const fontFamily = template?.fontFamily || 'Helvetica'
@@ -380,6 +397,22 @@ export function QuotePDF({
         )}
 
         <View style={styles.totalsBox}>
+          {data.laborItems.length > 0 && (
+            <View style={styles.totalRow}>
+              <Text style={styles.totalLabel}>Labor</Text>
+              <Text style={styles.totalValue}>
+                {formatCurrency(data.laborItems.reduce((sum, l) => sum + l.total, 0), currencyCode)}
+              </Text>
+            </View>
+          )}
+          {data.partItems.length > 0 && (
+            <View style={styles.totalRow}>
+              <Text style={styles.totalLabel}>Parts</Text>
+              <Text style={styles.totalValue}>
+                {formatCurrency(data.partItems.reduce((sum, p) => sum + p.total, 0), currencyCode)}
+              </Text>
+            </View>
+          )}
           {data.subtotal > 0 && (
             <View style={styles.totalRow}>
               <Text style={styles.totalLabel}>Subtotal</Text>
@@ -434,6 +467,23 @@ export function QuotePDF({
           </View>
         )}
 
+        {/* Document attachment names */}
+        {(otherAttachments.length > 0 || pdfAttachmentNames.length > 0) && (
+          <View style={{ marginTop: 10 }}>
+            <Text style={styles.sectionTitle}>Attached Documents</Text>
+            {otherAttachments.map((att, i) => (
+              <Text key={`other-${i}`} style={{ fontSize: 9, color: gray, marginBottom: 2 }}>
+                {att.fileName}
+              </Text>
+            ))}
+            {pdfAttachmentNames.map((name, i) => (
+              <Text key={`pdf-${i}`} style={{ fontSize: 9, color: gray, marginBottom: 2 }}>
+                {name} (attached)
+              </Text>
+            ))}
+          </View>
+        )}
+
         {torqvoiceLogoDataUri ? (
           <View style={{
             ...styles.footer,
@@ -455,6 +505,35 @@ export function QuotePDF({
           </Text>
         )}
       </Page>
+
+      {imageAttachments.length > 0 && (
+        <Page size="A4" style={styles.page}>
+          <Text style={styles.sectionTitle}>Quote Images</Text>
+          <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 12 }}>
+            {imageAttachments.map((img, i) => (
+              <View key={i} style={{ width: '48%', marginBottom: 8 }}>
+                <Image
+                  src={img.dataUri}
+                  style={{
+                    maxHeight: 250,
+                    borderRadius: 4,
+                    objectFit: 'contain',
+                    objectPosition: 'left',
+                  }}
+                />
+                {img.description ? (
+                  <Text style={{ fontSize: 8, color: gray, marginTop: 2 }}>{img.description}</Text>
+                ) : (
+                  <Text style={{ fontSize: 8, color: gray, marginTop: 2 }}>{img.fileName}</Text>
+                )}
+              </View>
+            ))}
+          </View>
+          <Text style={styles.footer}>
+            {quoteNum} · {shopName}
+          </Text>
+        </Page>
+      )}
     </Document>
   )
 }

+ 111 - 32
src/features/quotes/Components/QuotePageClient.tsx

@@ -36,9 +36,12 @@ import { SendEmailDialog } from "@/features/email/Components/SendEmailDialog";
 import { QuoteShareDialog } from "@/features/quotes/Components/QuoteShareDialog";
 import { RichTextEditor } from "@/features/vehicles/Components/service-edit/RichTextEditor";
 import type { QuotePartInput, QuoteLaborInput } from "@/features/quotes/Schema/quoteSchema";
+import { QuoteImagesManager } from "@/features/quotes/Components/QuoteImagesManager";
+import { QuoteDocumentsManager } from "@/features/quotes/Components/QuoteDocumentsManager";
 import {
   ArrowLeft,
   ArrowRight,
+  Camera,
   Car,
   Check,
   ClipboardCheck,
@@ -48,6 +51,7 @@ import {
   Loader2,
   Mail,
   MessageSquare,
+  Paperclip,
   Plus,
   Save,
   Trash2,
@@ -83,6 +87,19 @@ interface VehicleOption {
   customerName: string | null;
 }
 
+interface QuoteAttachment {
+  id: string;
+  fileName: string;
+  fileUrl: string;
+  fileType: string;
+  fileSize: number;
+  category: string;
+  description: string | null;
+  includeInInvoice: boolean;
+}
+
+type TabType = "details" | "images" | "documents";
+
 interface QuoteRecord {
   id: string;
   quoteNumber: string | null;
@@ -150,6 +167,10 @@ export function QuotePageClient({
   vehicles = [],
   smsEnabled = false,
   emailEnabled = false,
+  imageAttachments = [],
+  documentAttachments = [],
+  maxImages,
+  maxDocuments,
 }: {
   quote: QuoteRecord;
   organizationId: string;
@@ -161,6 +182,10 @@ export function QuotePageClient({
   vehicles?: VehicleOption[];
   smsEnabled?: boolean;
   emailEnabled?: boolean;
+  imageAttachments?: QuoteAttachment[];
+  documentAttachments?: QuoteAttachment[];
+  maxImages?: number;
+  maxDocuments?: number;
 }) {
   const cs = getCurrencySymbol(currencyCode);
   const router = useRouter();
@@ -168,6 +193,9 @@ export function QuotePageClient({
   const confirm = useConfirm();
   const isLarge = useIsLargeScreen();
 
+  // Tab state
+  const [activeTab, setActiveTab] = useState<TabType>("details");
+
   // Form state
   const [saving, setSaving] = useState(false);
   const [downloading, setDownloading] = useState(false);
@@ -353,8 +381,8 @@ export function QuotePageClient({
               <div key={i} className="grid grid-cols-2 gap-2 sm:grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto]">
                 <Input placeholder="Part #" value={part.partNumber ?? ""} onChange={(e) => updatePart(i, "partNumber", e.target.value)} />
                 <Input placeholder="Name *" value={part.name} onChange={(e) => updatePart(i, "name", e.target.value)} />
-                <Input type="number" min="0" step="1" value={part.quantity} onChange={(e) => updatePart(i, "quantity", Number(e.target.value))} />
-                <Input type="number" min="0" step="0.01" value={part.unitPrice} onChange={(e) => updatePart(i, "unitPrice", Number(e.target.value))} />
+                <Input type="number" min="0" step="1" value={part.quantity} onChange={(e) => updatePart(i, "quantity", e.target.value)} />
+                <Input type="number" min="0" step="0.01" value={part.unitPrice} onChange={(e) => updatePart(i, "unitPrice", e.target.value)} />
                 <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(part.total, currencyCode)}</div>
                 <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => setPartItems(partItems.filter((_, j) => j !== i))}><Trash2 className="h-4 w-4" /></Button>
               </div>
@@ -384,8 +412,8 @@ export function QuotePageClient({
             {laborItems.map((labor, i) => (
               <div key={i} className="grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]">
                 <Input placeholder="Description *" value={labor.description} onChange={(e) => updateLabor(i, "description", e.target.value)} className="col-span-2 sm:col-span-1" />
-                <Input type="number" min="0" step="0.5" value={labor.hours} onChange={(e) => updateLabor(i, "hours", Number(e.target.value))} />
-                <Input type="number" min="0" step="0.01" value={labor.rate} onChange={(e) => updateLabor(i, "rate", Number(e.target.value))} />
+                <Input type="number" min="0" step="0.1" value={labor.hours} onChange={(e) => updateLabor(i, "hours", e.target.value)} />
+                <Input type="number" min="0" step="0.01" value={labor.rate} onChange={(e) => updateLabor(i, "rate", e.target.value)} />
                 <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">{formatCurrency(labor.total, currencyCode)}</div>
                 <Button type="button" variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-destructive" onClick={() => setLaborItems(laborItems.filter((_, j) => j !== i))}><Trash2 className="h-4 w-4" /></Button>
               </div>
@@ -458,10 +486,10 @@ export function QuotePageClient({
         {selectedVehicle && (
           <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
             <Car className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
-            <div className="min-w-0 flex-1 text-sm">
+            <Link href={`/vehicles/${selectedVehicle.id}`} target="_blank" className="min-w-0 flex-1 text-sm hover:underline">
               <span className="font-medium">{selectedVehicle.year} {selectedVehicle.make} {selectedVehicle.model}</span>
               {selectedVehicle.licensePlate && <span className="ml-1.5 text-muted-foreground">{selectedVehicle.licensePlate}</span>}
-            </div>
+            </Link>
             <Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground" onClick={() => setVehicleId("")}><X className="h-3 w-3" /></Button>
           </div>
         )}
@@ -480,10 +508,10 @@ export function QuotePageClient({
         {selectedCustomer && (
           <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
             <Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
-            <div className="min-w-0 flex-1 text-sm">
+            <Link href={`/customers/${selectedCustomer.id}`} target="_blank" className="min-w-0 flex-1 text-sm hover:underline">
               <span className="font-medium">{selectedCustomer.name}</span>
               {selectedCustomer.company && <span className="ml-1.5 text-muted-foreground">{selectedCustomer.company}</span>}
-            </div>
+            </Link>
             <Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground" onClick={() => setCustomerId("")}><X className="h-3 w-3" /></Button>
           </div>
         )}
@@ -584,7 +612,7 @@ export function QuotePageClient({
                 </SelectContent>
               </Select>
               {discountType !== "none" && (
-                <Input type="number" min="0" step="0.01" value={discountValue} onChange={(e) => setDiscountValue(Number(e.target.value))} className="h-7 w-20 text-right text-xs" />
+                <Input type="number" min="0" step="0.01" value={discountValue} onChange={(e) => setDiscountValue(e.target.value === "" ? 0 : Number(e.target.value))} className="h-7 w-20 text-right text-xs" />
               )}
               {discountType === "percentage" && <span className="text-muted-foreground">%</span>}
             </div>
@@ -594,7 +622,7 @@ export function QuotePageClient({
             <div className="flex items-center justify-between text-sm">
               <div className="flex items-center gap-2">
                 <span className="text-muted-foreground">Tax</span>
-                <Input type="number" min="0" step="0.1" value={taxRate} onChange={(e) => setTaxRate(Number(e.target.value))} className="h-7 w-20 text-right text-xs" />
+                <Input type="number" min="0" step="0.1" value={taxRate} onChange={(e) => setTaxRate(e.target.value === "" ? 0 : Number(e.target.value))} className="h-7 w-20 text-right text-xs" />
                 <span className="text-muted-foreground">%</span>
               </div>
               <span>{formatCurrency(taxAmount, currencyCode)}</span>
@@ -657,31 +685,82 @@ export function QuotePageClient({
         </div>
       </div>
 
-      {/* Form */}
-      <form id="quote-form" onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col overflow-hidden">
-        {isLarge ? (
-          <ResizablePanelGroup orientation="horizontal" className="flex-1 overflow-hidden">
-            <ResizablePanel defaultSize={75} minSize={40}>
-              <div className="h-full overflow-y-auto overscroll-contain p-4 pr-2">
-                <div className="space-y-3 pb-40">{leftColumn}</div>
-              </div>
-            </ResizablePanel>
-            <ResizableHandle withHandle />
-            <ResizablePanel defaultSize={25} minSize={15}>
-              <div className="h-full overflow-y-auto overscroll-contain p-4 pl-2">
-                <div className="space-y-3 pb-40">{rightColumn}</div>
+      {/* Tabs */}
+      <div className="shrink-0 border-b bg-background px-4">
+        <div className="flex gap-1">
+          {([
+            { key: "details" as TabType, label: "Details", icon: FileText },
+            { key: "images" as TabType, label: "Images", icon: Camera },
+            { key: "documents" as TabType, label: "Documents", icon: Paperclip },
+          ]).map(({ key, label, icon: Icon }) => (
+            <button
+              key={key}
+              type="button"
+              onClick={() => setActiveTab(key)}
+              className={`flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors ${
+                activeTab === key
+                  ? "border-primary text-primary"
+                  : "border-transparent text-muted-foreground hover:text-foreground"
+              }`}
+            >
+              <Icon className="h-3.5 w-3.5" />
+              {label}
+            </button>
+          ))}
+        </div>
+      </div>
+
+      {/* Tab Content */}
+      {activeTab === "details" && (
+        <form id="quote-form" onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col overflow-hidden">
+          {isLarge ? (
+            <ResizablePanelGroup orientation="horizontal" className="flex-1 overflow-hidden">
+              <ResizablePanel defaultSize={75} minSize={40}>
+                <div className="h-full overflow-y-auto overscroll-contain p-4 pr-2">
+                  <div className="space-y-3 pb-40">{leftColumn}</div>
+                </div>
+              </ResizablePanel>
+              <ResizableHandle withHandle />
+              <ResizablePanel defaultSize={25} minSize={15}>
+                <div className="h-full overflow-y-auto overscroll-contain p-4 pl-2">
+                  <div className="space-y-3 pb-40">{rightColumn}</div>
+                </div>
+              </ResizablePanel>
+            </ResizablePanelGroup>
+          ) : (
+            <div className="flex-1 overflow-y-auto overscroll-contain p-4">
+              <div className="space-y-3 pb-40">
+                {leftColumn}
+                {rightColumn}
               </div>
-            </ResizablePanel>
-          </ResizablePanelGroup>
-        ) : (
-          <div className="flex-1 overflow-y-auto overscroll-contain p-4">
-            <div className="space-y-3 pb-40">
-              {leftColumn}
-              {rightColumn}
             </div>
+          )}
+        </form>
+      )}
+
+      {activeTab === "images" && (
+        <div className="flex-1 overflow-y-auto overscroll-contain p-4">
+          <div className="mx-auto max-w-4xl pb-40">
+            <QuoteImagesManager
+              quoteId={quote.id}
+              initialImages={imageAttachments}
+              maxImages={maxImages}
+            />
           </div>
-        )}
-      </form>
+        </div>
+      )}
+
+      {activeTab === "documents" && (
+        <div className="flex-1 overflow-y-auto overscroll-contain p-4">
+          <div className="mx-auto max-w-4xl pb-40">
+            <QuoteDocumentsManager
+              quoteId={quote.id}
+              initialDocuments={documentAttachments}
+              maxDocuments={maxDocuments}
+            />
+          </div>
+        </div>
+      )}
 
       {/* Dialogs */}
       <SendEmailDialog

+ 14 - 2
src/features/quotes/Components/QuoteShareDialog.tsx

@@ -15,7 +15,9 @@ import { Check, Copy, Link2, Loader2, Mail, MessageSquare } from "lucide-react";
 import { toast } from "sonner";
 import { generateQuotePublicLink, revokeQuotePublicLink } from "@/features/quotes/Actions/quoteShareActions";
 import { sendQuoteEmail } from "@/features/email/Actions/emailActions";
-import { sendSmsToCustomer } from "@/features/sms/Actions/smsActions";
+import { sendSmsToCustomer, getSmsTemplates } from "@/features/sms/Actions/smsActions";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from "@/lib/sms-templates";
 
 interface QuoteShareDialogProps {
   open: boolean;
@@ -93,9 +95,19 @@ export function QuoteShareDialog({
     }
 
     if (notifySms && hasPhone) {
+      const tplResult = await getSmsTemplates();
+      const tplData = tplResult.success && tplResult.data ? tplResult.data : null;
+      const tpl = tplData?.templates[SETTING_KEYS.SMS_TEMPLATE_QUOTE_READY]
+        || SMS_TEMPLATE_DEFAULTS[SETTING_KEYS.SMS_TEMPLATE_QUOTE_READY];
+      const body = interpolateSmsTemplate(tpl || "", {
+        share_link: publicUrl,
+        customer_name: customer.name,
+        company_name: tplData?.companyName || "",
+        current_user: tplData?.currentUser || "",
+      });
       const res = await sendSmsToCustomer({
         customerId: customer.id,
-        body: `Your quote is ready for review. View it here: ${publicUrl}`,
+        body,
         relatedEntityType: "quote",
         relatedEntityId: quoteId,
       });

+ 11 - 0
src/features/quotes/Schema/quoteSchema.ts

@@ -15,6 +15,16 @@ export const quoteLaborSchema = z.object({
   total: z.coerce.number().min(0).default(0),
 });
 
+export const quoteAttachmentSchema = z.object({
+  fileName: z.string(),
+  fileUrl: z.string(),
+  fileType: z.string(),
+  fileSize: z.number(),
+  category: z.enum(["image", "document"]).default("image"),
+  description: z.string().optional(),
+  includeInInvoice: z.boolean().default(true),
+});
+
 export const createQuoteSchema = z.object({
   title: z.string().min(1, "Title is required"),
   description: z.string().optional(),
@@ -39,6 +49,7 @@ export const updateQuoteSchema = createQuoteSchema.partial().extend({
   id: z.string(),
 });
 
+export type QuoteAttachmentInput = z.infer<typeof quoteAttachmentSchema>;
 export type QuotePartInput = z.infer<typeof quotePartSchema>;
 export type QuoteLaborInput = z.infer<typeof quoteLaborSchema>;
 export type CreateQuoteInput = z.infer<typeof createQuoteSchema>;

+ 1 - 0
src/features/settings/Schema/settingsSchema.ts

@@ -62,6 +62,7 @@ export const SETTING_KEYS = {
   MAINTENANCE_APPROACHING_THRESHOLD: "maintenance.approachingThreshold",
   INVENTORY_MARKUP_MULTIPLIER: "inventory.markupMultiplier",
   SMS_TEMPLATE_INVOICE_READY: "sms.template.invoiceReady",
+  SMS_TEMPLATE_QUOTE_READY: "sms.template.quoteReady",
   SMS_TEMPLATE_INSPECTION_READY: "sms.template.inspectionReady",
   SMS_TEMPLATE_STATUS_IN_PROGRESS: "sms.template.statusInProgress",
   SMS_TEMPLATE_STATUS_WAITING_PARTS: "sms.template.statusWaitingParts",

+ 1 - 0
src/features/sms/Actions/smsActions.ts

@@ -168,6 +168,7 @@ export async function getSmsTemplates() {
     async ({ organizationId, userId }) => {
       const smsKeys = [
         SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY,
+        SETTING_KEYS.SMS_TEMPLATE_QUOTE_READY,
         SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY,
         SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS,
         SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS,

+ 2 - 0
src/features/vehicles/Actions/reminderActions.ts

@@ -38,7 +38,9 @@ export async function updateReminder(input: unknown) {
       where: { id },
       data: {
         ...data,
+        description: data.description !== undefined ? (data.description || null) : undefined,
         dueDate: data.dueDate !== undefined ? (data.dueDate ? new Date(data.dueDate) : null) : undefined,
+        dueMileage: data.dueMileage !== undefined ? (data.dueMileage ?? null) : undefined,
       },
     });
     revalidatePath(`/vehicles/${reminder.vehicleId}`);

+ 6 - 0
src/features/vehicles/Actions/serviceActions.ts

@@ -375,6 +375,12 @@ export async function updateServiceRecord(input: unknown) {
         where: { id },
         data: {
           ...recordData,
+          description: recordData.description !== undefined ? (recordData.description || null) : undefined,
+          techName: recordData.techName !== undefined ? (recordData.techName || null) : undefined,
+          diagnosticNotes: recordData.diagnosticNotes !== undefined ? (recordData.diagnosticNotes || null) : undefined,
+          invoiceNotes: recordData.invoiceNotes !== undefined ? (recordData.invoiceNotes || null) : undefined,
+          invoiceNumber: recordData.invoiceNumber !== undefined ? (recordData.invoiceNumber || null) : undefined,
+          mileage: recordData.mileage !== undefined ? (recordData.mileage ?? null) : undefined,
           serviceDate: recordData.serviceDate ? new Date(recordData.serviceDate) : undefined,
         },
       });

+ 6 - 0
src/features/vehicles/Actions/vehicleActions.ts

@@ -158,6 +158,12 @@ export async function updateVehicle(input: unknown) {
       where: { id, organizationId },
       data: {
         ...data,
+        vin: data.vin !== undefined ? (data.vin || null) : undefined,
+        licensePlate: data.licensePlate !== undefined ? (data.licensePlate || null) : undefined,
+        color: data.color !== undefined ? (data.color || null) : undefined,
+        fuelType: data.fuelType !== undefined ? (data.fuelType || null) : undefined,
+        transmission: data.transmission !== undefined ? (data.transmission || null) : undefined,
+        engineSize: data.engineSize !== undefined ? (data.engineSize || null) : undefined,
         purchaseDate: data.purchaseDate ? new Date(data.purchaseDate) : undefined,
         customerId: data.customerId !== undefined ? (data.customerId || null) : undefined,
       },

+ 2 - 2
src/features/vehicles/Components/service-edit/LaborEditor.tsx

@@ -68,14 +68,14 @@ export function LaborEditor({
                 min="0"
                 step="any"
                 value={labor.hours}
-                onChange={(e) => updateLabor(i, 'hours', Number(e.target.value))}
+                onChange={(e) => updateLabor(i, 'hours', e.target.value)}
               />
               <Input
                 type="number"
                 min="0"
                 step="0.01"
                 value={labor.rate}
-                onChange={(e) => updateLabor(i, 'rate', Number(e.target.value))}
+                onChange={(e) => updateLabor(i, 'rate', e.target.value)}
               />
               <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">
                 {formatCurrency(labor.total, currencyCode)}

+ 2 - 2
src/features/vehicles/Components/service-edit/PartsEditor.tsx

@@ -82,14 +82,14 @@ export function PartsEditor({
                 min="0"
                 step="0.01"
                 value={part.quantity}
-                onChange={(e) => updatePart(i, 'quantity', Number(e.target.value))}
+                onChange={(e) => updatePart(i, 'quantity', e.target.value)}
               />
               <Input
                 type="number"
                 min="0"
                 step="0.01"
                 value={part.unitPrice}
-                onChange={(e) => updatePart(i, 'unitPrice', Number(e.target.value))}
+                onChange={(e) => updatePart(i, 'unitPrice', e.target.value)}
               />
               <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">
                 {formatCurrency(part.total, currencyCode)}

+ 2 - 2
src/features/vehicles/Components/service-edit/TotalsSection.tsx

@@ -79,7 +79,7 @@ export function TotalsSection({
                 min="0"
                 step="0.01"
                 value={discountValue}
-                onChange={(e) => setDiscountValue(Number(e.target.value))}
+                onChange={(e) => setDiscountValue(e.target.value === "" ? 0 : Number(e.target.value))}
                 className="h-7 w-20 text-right text-xs"
               />
             )}
@@ -103,7 +103,7 @@ export function TotalsSection({
                 min="0"
                 step="0.1"
                 value={taxRate}
-                onChange={(e) => setTaxRate(Number(e.target.value))}
+                onChange={(e) => setTaxRate(e.target.value === "" ? 0 : Number(e.target.value))}
                 className="h-7 w-20 text-right text-xs"
               />
               <span className="text-muted-foreground">%</span>

+ 2 - 0
src/lib/sms-templates.ts

@@ -3,6 +3,8 @@ import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 export const SMS_TEMPLATE_DEFAULTS: Record<string, string> = {
   [SETTING_KEYS.SMS_TEMPLATE_INVOICE_READY]:
     "Your invoice is ready. View it here: {share_link}",
+  [SETTING_KEYS.SMS_TEMPLATE_QUOTE_READY]:
+    "Your quote is ready for review. View it here: {share_link}",
   [SETTING_KEYS.SMS_TEMPLATE_INSPECTION_READY]:
     "Your vehicle inspection report is ready. View it here: {share_link}",
   [SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS]: