فهرست منبع

add notification when user accept or request changes to a qoute (#2)

Bernt Christian Egeland 7 ماه پیش
والد
کامیت
d04251af15

+ 2 - 0
prisma/migrations/20260220174923_customer_quotation_interaction/migration.sql

@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "quotes" ADD COLUMN     "customerMessage" TEXT;

+ 1 - 0
prisma/schema.prisma

@@ -377,6 +377,7 @@ model Quote {
   totalAmount    Float     @default(0)
   notes          String?
   publicToken    String?
+  customerMessage String?
   convertedToId  String?
   createdAt      DateTime  @default(now())
   updatedAt      DateTime  @updatedAt

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

@@ -17,6 +17,7 @@ import {
 import { statusColors } from "@/lib/table-utils";
 import {
   AlertTriangle,
+  ArrowRight,
   Bell,
   Check,
   ClipboardCheck,
@@ -32,6 +33,8 @@ import {
 import { formatCurrency } from "@/lib/format";
 import { dismissMaintenance } from "@/features/vehicles/Actions/dismissMaintenance";
 import { updateQuoteRequestStatus } from "@/features/inspections/Actions/quoteRequestActions";
+import { acknowledgeQuoteResponse } from "@/features/quotes/Actions/quoteResponseActions";
+import { convertQuoteToServiceRecord } from "@/features/quotes/Actions/quoteActions";
 
 interface ServiceItem {
   id: string;
@@ -125,6 +128,19 @@ interface DashboardQuoteRequest {
   };
 }
 
+interface DashboardQuoteResponse {
+  id: string;
+  title: string;
+  quoteNumber: string | null;
+  status: string;
+  customerMessage: string | null;
+  totalAmount: number;
+  updatedAt: Date;
+  vehicleId: string | null;
+  customer: { name: string } | null;
+  vehicle: { id: string; make: string; model: string; year: number } | null;
+}
+
 export function DashboardClient({
   stats,
   currencyCode = "USD",
@@ -134,6 +150,7 @@ export function DashboardClient({
   inProgressInspections = [],
   completedInspections = [],
   quoteRequests = [],
+  quoteResponses = [],
 }: {
   stats: DashboardStats;
   currencyCode?: string;
@@ -143,6 +160,7 @@ export function DashboardClient({
   inProgressInspections?: DashboardInspection[];
   completedInspections?: DashboardInspection[];
   quoteRequests?: DashboardQuoteRequest[];
+  quoteResponses?: DashboardQuoteResponse[];
 }) {
   const distUnit = unitSystem === "metric" ? "km" : "mi";
   const router = useRouter();
@@ -532,6 +550,106 @@ export function DashboardClient({
         </Card>
       )}
 
+      {/* Customer Quote Responses */}
+      {quoteResponses.length > 0 && (
+        <Card className="border-0 shadow-sm">
+          <CardHeader className="pb-3">
+            <CardTitle className="flex items-center gap-2 text-base">
+              <FileText className="h-4 w-4" />
+              Customer Quote Responses
+            </CardTitle>
+            <p className="text-xs text-muted-foreground">
+              Quotes that customers have accepted or requested changes on
+            </p>
+          </CardHeader>
+          <CardContent className="p-0">
+            <div className="divide-y">
+              {quoteResponses.map((resp) => (
+                <div
+                  key={resp.id}
+                  className="flex items-center justify-between px-5 py-3"
+                >
+                  <div
+                    className="flex items-center gap-3 min-w-0 cursor-pointer hover:opacity-80"
+                    onClick={() => router.push(`/quotes/${resp.id}`)}
+                  >
+                    <div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
+                      resp.status === "accepted" ? "bg-emerald-500/10" : "bg-orange-500/10"
+                    }`}>
+                      {resp.status === "accepted" ? (
+                        <Check className="h-4 w-4 text-emerald-500" />
+                      ) : (
+                        <FileText className="h-4 w-4 text-orange-500" />
+                      )}
+                    </div>
+                    <div className="min-w-0">
+                      <p className="font-medium text-sm truncate">
+                        {resp.title}
+                        {resp.quoteNumber && <span className="ml-1.5 text-muted-foreground font-normal">({resp.quoteNumber})</span>}
+                      </p>
+                      <p className="text-xs text-muted-foreground truncate">
+                        {resp.customer?.name && `${resp.customer.name} · `}
+                        {resp.status === "accepted" ? "Accepted" : "Changes requested"}
+                        {resp.customerMessage && ` · "${resp.customerMessage}"`}
+                      </p>
+                    </div>
+                  </div>
+                  <div className="shrink-0 ml-3 flex items-center gap-1.5">
+                    {resp.status === "accepted" ? (
+                      <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-500/20 text-[10px]">
+                        Accepted
+                      </Badge>
+                    ) : (
+                      <Badge className="bg-orange-500/10 text-orange-600 border-orange-500/20 text-[10px]">
+                        Changes Requested
+                      </Badge>
+                    )}
+                    {resp.status === "accepted" && resp.vehicleId && (
+                      <Button
+                        variant="outline"
+                        size="sm"
+                        className="h-7 text-xs"
+                        onClick={() => {
+                          startTransition(async () => {
+                            const result = await convertQuoteToServiceRecord(resp.id, resp.vehicleId!);
+                            if (result.success && result.data) {
+                              router.push(`/vehicles/${resp.vehicleId}/service/${result.data.id}`);
+                            }
+                          });
+                        }}
+                      >
+                        <ArrowRight className="mr-1 h-3 w-3" />
+                        Convert to Work Order
+                      </Button>
+                    )}
+                    <Button
+                      variant="outline"
+                      size="sm"
+                      className="h-7 text-xs"
+                      onClick={() => router.push(`/quotes/${resp.id}`)}
+                    >
+                      View Quote
+                    </Button>
+                    <Button
+                      variant="ghost"
+                      size="icon"
+                      className="h-6 w-6 text-muted-foreground hover:text-foreground"
+                      onClick={() => {
+                        startTransition(async () => {
+                          await acknowledgeQuoteResponse(resp.id);
+                        });
+                      }}
+                    >
+                      <X className="h-3.5 w-3.5" />
+                    </Button>
+                  </div>
+                </div>
+              ))}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
       {/* Recent Completed table */}
       <Card className="border-0 shadow-sm">
         <CardHeader className="pb-3">

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

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

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

@@ -29,14 +29,14 @@ export default async function QuoteDetailPage({
 
   if (!result.success || !result.data) {
     return (
-      <>
+      <div className="flex h-svh flex-col overflow-hidden">
         <PageHeader />
         <div className="flex h-[50vh] items-center justify-center">
           <p className="text-muted-foreground">
             {result.error || "Quote not found"}
           </p>
         </div>
-      </>
+      </div>
     );
   }
 
@@ -60,7 +60,7 @@ export default async function QuoteDetailPage({
   const organizationId = authContext?.organizationId || "";
 
   return (
-    <>
+    <div className="flex h-svh flex-col overflow-hidden">
       <PageHeader />
       <QuotePageClient
         quote={result.data}
@@ -105,6 +105,6 @@ export default async function QuoteDetailPage({
           })
         )}
       />
-    </>
+    </div>
   );
 }

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

@@ -1,6 +1,6 @@
 "use client";
 
-import { Download, Loader2 } from "lucide-react";
+import { Check, Download, Loader2, MessageSquare } from "lucide-react";
 import { useState } from "react";
 import { formatCurrency, formatDate as fmtDate, DEFAULT_DATE_FORMAT } from "@/lib/format";
 import { sanitizeHtml } from "@/lib/sanitize-html";
@@ -57,6 +57,7 @@ const statusLabels: Record<string, string> = {
   rejected: "Rejected",
   expired: "Expired",
   converted: "Converted",
+  changes_requested: "Changes Requested",
 };
 
 export function QuoteView({
@@ -85,6 +86,10 @@ export function QuoteView({
   headerStyle?: string;
 }) {
   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 quoteNum = quote.quoteNumber || `QT-${quote.id.slice(-8).toUpperCase()}`;
   const df = dateFormat || DEFAULT_DATE_FORMAT;
@@ -111,6 +116,28 @@ export function QuoteView({
     setDownloading(false);
   };
 
+  const handleQuoteResponse = async (action: "accepted" | "changes_requested", message?: string) => {
+    setSubmitting(true);
+    try {
+      const res = await fetch("/api/public/quote-response", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ quoteId: quote.id, publicToken: token, action, message }),
+      });
+      const data = await res.json();
+      if (data.success) {
+        setStatus(action);
+        setShowChangesForm(false);
+        setChangeMessage("");
+      }
+    } catch {
+      // silent
+    }
+    setSubmitting(false);
+  };
+
+  const canRespond = status === "draft" || status === "sent";
+
   return (
     <div className="mx-auto max-w-3xl p-4 sm:p-8">
       <div className="mb-6 flex items-center justify-between">
@@ -147,13 +174,15 @@ export function QuoteView({
               <div className="flex items-center gap-3">
                 <h3 className="text-xl font-bold">QUOTE</h3>
                 <span className={`inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${
-                  quote.status === "accepted"
+                  status === "accepted"
                     ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
-                    : quote.status === "rejected"
+                    : status === "rejected"
                     ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
+                    : status === "changes_requested"
+                    ? "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
                     : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
                 }`}>
-                  {statusLabels[quote.status] || quote.status}
+                  {statusLabels[status] || status}
                 </span>
               </div>
               <div className="flex gap-3 text-sm text-gray-500">
@@ -184,13 +213,15 @@ export function QuoteView({
                 <p className="text-sm text-gray-500">Valid until: {validUntilDate}</p>
               )}
               <span className={`mt-1 inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${
-                quote.status === "accepted"
+                status === "accepted"
                   ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
-                  : quote.status === "rejected"
+                  : status === "rejected"
                   ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
+                  : status === "changes_requested"
+                  ? "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
                   : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
               }`}>
-                {statusLabels[quote.status] || quote.status}
+                {statusLabels[status] || status}
               </span>
             </div>
           </div>
@@ -222,13 +253,15 @@ export function QuoteView({
                 <p className="text-sm text-gray-500">Valid until: {validUntilDate}</p>
               )}
               <span className={`mt-2 inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${
-                quote.status === "accepted"
+                status === "accepted"
                   ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
-                  : quote.status === "rejected"
+                  : status === "rejected"
                   ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
+                  : status === "changes_requested"
+                  ? "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
                   : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
               }`}>
-                {statusLabels[quote.status] || quote.status}
+                {statusLabels[status] || status}
               </span>
             </div>
           </div>
@@ -361,6 +394,86 @@ export function QuoteView({
           </div>
         </div>
 
+        {/* Customer Actions */}
+        {canRespond && !showChangesForm && (
+          <div className="mt-8 flex flex-col gap-3 sm:flex-row sm:justify-end">
+            <button
+              onClick={() => setShowChangesForm(true)}
+              disabled={submitting}
+              className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-300 px-5 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
+            >
+              <MessageSquare className="h-4 w-4" />
+              Request Changes
+            </button>
+            <button
+              onClick={() => handleQuoteResponse("accepted")}
+              disabled={submitting}
+              className="inline-flex items-center justify-center gap-2 rounded-lg px-5 py-2.5 text-sm font-medium text-white disabled:opacity-50"
+              style={{ backgroundColor: primaryColor }}
+            >
+              {submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
+              Accept Quote
+            </button>
+          </div>
+        )}
+
+        {canRespond && showChangesForm && (
+          <div className="mt-8 rounded-lg border border-gray-200 p-4 dark:border-gray-700">
+            <label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
+              What changes would you like?
+            </label>
+            <textarea
+              value={changeMessage}
+              onChange={(e) => setChangeMessage(e.target.value)}
+              rows={3}
+              className="w-full rounded-lg border border-gray-300 p-3 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
+              placeholder="Describe the changes you'd like..."
+            />
+            <div className="mt-3 flex gap-3 justify-end">
+              <button
+                onClick={() => { setShowChangesForm(false); setChangeMessage(""); }}
+                disabled={submitting}
+                className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
+              >
+                Cancel
+              </button>
+              <button
+                onClick={() => handleQuoteResponse("changes_requested", changeMessage)}
+                disabled={submitting || !changeMessage.trim()}
+                className="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
+                style={{ backgroundColor: primaryColor }}
+              >
+                {submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <MessageSquare className="h-4 w-4" />}
+                Submit Request
+              </button>
+            </div>
+          </div>
+        )}
+
+        {status === "accepted" && (
+          <div className="mt-8 rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-800 dark:bg-emerald-900/20">
+            <div className="flex items-center gap-2">
+              <Check className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
+              <p className="font-medium text-emerald-700 dark:text-emerald-400">Quote Accepted</p>
+            </div>
+            <p className="mt-1 text-sm text-emerald-600 dark:text-emerald-500">
+              Thank you! This quote has been accepted. The workshop will be in touch shortly.
+            </p>
+          </div>
+        )}
+
+        {status === "changes_requested" && (
+          <div className="mt-8 rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-900/20">
+            <div className="flex items-center gap-2">
+              <MessageSquare className="h-5 w-5 text-orange-600 dark:text-orange-400" />
+              <p className="font-medium text-orange-700 dark:text-orange-400">Changes Requested</p>
+            </div>
+            <p className="mt-1 text-sm text-orange-600 dark:text-orange-500">
+              Your change request has been submitted. The workshop will review and get back to you.
+            </p>
+          </div>
+        )}
+
         {/* Torqvoice branding near totals */}
         {showTorqvoiceBranding && (
           <div className="mt-3 flex items-center justify-end gap-1.5">

+ 13 - 0
src/app/api/public/quote-response/route.ts

@@ -0,0 +1,13 @@
+import { NextResponse } from "next/server";
+import { respondToQuote } from "@/features/quotes/Actions/quoteResponseActions";
+
+export async function POST(request: Request) {
+  try {
+    const body = await request.json();
+    const result = await respondToQuote(body);
+    return NextResponse.json(result, { status: result.success ? 200 : 400 });
+  } catch (error) {
+    const message = error instanceof Error ? error.message : "Failed to process quote response";
+    return NextResponse.json({ success: false, error: message }, { status: 400 });
+  }
+}

+ 96 - 0
src/features/quotes/Actions/quoteResponseActions.ts

@@ -0,0 +1,96 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { z } from "zod";
+
+const respondToQuoteSchema = z.object({
+  quoteId: z.string().min(1),
+  publicToken: z.string().min(1),
+  action: z.enum(["accepted", "changes_requested"]),
+  message: z.string().optional(),
+});
+
+/**
+ * Public action — no auth required.
+ * Customer accepts or requests changes on a shared quote page.
+ */
+export async function respondToQuote(input: unknown) {
+  const data = respondToQuoteSchema.parse(input);
+
+  // Verify the quote exists and the token matches
+  const quote = await db.quote.findFirst({
+    where: { id: data.quoteId, publicToken: data.publicToken },
+  });
+  if (!quote) {
+    return { success: false, error: "Quote not found" };
+  }
+
+  // Only allow response on quotes that are draft or sent
+  if (!["draft", "sent"].includes(quote.status)) {
+    return { success: false, error: "This quote can no longer be updated" };
+  }
+
+  await db.quote.update({
+    where: { id: data.quoteId },
+    data: {
+      status: data.action,
+      customerMessage: data.message || null,
+    },
+  });
+
+  return { success: true };
+}
+
+/**
+ * Authenticated — fetch quotes with customer responses (accepted / changes_requested).
+ */
+export async function getQuoteResponses() {
+  return withAuth(async ({ organizationId }) => {
+    const quotes = await db.quote.findMany({
+      where: {
+        organizationId,
+        status: { in: ["accepted", "changes_requested"] },
+      },
+      select: {
+        id: true,
+        title: true,
+        quoteNumber: true,
+        status: true,
+        customerMessage: true,
+        totalAmount: true,
+        updatedAt: true,
+        vehicleId: true,
+        customer: {
+          select: { name: true },
+        },
+        vehicle: {
+          select: { id: true, make: true, model: true, year: true },
+        },
+      },
+      orderBy: { updatedAt: "desc" },
+      take: 20,
+    });
+    return quotes;
+  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.QUOTES }] });
+}
+
+/**
+ * Authenticated — acknowledge a customer response by setting the quote back to a working status.
+ */
+export async function acknowledgeQuoteResponse(quoteId: string) {
+  return withAuth(async ({ organizationId }) => {
+    const quote = await db.quote.findFirst({
+      where: { id: quoteId, organizationId },
+    });
+    if (!quote) throw new Error("Quote not found");
+
+    await db.quote.update({
+      where: { id: quoteId },
+      data: { status: "draft", customerMessage: null },
+    });
+
+    return { success: true };
+  }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.QUOTES }] });
+}

+ 68 - 8
src/features/quotes/Components/QuotePageClient.tsx

@@ -30,6 +30,7 @@ import {
 import { useGlassModal } from "@/components/glass-modal";
 import { useConfirm } from "@/components/confirm-dialog";
 import { updateQuote, deleteQuote, convertQuoteToServiceRecord } from "@/features/quotes/Actions/quoteActions";
+import { acknowledgeQuoteResponse } from "@/features/quotes/Actions/quoteResponseActions";
 import { sendQuoteEmail } from "@/features/email/Actions/emailActions";
 import { SendEmailDialog } from "@/features/email/Components/SendEmailDialog";
 import { QuoteShareDialog } from "@/features/quotes/Components/QuoteShareDialog";
@@ -39,12 +40,14 @@ import {
   ArrowLeft,
   ArrowRight,
   Car,
+  Check,
   ClipboardCheck,
   Download,
   FileText,
   Globe,
   Loader2,
   Mail,
+  MessageSquare,
   Plus,
   Save,
   Trash2,
@@ -60,6 +63,7 @@ const statusColors: Record<string, string> = {
   rejected: "bg-red-500/10 text-red-500 border-red-500/20",
   expired: "bg-amber-500/10 text-amber-600 border-amber-500/20",
   converted: "bg-purple-500/10 text-purple-500 border-purple-500/20",
+  changes_requested: "bg-orange-500/10 text-orange-600 border-orange-500/20",
 };
 
 interface CustomerOption {
@@ -94,10 +98,12 @@ interface QuoteRecord {
   discountAmount: number;
   totalAmount: number;
   notes: string | null;
+  customerMessage: string | null;
   publicToken: string | null;
   convertedToId: string | null;
   inspectionId: string | null;
   createdAt: Date;
+  updatedAt: Date;
   partItems: { id: string; partNumber: string | null; name: string; quantity: number; unitPrice: number; total: number }[];
   laborItems: { id: string; description: string; hours: number; rate: number; total: number }[];
   customer: { id: string; name: string; email: string | null; phone: string | null; address: string | null; company: string | null } | null;
@@ -310,6 +316,19 @@ export function QuotePageClient({
     setConverting(false);
   };
 
+  const [resolving, setResolving] = useState(false);
+
+  const handleResolveResponse = async () => {
+    setResolving(true);
+    const result = await acknowledgeQuoteResponse(quote.id);
+    if (result.success) {
+      setStatus("draft");
+      toast.success("Customer response resolved");
+      router.refresh();
+    }
+    setResolving(false);
+  };
+
   // --- Left column: Parts, Labor, Notes ---
   const leftColumn = (
     <div className="space-y-3">
@@ -409,6 +428,15 @@ export function QuotePageClient({
   // --- Right column: Vehicle & Customer, Quote Details, Totals ---
   const rightColumn = (
     <div className="space-y-3">
+      {/* Convert to Work Order */}
+      {quote.status !== "converted" && (
+        <div className="rounded-lg border p-3">
+          <Button type="button" variant="outline" size="sm" className="w-full" onClick={() => setShowConvertDialog(true)}>
+            <ArrowRight className="mr-1 h-3.5 w-3.5" /> Convert to Work Order
+          </Button>
+        </div>
+      )}
+
       {/* Vehicle & Customer */}
       <div className="rounded-lg border p-3 space-y-3">
         <div className="space-y-1">
@@ -493,6 +521,46 @@ export function QuotePageClient({
         )}
       </div>
 
+      {/* Customer Response */}
+      {quote.customerMessage && (status === "changes_requested" || status === "accepted") && (
+        <div className={`rounded-lg border p-3 space-y-2 ${
+          status === "changes_requested"
+            ? "border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20"
+            : "border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-900/20"
+        }`}>
+          <div className="flex items-center justify-between">
+            <h3 className={`flex items-center gap-1.5 text-sm font-semibold ${
+              status === "changes_requested" ? "text-orange-700 dark:text-orange-400" : "text-emerald-700 dark:text-emerald-400"
+            }`}>
+              <MessageSquare className="h-3.5 w-3.5" />
+              {status === "changes_requested" ? "Changes Requested" : "Quote Accepted"}
+            </h3>
+            <span className={`text-[10px] ${
+              status === "changes_requested" ? "text-orange-500 dark:text-orange-500" : "text-emerald-500 dark:text-emerald-500"
+            }`}>
+              {new Date(quote.updatedAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}{" "}
+              {new Date(quote.updatedAt).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}
+            </span>
+          </div>
+          <p className={`text-sm ${
+            status === "changes_requested" ? "text-orange-600 dark:text-orange-400" : "text-emerald-600 dark:text-emerald-400"
+          }`}>
+            &ldquo;{quote.customerMessage}&rdquo;
+          </p>
+          <Button
+            type="button"
+            variant="outline"
+            size="sm"
+            className="w-full"
+            disabled={resolving}
+            onClick={handleResolveResponse}
+          >
+            {resolving ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Check className="mr-1 h-3.5 w-3.5" />}
+            Mark as Resolved
+          </Button>
+        </div>
+      )}
+
       {/* Totals */}
       <div className="rounded-lg border p-3 space-y-2">
         <h3 className="text-sm font-semibold">Totals</h3>
@@ -532,14 +600,6 @@ export function QuotePageClient({
         </div>
       </div>
 
-      {/* Convert to Work Order */}
-      {quote.status !== "converted" && (
-        <div className="rounded-lg border p-3">
-          <Button type="button" variant="outline" size="sm" className="w-full" onClick={() => setShowConvertDialog(true)}>
-            <ArrowRight className="mr-1 h-3.5 w-3.5" /> Convert to Work Order
-          </Button>
-        </div>
-      )}
     </div>
   );
 

+ 1 - 1
src/features/quotes/Schema/quoteSchema.ts

@@ -18,7 +18,7 @@ export const quoteLaborSchema = z.object({
 export const createQuoteSchema = z.object({
   title: z.string().min(1, "Title is required"),
   description: z.string().optional(),
-  status: z.enum(["draft", "sent", "accepted", "rejected", "expired", "converted"]).default("draft"),
+  status: z.enum(["draft", "sent", "accepted", "rejected", "expired", "converted", "changes_requested"]).default("draft"),
   validUntil: z.string().optional(),
   customerId: z.string().optional(),
   vehicleId: z.string().optional(),