Explorar el Código

Let a locked quote be converted to a work order (#397)

* fixed locked qoute

* fix sent qoutes
Bernt Christian Egeland hace 2 semanas
padre
commit
8a27bca7ea

+ 194 - 0
e2e/specs/quotes/lock.spec.ts

@@ -0,0 +1,194 @@
+import { expect, type Page, test } from '@playwright/test'
+import { settle } from '../../support/hydration'
+import { clearMailbox, waitForMail } from '../../support/mail'
+import { addQuotePart, newQuote, quoteShareLink, saveQuote } from '../../support/quote'
+import { setQuoteLock } from '../../support/settings'
+
+/**
+ * A locked quote still becomes a work order.
+ *
+ * The lock freezes what a quote says it costs, and nothing else: converting it,
+ * copying its link and sending it are not edits. The page used to disable the
+ * whole details tab through one `<fieldset disabled>`, and a disabled fieldset
+ * disables every button inside it too, so "Convert to Work Order" went grey
+ * the moment a customer accepted. A workshop reported it as a glitch: the
+ * quote they had just been told was accepted could not be turned into the job.
+ *
+ * The same report turned up two more things a locked quote has to allow: a
+ * status that keeps it locked (a quote accepted over the phone), and dismissing
+ * the customer's acceptance from the dashboard. Dismissing used to put the
+ * quote back to draft, which erased the acceptance and released the lock, and
+ * the row stayed on screen until the page was reloaded.
+ *
+ * Both triggers are walked, because they lock at different moments and the
+ * button has to survive each of them. The quote setting is off by default and
+ * is put back afterwards.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const CUSTOMER = `e2e-quote-lock-${stamp}@example.com`
+/** The seeded Camry belongs to James Mitchell, who is who the Share dialog notifies. */
+const SEEDED_CUSTOMER_EMAIL = 'james.mitchell@gmail.com'
+
+/** The editor's price fields are the part the lock is for, so they have to be frozen. */
+async function expectPricesFrozen(page: Page, banner: string) {
+  await expect(page.getByText(banner, { exact: true })).toBeVisible()
+  await expect(page.getByPlaceholder('Name *').first()).toBeDisabled()
+}
+
+/** Converts the open quote from its own page and checks the job carries its line. */
+async function convertToWorkOrder(page: Page, quoteUrl: string, partName: string) {
+  const convert = page.getByRole('button', { name: 'Convert to Work Order', exact: true })
+  await expect(convert).toBeEnabled()
+
+  const dialog = page.getByRole('dialog', { name: 'Convert Quote to Work Order' })
+  await expect(async () => {
+    await convert.click()
+    await expect(dialog).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+
+  // The quote's own vehicle is chosen already, so Convert is ready to press.
+  const confirm = dialog.getByRole('button', { name: 'Convert', exact: true })
+  await expect(confirm).toBeEnabled()
+  await confirm.click()
+
+  await page.waitForURL(/\/vehicles\/[^/]+\/service\/[^/]+$/, { timeout: 60_000 })
+  await settle(page)
+  await expect(page.getByPlaceholder('Name *').first()).toHaveValue(partName)
+
+  // Back on the quote, it is marked converted and offers no second conversion.
+  await page.goto(quoteUrl)
+  await settle(page)
+  await expect(page.getByText('converted', { exact: true }).first()).toBeVisible()
+  await expect(
+    page.getByRole('button', { name: 'Convert to Work Order', exact: true })
+  ).toHaveCount(0)
+}
+
+test.afterAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  await setQuoteLock(page, { enabled: false, trigger: 'accepted' })
+  await page.close()
+})
+
+test.describe('a locked quote can still become a work order', () => {
+  test('locked once sent: emailed to the customer, then converted', async ({ page }) => {
+    await setQuoteLock(page, { enabled: true, trigger: 'sent' })
+
+    const part = `E2E lock sent part ${stamp}`
+    const quoteUrl = await newQuote(page, `E2E locked when sent ${stamp}`)
+    await addQuotePart(page, { name: part, quantity: 1, unitPrice: 500 })
+    await saveQuote(page)
+
+    // Sent the way the workshop in the report sent theirs.
+    await clearMailbox()
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Email', exact: true }).click()
+      await expect(page.locator('#email')).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.locator('#email').fill(CUSTOMER)
+    await page.getByRole('button', { name: 'Send Email', exact: true }).click()
+    await waitForMail(CUSTOMER, { timeout: 30_000 })
+
+    await page.goto(quoteUrl)
+    await settle(page)
+    await expectPricesFrozen(page, 'This quote is locked because it has been sent')
+
+    // The customer says yes on the phone. A status that keeps the quote locked
+    // is not an edit, and one that would release the lock is not offered.
+    const status = page.getByRole('combobox', { name: 'Status' })
+    await expect(status).toBeEnabled()
+    await expect(async () => {
+      await status.click()
+      await expect(page.getByRole('option', { name: 'Accepted' })).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await expect(page.getByRole('option', { name: 'Draft' })).toHaveAttribute(
+      'aria-disabled',
+      'true'
+    )
+    await page.getByRole('option', { name: 'Accepted' }).click()
+    await expect(status).toContainText('Accepted')
+    // Disabled while the change is written, so enabled again means it landed.
+    await expect(status).toBeEnabled()
+
+    await page.reload()
+    await settle(page)
+    await expect(page.getByRole('combobox', { name: 'Status' })).toContainText('Accepted')
+    await expectPricesFrozen(page, 'This quote is locked because it has been sent')
+
+    await convertToWorkOrder(page, quoteUrl, part)
+  })
+
+  test('locked once accepted: the customer accepts by link, then converted', async ({ page }) => {
+    await setQuoteLock(page, { enabled: true, trigger: 'accepted' })
+
+    const part = `E2E lock accepted part ${stamp}`
+    const title = `E2E locked when accepted ${stamp}`
+    const quoteUrl = await newQuote(page, title)
+    await addQuotePart(page, { name: part, quantity: 1, unitPrice: 700 })
+    await saveQuote(page)
+    const shareUrl = await quoteShareLink(page)
+
+    // Telling the customer from the Share dialog closes it once the mail has
+    // gone, instead of leaving a finished dialog on screen.
+    await clearMailbox()
+    const shareDialog = page.getByRole('dialog', { name: 'Share Quote' })
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Share', exact: true }).click()
+      await expect(shareDialog).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await shareDialog.locator('#notify-email-quote').click()
+    await shareDialog.getByRole('button', { name: 'Send Notification', exact: true }).click()
+    await expect(shareDialog).toBeHidden({ timeout: 30_000 })
+    await waitForMail(SEEDED_CUSTOMER_EMAIL, { timeout: 30_000 })
+
+    // Sharing sends it, and under this trigger a sent quote is still open.
+    await page.goto(quoteUrl)
+    await settle(page)
+    await expect(page.getByPlaceholder('Name *').first()).toBeEnabled()
+
+    // Signed out, the way a customer opens the link in their mail.
+    const customer = await page
+      .context()
+      .browser()!
+      .newContext({
+        storageState: { cookies: [], origins: [] },
+      })
+    const customerPage = await customer.newPage()
+    await customerPage.goto(shareUrl)
+    await expect(async () => {
+      await customerPage.getByRole('button', { name: 'Accept Quote' }).click()
+      await expect(customerPage.getByText('Quote Accepted')).toBeVisible({ timeout: 3_000 })
+    }).toPass({ timeout: 30_000 })
+    await customer.close()
+
+    await page.goto(quoteUrl)
+    await settle(page)
+    await expectPricesFrozen(page, 'This quote is locked because it has been accepted')
+
+    // Copying the link is not an edit either.
+    await expect(page.getByRole('button', { name: 'Copy link', exact: true })).toBeEnabled()
+
+    // Dismissed from the dashboard, the response leaves the list without a
+    // reload and stays gone, while the quote keeps the acceptance and its lock.
+    await page.goto('/')
+    await settle(page)
+    const row = page.getByTestId('quote-response-row').filter({ hasText: title })
+    await expect(row).toBeVisible()
+    await expect(async () => {
+      await row.getByRole('button', { name: 'Dismiss response' }).click()
+      await expect(row).toHaveCount(0, { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.reload()
+    await settle(page)
+    await expect(page.getByTestId('quote-response-row').filter({ hasText: title })).toHaveCount(0)
+
+    await page.goto(quoteUrl)
+    await settle(page)
+    await expectPricesFrozen(page, 'This quote is locked because it has been accepted')
+
+    await convertToWorkOrder(page, quoteUrl, part)
+  })
+})

+ 36 - 0
e2e/support/settings.ts

@@ -93,6 +93,42 @@ export async function setInvoiceNumbering(
   await expect(page.getByText('Invoice settings saved', { exact: true })).toBeVisible()
 }
 
+export interface QuoteLockSetup {
+  enabled: boolean
+  /** When a quote stops being editable: once it goes to the customer, or once they accept. */
+  trigger: 'sent' | 'accepted'
+}
+
+/**
+ * Settings → Invoice, the quote half of "Locking finished documents", saved.
+ * Off by default, so a spec that turns it on turns it off again.
+ */
+export async function setQuoteLock(
+  page: Page,
+  { enabled, trigger }: QuoteLockSetup
+): Promise<void> {
+  await page.goto('/settings/invoice')
+  await settle(page)
+
+  // The trigger select is disabled while the switch is off, so the switch goes
+  // on first and is set to what was asked for after the trigger is chosen.
+  const toggle = page.locator('#quoteLockEnabled')
+  await setSwitch(toggle, true)
+
+  const wanted = trigger === 'sent' ? 'Once the quote is sent' : 'Once the quote is accepted'
+  const select = page.locator('#quoteLockTrigger')
+  await expect(async () => {
+    await select.click()
+    await expect(page.getByRole('option', { name: wanted })).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await page.getByRole('option', { name: wanted }).click()
+  await expect(select).toContainText(wanted)
+
+  await setSwitch(toggle, enabled)
+  await page.getByRole('button', { name: 'Save Invoice Settings', exact: true }).click()
+  await expect(page.getByText('Invoice settings saved', { exact: true })).toBeVisible()
+}
+
 /** What the settings page currently offers as the next invoice number. */
 export async function invoiceStartNumber(page: Page): Promise<string> {
   await page.goto('/settings/invoice')

+ 2 - 0
prisma/migrations/20260915141516_quote_response_dismissed_at/migration.sql

@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "quotes" ADD COLUMN     "responseDismissedAt" TIMESTAMP(3);

+ 7 - 0
prisma/schema/quotes.prisma

@@ -31,6 +31,13 @@ model Quote {
   /// not change it.
   sentAt DateTime?
 
+  /// When the workshop dismissed the customer's answer from its list of
+  /// responses. An acceptance keeps its status when dismissed, because it is
+  /// the customer's agreement and may be what holds the quote locked, so the
+  /// list needs its own record of it. Cleared whenever the customer answers
+  /// again.
+  responseDismissedAt DateTime?
+
   /// See ServiceRecord.editUnlockedAt.
   editUnlockedAt   DateTime?
   editUnlockedById String?

+ 6 - 2
src/__tests__/features/public/respondToQuote.test.ts

@@ -106,7 +106,7 @@ describe('respondToQuote', () => {
     expect(result).toEqual({ success: true })
     expect(mockUpdate).toHaveBeenCalledWith({
       where: { id: 'quote-1' },
-      data: { status: 'accepted', customerMessage: null },
+      data: { status: 'accepted', customerMessage: null, responseDismissedAt: null },
     })
   })
 
@@ -131,7 +131,11 @@ describe('respondToQuote', () => {
     expect(result).toEqual({ success: true })
     expect(mockUpdate).toHaveBeenCalledWith({
       where: { id: 'quote-1' },
-      data: { status: 'changes_requested', customerMessage: 'Please add an extra oil filter' },
+      data: {
+        status: 'changes_requested',
+        customerMessage: 'Please add an extra oil filter',
+        responseDismissedAt: null,
+      },
     })
   })
 

+ 107 - 0
src/__tests__/features/quotes/acknowledge-quote-response.test.ts

@@ -0,0 +1,107 @@
+/**
+ * Tests for acknowledgeQuoteResponse — the workshop dealing with a customer's
+ * answer, from the dashboard's dismiss button or the quote's "Mark as Resolved".
+ *
+ * It used to put every quote back to draft. For an acceptance that erased the
+ * customer's agreement, and where accepted quotes lock it released the lock
+ * without an owner or admin. A change request still goes back to draft; an
+ * acceptance only leaves the list of responses.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    quote: { findFirst: vi.fn(), findMany: vi.fn(), update: vi.fn() },
+  },
+}))
+
+import { revalidatePath } from 'next/cache'
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import {
+  acknowledgeQuoteResponse,
+  getQuoteResponses,
+} from '@/features/quotes/Actions/quoteResponseActions'
+
+const ORG = 'org-1'
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'user-1', email: 'user@example.com' },
+  } as any)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as any)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as any)
+  vi.mocked(db.quote.update).mockResolvedValue({} as any)
+  vi.mocked(db.quote.findMany).mockResolvedValue([])
+})
+
+describe('acknowledgeQuoteResponse', () => {
+  it('keeps an accepted quote accepted and only dismisses the response', async () => {
+    vi.mocked(db.quote.findFirst).mockResolvedValue({ id: 'q1', status: 'accepted' } as any)
+
+    const result = await acknowledgeQuoteResponse('q1')
+
+    expect(result).toMatchObject({ success: true, data: { status: 'accepted' } })
+    const { data } = vi.mocked(db.quote.update).mock.calls[0][0] as any
+    expect(data.status).toBeUndefined()
+    expect(data.customerMessage).toBeUndefined()
+    expect(data.responseDismissedAt).toBeInstanceOf(Date)
+  })
+
+  it('sends a change request back to draft', async () => {
+    vi.mocked(db.quote.findFirst).mockResolvedValue({
+      id: 'q1',
+      status: 'changes_requested',
+    } as any)
+
+    const result = await acknowledgeQuoteResponse('q1')
+
+    expect(result).toMatchObject({ success: true, data: { status: 'draft' } })
+    expect(db.quote.update).toHaveBeenCalledWith({
+      where: { id: 'q1' },
+      data: { status: 'draft', customerMessage: null },
+    })
+  })
+
+  it('refreshes the dashboard, so a dismissed row does not wait for a reload', async () => {
+    vi.mocked(db.quote.findFirst).mockResolvedValue({ id: 'q1', status: 'accepted' } as any)
+
+    await acknowledgeQuoteResponse('q1')
+
+    expect(revalidatePath).toHaveBeenCalledWith('/')
+  })
+
+  it('refuses a quote from another workshop and changes nothing', async () => {
+    vi.mocked(db.quote.findFirst).mockResolvedValue(null)
+
+    const result = await acknowledgeQuoteResponse('q1')
+
+    expect(result.success).toBe(false)
+    expect(db.quote.findFirst).toHaveBeenCalledWith({ where: { id: 'q1', organizationId: ORG } })
+    expect(db.quote.update).not.toHaveBeenCalled()
+  })
+})
+
+describe('getQuoteResponses', () => {
+  it('leaves out responses the workshop has dismissed', async () => {
+    await getQuoteResponses()
+
+    const { where } = vi.mocked(db.quote.findMany).mock.calls[0][0] as any
+    expect(where).toMatchObject({ organizationId: ORG, responseDismissedAt: null })
+  })
+})

+ 36 - 0
src/__tests__/lib/document-lock.test.ts

@@ -16,6 +16,7 @@ import {
   invoiceLockState,
   invoicePaymentStatus,
   quoteLockState,
+  quoteStatusKeepsLock,
   readDocumentLockSettings,
   type DocumentLockSettings,
 } from '@/lib/document-lock'
@@ -385,6 +386,41 @@ describe('quoteLockState', () => {
   })
 })
 
+describe('quoteStatusKeepsLock', () => {
+  it('lets a quote locked on sending move between sent, accepted and converted', () => {
+    for (const status of ['sent', 'accepted', 'converted']) {
+      expect(quoteStatusKeepsLock('sent', status)).toBe(true)
+    }
+  })
+
+  it('does not let a quote locked on sending go back to draft or on to rejected', () => {
+    expect(quoteStatusKeepsLock('sent', 'draft')).toBe(false)
+    expect(quoteStatusKeepsLock('sent', 'rejected')).toBe(false)
+  })
+
+  it('keeps a quote locked on acceptance to accepted and converted', () => {
+    expect(quoteStatusKeepsLock('accepted', 'accepted')).toBe(true)
+    expect(quoteStatusKeepsLock('accepted', 'converted')).toBe(true)
+    expect(quoteStatusKeepsLock('accepted', 'sent')).toBe(false)
+    expect(quoteStatusKeepsLock('accepted', 'draft')).toBe(false)
+  })
+
+  it('agrees with quoteLockState for every status the editor offers', () => {
+    for (const trigger of ['sent', 'accepted'] as const) {
+      const settings: DocumentLockSettings = {
+        ...DOCUMENT_LOCK_DEFAULTS,
+        quoteLockEnabled: true,
+        quoteLockTrigger: trigger,
+      }
+      for (const status of ['draft', 'sent', 'accepted', 'rejected', 'converted']) {
+        expect(quoteStatusKeepsLock(trigger, status), `${trigger} -> ${status}`).toBe(
+          quoteLockState({ status }, settings).locked
+        )
+      }
+    }
+  })
+})
+
 describe('assertEditable', () => {
   it('passes an editable document through', () => {
     expect(() => assertEditable({ locked: false, reason: null, unlockedAt: null })).not.toThrow()

+ 17 - 5
src/app/(authenticated)/dashboard-client.tsx

@@ -353,6 +353,10 @@ export function DashboardClient({
   const activeNav = useTableKeyboardNav()
   const obsNav = useTableKeyboardNav()
   const [dismissingId, setDismissingId] = useState<string | null>(null)
+  // A dismissed response leaves the list at once rather than waiting for the
+  // server's next render of it, and comes back if the dismissal is refused.
+  const [dismissedResponseIds, setDismissedResponseIds] = useState<string[]>([])
+  const visibleQuoteResponses = quoteResponses.filter((r) => !dismissedResponseIds.includes(r.id))
   const [maintenanceTab, setMaintenanceTab] = useState<'active' | 'dismissed'>('active')
   const [restoringId, setRestoringId] = useState<string | null>(null)
   const [, startTransition] = useTransition()
@@ -475,7 +479,7 @@ export function DashboardClient({
     inProgressInspections.length === 0 && completedInspections.length === 0 ? 'inspections' : null,
     !serviceRequests || serviceRequests.length === 0 ? 'serviceRequests' : null,
     quoteRequests.length === 0 ? 'quoteRequests' : null,
-    quoteResponses.length === 0 ? 'quoteResponses' : null,
+    visibleQuoteResponses.length === 0 ? 'quoteResponses' : null,
     stats.recentServices.length === 0 ? 'recentCompleted' : null,
     stats.todaysServices.length === 0 ? 'activeJobs' : null,
     recentAuditLogs.length === 0 ? 'recentActivity' : null,
@@ -1418,7 +1422,7 @@ export function DashboardClient({
               <AppCard
                 icon={FileText}
                 title={t('quoteResponses.title')}
-                badge={quoteResponses.length || undefined}
+                badge={visibleQuoteResponses.length || undefined}
                 description={t('quoteResponses.description')}
                 contentClassName="p-0"
                 footer={
@@ -1432,13 +1436,14 @@ export function DashboardClient({
                   </button>
                 }
               >
-                {quoteResponses.length === 0 ? (
+                {visibleQuoteResponses.length === 0 ? (
                   <CardEmpty icon={FileText} title={t('quoteResponses.noData')} />
                 ) : (
                   <div className="divide-y">
-                    {quoteResponses.map((resp) => (
+                    {visibleQuoteResponses.map((resp) => (
                       <div
                         key={resp.id}
+                        data-testid="quote-response-row"
                         className="flex items-center justify-between px-5 py-3 overflow-hidden"
                       >
                         <div
@@ -1528,8 +1533,15 @@ export function DashboardClient({
                                 size="icon"
                                 className="h-6 w-6 text-muted-foreground hover:text-foreground"
                                 onClick={() => {
+                                  setDismissedResponseIds((ids) => [...ids, resp.id])
                                   startTransition(async () => {
-                                    await acknowledgeQuoteResponse(resp.id)
+                                    const result = await acknowledgeQuoteResponse(resp.id)
+                                    if (!result.success) {
+                                      setDismissedResponseIds((ids) =>
+                                        ids.filter((id) => id !== resp.id)
+                                      )
+                                      if (result.error) toast.error(result.error)
+                                    }
                                   })
                                 }}
                                 aria-label={t('quoteResponses.dismissAriaLabel')}

+ 9 - 17
src/components/shared-link-card.tsx

@@ -3,6 +3,7 @@
 import { useState } from 'react'
 import { Link2, Copy, Check, Trash2, Loader2 } from 'lucide-react'
 import { useTranslations } from 'next-intl'
+import { useFormatDate } from '@/lib/use-format-date'
 
 interface SharedLinkCardProps {
   publicToken: string
@@ -24,6 +25,9 @@ export function SharedLinkCard({
   onRevoke,
 }: SharedLinkCardProps) {
   const t = useTranslations(type === 'quote' ? 'quotes' : 'service')
+  // The workshop's own zone and formats, so the server's render and the
+  // browser's agree instead of each printing its own clock.
+  const { formatDate, formatDateTime } = useFormatDate()
   const [copied, setCopied] = useState(false)
   const [revoking, setRevoking] = useState(false)
 
@@ -82,6 +86,7 @@ export function SharedLinkCard({
         <button
           type="button"
           onClick={handleCopy}
+          aria-label={t('sidebar.sharedLink.copyLink')}
           className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
         >
           {copied ? (
@@ -94,14 +99,8 @@ export function SharedLinkCard({
 
       {/* Shared date */}
       {sharedAt && (
-        <p className="text-xs text-muted-foreground" suppressHydrationWarning>
-          {t('sidebar.sharedLink.sharedOn', {
-            date: new Date(sharedAt).toLocaleDateString(undefined, {
-              month: 'short',
-              day: 'numeric',
-              year: 'numeric',
-            }),
-          })}
+        <p className="text-xs text-muted-foreground">
+          {t('sidebar.sharedLink.sharedOn', { date: formatDate(sharedAt) })}
         </p>
       )}
 
@@ -113,19 +112,12 @@ export function SharedLinkCard({
           }`}
         />
         {hasViews ? (
-          <span className="text-xs text-muted-foreground" suppressHydrationWarning>
+          <span className="text-xs text-muted-foreground">
             {t('sidebar.sharedLink.viewedTimes', { count: viewCount })}
             {lastViewedAt && (
               <>
                 {' · '}
-                {t('sidebar.sharedLink.lastViewed', {
-                  date: new Date(lastViewedAt).toLocaleDateString(undefined, {
-                    month: 'short',
-                    day: 'numeric',
-                    hour: '2-digit',
-                    minute: '2-digit',
-                  }),
-                })}
+                {t('sidebar.sharedLink.lastViewed', { date: formatDateTime(lastViewedAt) })}
               </>
             )}
           </span>

+ 19 - 9
src/features/inspections/Components/InspectionShareDialog.tsx

@@ -108,6 +108,7 @@ export function InspectionShareDialog({
     setSending(true)
 
     const results: string[] = []
+    let failed = false
 
     if (notifyEmail && hasEmail) {
       const res = await sendInspectionEmail({
@@ -115,8 +116,13 @@ export function InspectionShareDialog({
         recipientEmail: customer.email!,
         attachPdf,
       })
-      if (res.success) results.push('Email sent')
-      else toast.error(res.error || 'Failed to send email')
+      if (res.success) {
+        results.push('Email sent')
+        setNotifyEmail(false)
+      } else {
+        failed = true
+        toast.error(res.error || 'Failed to send email')
+      }
     }
 
     if (notifySms && hasPhone) {
@@ -137,16 +143,20 @@ export function InspectionShareDialog({
         relatedEntityType: 'inspection',
         relatedEntityId: inspectionId,
       })
-      if (res.success) results.push('SMS sent')
-      else toast.error(res.error || 'Failed to send SMS')
+      if (res.success) {
+        results.push('SMS sent')
+        setNotifySms(false)
+      } else {
+        failed = true
+        toast.error(res.error || 'Failed to send SMS')
+      }
     }
 
-    if (results.length > 0) {
-      toast.success(results.join(' & '))
-      setNotifySms(false)
-      setNotifyEmail(false)
-    }
+    if (results.length > 0) toast.success(results.join(' & '))
     setSending(false)
+    // Done once everything ticked has gone. A channel that failed stays ticked
+    // and the dialog stays open, so it can be tried again.
+    if (results.length > 0 && !failed) onOpenChange(false)
   }
 
   const canNotify = shareUrl && customer && (notifyEmail || notifySms)

+ 22 - 3
src/features/quotes/Actions/quoteResponseActions.ts

@@ -1,5 +1,6 @@
 'use server'
 
+import { revalidatePath } from 'next/cache'
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
@@ -39,6 +40,8 @@ export async function respondToQuote(input: unknown) {
     data: {
       status: data.action,
       customerMessage: data.message || null,
+      // A new answer is news again, even on a quote whose last one was dismissed.
+      responseDismissedAt: null,
     },
   })
 
@@ -69,6 +72,8 @@ export async function getQuoteResponses() {
         where: {
           organizationId,
           status: { in: ['accepted', 'changes_requested'] },
+          // A dismissed acceptance keeps its status, so it is left out here.
+          responseDismissedAt: null,
         },
         select: {
           id: true,
@@ -96,7 +101,13 @@ export async function getQuoteResponses() {
 }
 
 /**
- * Authenticated — acknowledge a customer response by setting the quote back to a working status.
+ * Authenticated — the workshop has dealt with a customer's response.
+ *
+ * A change request sends the quote back to draft, because the workshop is
+ * revising it. An acceptance keeps its status: it is the customer's agreement,
+ * converting still wants it, and where accepted quotes lock, changing it here
+ * would release the lock without an owner or admin. It is only taken off the
+ * list of responses.
  */
 export async function acknowledgeQuoteResponse(quoteId: string) {
   return withAuth(
@@ -106,12 +117,20 @@ export async function acknowledgeQuoteResponse(quoteId: string) {
       })
       if (!quote) throw new Error('Quote not found')
 
+      const revising = quote.status === 'changes_requested'
       await db.quote.update({
         where: { id: quoteId },
-        data: { status: 'draft', customerMessage: null },
+        data: revising
+          ? { status: 'draft', customerMessage: null }
+          : { responseDismissedAt: new Date() },
       })
 
-      return { success: true }
+      // The dashboard lists responses from its own server render, so without
+      // this a dismissed row stays on screen until the page is reloaded.
+      revalidatePath('/')
+      revalidatePath('/quotes')
+      revalidatePath(`/quotes/${quoteId}`)
+      return { success: true, status: revising ? 'draft' : quote.status }
     },
     {
       requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.QUOTES }],

+ 27 - 27
src/features/quotes/Components/QuotePageClient.tsx

@@ -188,8 +188,12 @@ export function QuotePageClient({
     router.refresh()
   }, [quote.id, router])
 
+  // A locked quote offers no editing, rather than letting someone retype a
+  // line and meet the refusal on save. The lock is a fieldset per column, not
+  // one around the whole tab: a disabled fieldset disables every button inside
+  // it as well, and converting the quote or copying its link is not an edit.
   const leftColumn = (
-    <div className="space-y-3">
+    <fieldset disabled={lockState.locked} className="min-w-0 space-y-3">
       <QuotePartsEditor
         partItems={state.partItems}
         currencyCode={currencyCode}
@@ -223,7 +227,7 @@ export function QuotePageClient({
         onNotesChange={handleNotesChange}
         t={t}
       />
-    </div>
+    </fieldset>
   )
 
   const rightColumn = (
@@ -234,6 +238,7 @@ export function QuotePageClient({
       currencyCode={currencyCode}
       t={t}
       onRevoke={handleRevoke}
+      lockReason={lockState.locked ? lockState.reason : null}
     />
   )
 
@@ -408,33 +413,28 @@ export function QuotePageClient({
           onSubmit={state.handleSubmit}
           className="flex min-h-0 flex-1 flex-col overflow-hidden"
         >
-          {/* A locked quote offers no editing, rather than letting someone
-              retype a line and meet the refusal on save. display:contents
-              keeps the layout exactly as it was. */}
-          <fieldset disabled={lockState.locked} className="contents">
-            {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}
+          {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>
-            )}
-          </fieldset>
+            </div>
+          )}
         </form>
       )}
 

+ 300 - 269
src/features/quotes/Components/QuoteRightColumn.tsx

@@ -28,6 +28,8 @@ import { useFormatCurrency } from '@/components/currency-settings-context'
 import { netLineTotal } from '@/lib/tax'
 import { taxComponentLabel } from '@/lib/tax-components'
 import { CustomFieldsForm } from '@/features/custom-fields/Components/CustomFieldsForm'
+import { type LockReason, quoteStatusKeepsLock } from '@/lib/document-lock'
+import { useFormatDate } from '@/lib/use-format-date'
 import type { QuoteFormState } from './useQuoteFormState'
 import type { QuoteRecord } from './quote-page-types'
 import { VehicleCombobox } from './VehicleCombobox'
@@ -41,6 +43,12 @@ interface QuoteRightColumnProps {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   t: (key: string, values?: any) => string
   onRevoke: () => Promise<void>
+  /**
+   * Why the quote is locked, or null when it edits. A lock disables the fields
+   * that edit the quote; its actions, and any status that keeps it locked, stay
+   * usable.
+   */
+  lockReason: LockReason | null
 }
 
 export const QuoteRightColumn = memo(function QuoteRightColumn({
@@ -50,11 +58,17 @@ export const QuoteRightColumn = memo(function QuoteRightColumn({
   currencyCode,
   t,
   onRevoke,
+  lockReason,
 }: QuoteRightColumnProps) {
   const formatCurrency = useFormatCurrency()
+  const { formatDateTime } = useFormatDate()
   const [validUntil, setValidUntil] = useState(state.defaultValidDate)
+  const locked = lockReason !== null
+  const statusAllowed = (value: string) => !lockReason || quoteStatusKeepsLock(lockReason, value)
   return (
-    <div className="space-y-3">
+    // Gaps rather than space-y: the fieldsets below are display:contents, and
+    // a margin on one of those spaces nothing.
+    <div className="flex flex-col gap-3">
       {/* Convert to Work Order */}
       {quote.status !== 'converted' && (
         <div className="rounded-lg border p-3">
@@ -83,170 +97,186 @@ export const QuoteRightColumn = memo(function QuoteRightColumn({
         />
       )}
 
-      {/* Vehicle & Customer */}
-      <div className="rounded-lg border p-3 space-y-3">
-        <div className="space-y-1">
-          <Label className="text-xs">{t('details.customer')}</Label>
-          <CustomerCombobox
-            value={state.customerId}
-            initialCustomer={state.selectedCustomer}
-            placeholder={t('details.selectCustomer')}
-            noneLabel={t('details.none')}
-            onChange={(id, customer) => {
-              state.setCustomerId(id)
-              state.setSelectedCustomer(customer)
-              // A vehicle belonging to another customer no longer fits
-              if (id && state.selectedVehicle && state.selectedVehicle.customerId !== id) {
-                state.setVehicleId('')
-                state.setSelectedVehicle(null)
-              }
-              state.markDirty()
-            }}
-          />
-        </div>
-        {state.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" />
-            <Link
-              href={`/customers/${state.selectedCustomer.id}`}
-              target="_blank"
-              className="min-w-0 flex-1 text-sm hover:underline"
-            >
-              <span className="font-medium">{state.selectedCustomer.name}</span>
-              {state.selectedCustomer.company && (
-                <span className="ml-1.5 text-muted-foreground">
-                  {state.selectedCustomer.company}
-                </span>
-              )}
-            </Link>
-            <Button
-              type="button"
-              variant="ghost"
-              size="icon"
-              className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
-              onClick={() => {
-                state.setCustomerId('')
-                state.setSelectedCustomer(null)
+      {/* The fields that edit the quote sit in fieldsets a lock disables. The
+          actions do not: converting, the shared link, the status and the
+          customer's response, because a disabled fieldset disables the
+          buttons inside it too. */}
+      <fieldset disabled={locked} className="contents">
+        {/* Vehicle & Customer */}
+        <div className="rounded-lg border p-3 space-y-3">
+          <div className="space-y-1">
+            <Label className="text-xs">{t('details.customer')}</Label>
+            <CustomerCombobox
+              value={state.customerId}
+              initialCustomer={state.selectedCustomer}
+              placeholder={t('details.selectCustomer')}
+              noneLabel={t('details.none')}
+              onChange={(id, customer) => {
+                state.setCustomerId(id)
+                state.setSelectedCustomer(customer)
+                // A vehicle belonging to another customer no longer fits
+                if (id && state.selectedVehicle && state.selectedVehicle.customerId !== id) {
+                  state.setVehicleId('')
+                  state.setSelectedVehicle(null)
+                }
                 state.markDirty()
               }}
-              aria-label={t('details.clearCustomer')}
-            >
-              <X className="h-3 w-3" />
-            </Button>
+            />
           </div>
-        )}
-        <div className="space-y-1">
-          <Label className="text-xs">{t('details.vehicle')}</Label>
-          <VehicleCombobox
-            value={state.vehicleId}
-            customerId={state.customerId || undefined}
-            initialVehicle={state.selectedVehicle}
-            placeholder={t('details.selectVehicle')}
-            noneLabel={t('details.none')}
-            onChange={(id, vehicle) => {
-              state.setVehicleId(id)
-              state.setSelectedVehicle(vehicle)
-              if (vehicle?.customerId) {
-                state.setCustomerId(vehicle.customerId)
-                if (vehicle.customer) {
-                  state.setSelectedCustomer({
-                    id: vehicle.customer.id,
-                    name: vehicle.customer.name,
-                    company: null,
-                  })
+          {state.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" />
+              <Link
+                href={`/customers/${state.selectedCustomer.id}`}
+                target="_blank"
+                className="min-w-0 flex-1 text-sm hover:underline"
+              >
+                <span className="font-medium">{state.selectedCustomer.name}</span>
+                {state.selectedCustomer.company && (
+                  <span className="ml-1.5 text-muted-foreground">
+                    {state.selectedCustomer.company}
+                  </span>
+                )}
+              </Link>
+              <Button
+                type="button"
+                variant="ghost"
+                size="icon"
+                className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
+                onClick={() => {
+                  state.setCustomerId('')
+                  state.setSelectedCustomer(null)
+                  state.markDirty()
+                }}
+                aria-label={t('details.clearCustomer')}
+              >
+                <X className="h-3 w-3" />
+              </Button>
+            </div>
+          )}
+          <div className="space-y-1">
+            <Label className="text-xs">{t('details.vehicle')}</Label>
+            <VehicleCombobox
+              value={state.vehicleId}
+              customerId={state.customerId || undefined}
+              initialVehicle={state.selectedVehicle}
+              placeholder={t('details.selectVehicle')}
+              noneLabel={t('details.none')}
+              onChange={(id, vehicle) => {
+                state.setVehicleId(id)
+                state.setSelectedVehicle(vehicle)
+                if (vehicle?.customerId) {
+                  state.setCustomerId(vehicle.customerId)
+                  if (vehicle.customer) {
+                    state.setSelectedCustomer({
+                      id: vehicle.customer.id,
+                      name: vehicle.customer.name,
+                      company: null,
+                    })
+                  }
                 }
-              }
-              state.markDirty()
-            }}
-          />
-        </div>
-        {state.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" />
-            <Link
-              href={`/vehicles/${state.selectedVehicle.id}`}
-              target="_blank"
-              className="min-w-0 flex-1 text-sm hover:underline"
-            >
-              <span className="font-medium">
-                {state.selectedVehicle.year} {state.selectedVehicle.make}{' '}
-                {state.selectedVehicle.model}
-              </span>
-              {state.selectedVehicle.licensePlate && (
-                <span className="ml-1.5 text-muted-foreground">
-                  {state.selectedVehicle.licensePlate}
-                </span>
-              )}
-            </Link>
-            <Button
-              type="button"
-              variant="ghost"
-              size="icon"
-              className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
-              onClick={() => {
-                state.setVehicleId('')
-                state.setSelectedVehicle(null)
                 state.markDirty()
               }}
-              aria-label={t('details.clearVehicle')}
-            >
-              <X className="h-3 w-3" />
-            </Button>
+            />
           </div>
-        )}
-      </div>
+          {state.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" />
+              <Link
+                href={`/vehicles/${state.selectedVehicle.id}`}
+                target="_blank"
+                className="min-w-0 flex-1 text-sm hover:underline"
+              >
+                <span className="font-medium">
+                  {state.selectedVehicle.year} {state.selectedVehicle.make}{' '}
+                  {state.selectedVehicle.model}
+                </span>
+                {state.selectedVehicle.licensePlate && (
+                  <span className="ml-1.5 text-muted-foreground">
+                    {state.selectedVehicle.licensePlate}
+                  </span>
+                )}
+              </Link>
+              <Button
+                type="button"
+                variant="ghost"
+                size="icon"
+                className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
+                onClick={() => {
+                  state.setVehicleId('')
+                  state.setSelectedVehicle(null)
+                  state.markDirty()
+                }}
+                aria-label={t('details.clearVehicle')}
+              >
+                <X className="h-3 w-3" />
+              </Button>
+            </div>
+          )}
+        </div>
+      </fieldset>
 
       {/* Quote Details */}
-      <div className="rounded-lg border p-3 space-y-3">
+      <div className="flex flex-col gap-3 rounded-lg border p-3">
         <h3 className="text-sm font-semibold">{t('details.title')}</h3>
-        <div className="space-y-1">
-          <Label htmlFor="title" className="text-xs">
-            {t('details.titleLabel')}
-          </Label>
-          <Input
-            id="title"
-            name="title"
-            placeholder={t('details.titlePlaceholder')}
-            defaultValue={quote.title}
-            required
-            onChange={state.markDirty}
-          />
-        </div>
+        <fieldset disabled={locked} className="contents">
+          <div className="space-y-1">
+            <Label htmlFor="title" className="text-xs">
+              {t('details.titleLabel')}
+            </Label>
+            <Input
+              id="title"
+              name="title"
+              placeholder={t('details.titlePlaceholder')}
+              defaultValue={quote.title}
+              required
+              onChange={state.markDirty}
+            />
+          </div>
+        </fieldset>
         <div className="grid grid-cols-2 gap-2">
           <div className="space-y-1">
             <Label className="text-xs">{t('details.status')}</Label>
             <Select
               value={state.status}
-              onValueChange={(v) => {
-                state.setStatus(v)
-                state.markDirty()
-              }}
+              onValueChange={state.changeStatus}
+              disabled={state.changingStatus}
             >
-              <SelectTrigger>
+              <SelectTrigger aria-label={t('details.status')}>
                 <SelectValue />
               </SelectTrigger>
               <SelectContent>
-                <SelectItem value="draft">{t('details.statusDraft')}</SelectItem>
-                <SelectItem value="sent">{t('details.statusSent')}</SelectItem>
-                <SelectItem value="accepted">{t('details.statusAccepted')}</SelectItem>
-                <SelectItem value="rejected">{t('details.statusRejected')}</SelectItem>
+                <SelectItem value="draft" disabled={!statusAllowed('draft')}>
+                  {t('details.statusDraft')}
+                </SelectItem>
+                <SelectItem value="sent" disabled={!statusAllowed('sent')}>
+                  {t('details.statusSent')}
+                </SelectItem>
+                <SelectItem value="accepted" disabled={!statusAllowed('accepted')}>
+                  {t('details.statusAccepted')}
+                </SelectItem>
+                <SelectItem value="rejected" disabled={!statusAllowed('rejected')}>
+                  {t('details.statusRejected')}
+                </SelectItem>
               </SelectContent>
             </Select>
           </div>
-          <div className="space-y-1">
-            <Label htmlFor="validUntil" className="text-xs">
-              {t('details.validUntil')}
-            </Label>
-            <DateInput
-              id="validUntil"
-              name="validUntil"
-              value={validUntil}
-              onChange={(v) => {
-                setValidUntil(v)
-                state.markDirty()
-              }}
-            />
-          </div>
+          <fieldset disabled={locked} className="contents">
+            <div className="space-y-1">
+              <Label htmlFor="validUntil" className="text-xs">
+                {t('details.validUntil')}
+              </Label>
+              <DateInput
+                id="validUntil"
+                name="validUntil"
+                value={validUntil}
+                onChange={(v) => {
+                  setValidUntil(v)
+                  state.markDirty()
+                }}
+              />
+            </div>
+          </fieldset>
         </div>
         {quote.inspectionId && (
           <Link
@@ -260,15 +290,18 @@ export const QuoteRightColumn = memo(function QuoteRightColumn({
       </div>
 
       {/* Custom Fields */}
-      <CustomFieldsForm
-        entityId={quote.id}
-        entityType="quote"
-        onValuesReady={state.onCustomFieldsReady}
-        onChange={state.markDirty}
-      />
+      <fieldset disabled={locked} className="contents">
+        <CustomFieldsForm
+          entityId={quote.id}
+          entityType="quote"
+          onValuesReady={state.onCustomFieldsReady}
+          onChange={state.markDirty}
+        />
+      </fieldset>
 
       {/* Customer Response */}
       {quote.customerMessage &&
+        !quote.responseDismissedAt &&
         (state.status === 'changes_requested' || state.status === 'accepted') && (
           <div
             className={`rounded-lg border p-3 space-y-2 ${
@@ -297,15 +330,9 @@ export const QuoteRightColumn = memo(function QuoteRightColumn({
                     : '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',
-                })}
+                {/* In the workshop's zone: the browser's own locale and clock
+                    disagree with the server's render and break hydration. */}
+                {formatDateTime(quote.updatedAt)}
               </span>
             </div>
             <p
@@ -336,127 +363,131 @@ export const QuoteRightColumn = memo(function QuoteRightColumn({
         )}
 
       {/* Totals */}
-      {(() => {
-        // Universal display: net per category, net subtotal, net discount, tax, gross total.
-        // Same layout as the quote PDF/share view so the user always sees the breakdown.
-        const displayPartsSubtotal = netLineTotal(
-          state.partsSubtotal,
-          state.taxRate,
-          state.taxInclusive
-        )
-        const displayLaborSubtotal = netLineTotal(
-          state.laborSubtotal,
-          state.taxRate,
-          state.taxInclusive
-        )
-        const displaySubtotal = netLineTotal(state.subtotal, state.taxRate, state.taxInclusive)
-        const displayDiscountAmount = netLineTotal(
-          state.discountAmount,
-          state.taxRate,
-          state.taxInclusive
-        )
-        return (
-          <div className="rounded-lg border p-3 space-y-2">
-            <h3 className="text-sm font-semibold">{t('totals.title')}</h3>
-            <div className="space-y-2">
-              <div className="flex items-center justify-between text-sm">
-                <span className="text-muted-foreground">{t('totals.parts')}</span>
-                <span>{formatCurrency(displayPartsSubtotal, currencyCode)}</span>
-              </div>
-              <div className="flex items-center justify-between text-sm">
-                <span className="text-muted-foreground">{t('totals.labor')}</span>
-                <span>{formatCurrency(displayLaborSubtotal, currencyCode)}</span>
-              </div>
-              <div className="flex items-center justify-between text-sm">
-                <span className="text-muted-foreground">{t('totals.subtotal')}</span>
-                <span className="font-medium">{formatCurrency(displaySubtotal, currencyCode)}</span>
-              </div>
-              <div className="flex items-center justify-between text-sm">
-                <div className="flex items-center gap-2">
-                  <span className="text-muted-foreground">{t('totals.discount')}</span>
-                  <Select
-                    value={state.discountType}
-                    onValueChange={(v) => {
-                      state.setDiscountType(v)
-                      state.markDirty()
-                    }}
-                  >
-                    <SelectTrigger className="h-7 w-28 text-xs">
-                      <SelectValue />
-                    </SelectTrigger>
-                    <SelectContent>
-                      <SelectItem value="none">{t('totals.discountNone')}</SelectItem>
-                      <SelectItem value="percentage">{t('totals.discountPercentage')}</SelectItem>
-                      <SelectItem value="fixed">{t('totals.discountFixed')}</SelectItem>
-                    </SelectContent>
-                  </Select>
-                  {state.discountType !== 'none' && (
-                    <Input
-                      type="number"
-                      min="0"
-                      step="0.01"
-                      value={state.discountValue}
-                      onChange={(e) => {
-                        state.setDiscountValue(e.target.value === '' ? 0 : Number(e.target.value))
-                        state.markDirty()
-                      }}
-                      className="h-7 w-20 text-right text-xs"
-                    />
-                  )}
-                  {state.discountType === 'percentage' && (
-                    <span className="text-muted-foreground">%</span>
-                  )}
+      <fieldset disabled={locked} className="contents">
+        {(() => {
+          // Universal display: net per category, net subtotal, net discount, tax, gross total.
+          // Same layout as the quote PDF/share view so the user always sees the breakdown.
+          const displayPartsSubtotal = netLineTotal(
+            state.partsSubtotal,
+            state.taxRate,
+            state.taxInclusive
+          )
+          const displayLaborSubtotal = netLineTotal(
+            state.laborSubtotal,
+            state.taxRate,
+            state.taxInclusive
+          )
+          const displaySubtotal = netLineTotal(state.subtotal, state.taxRate, state.taxInclusive)
+          const displayDiscountAmount = netLineTotal(
+            state.discountAmount,
+            state.taxRate,
+            state.taxInclusive
+          )
+          return (
+            <div className="rounded-lg border p-3 space-y-2">
+              <h3 className="text-sm font-semibold">{t('totals.title')}</h3>
+              <div className="space-y-2">
+                <div className="flex items-center justify-between text-sm">
+                  <span className="text-muted-foreground">{t('totals.parts')}</span>
+                  <span>{formatCurrency(displayPartsSubtotal, currencyCode)}</span>
                 </div>
-                {displayDiscountAmount > 0 && (
-                  <span className="text-destructive">
-                    {formatCurrency(-displayDiscountAmount, currencyCode)}
+                <div className="flex items-center justify-between text-sm">
+                  <span className="text-muted-foreground">{t('totals.labor')}</span>
+                  <span>{formatCurrency(displayLaborSubtotal, currencyCode)}</span>
+                </div>
+                <div className="flex items-center justify-between text-sm">
+                  <span className="text-muted-foreground">{t('totals.subtotal')}</span>
+                  <span className="font-medium">
+                    {formatCurrency(displaySubtotal, currencyCode)}
                   </span>
-                )}
-              </div>
-              {state.taxEnabled && state.taxComponents && state.taxComponents.length > 0 ? (
-                state.taxComponents.map((component) => (
-                  <div
-                    key={component.name}
-                    className="flex items-center justify-between text-sm"
-                    data-testid="tax-component-row"
-                  >
-                    <span className="text-muted-foreground">{taxComponentLabel(component)}</span>
-                    <span>{formatCurrency(component.amount, currencyCode)}</span>
-                  </div>
-                ))
-              ) : state.taxEnabled ? (
+                </div>
                 <div className="flex items-center justify-between text-sm">
                   <div className="flex items-center gap-2">
-                    <span className="text-muted-foreground">{t('totals.tax')}</span>
-                    <Input
-                      type="number"
-                      min="0"
-                      step="0.1"
-                      value={state.taxRate}
-                      onChange={(e) => {
-                        state.setTaxRate(e.target.value === '' ? 0 : Number(e.target.value))
+                    <span className="text-muted-foreground">{t('totals.discount')}</span>
+                    <Select
+                      value={state.discountType}
+                      onValueChange={(v) => {
+                        state.setDiscountType(v)
                         state.markDirty()
                       }}
-                      className="h-7 w-20 text-right text-xs"
-                    />
-                    <span className="text-muted-foreground">%</span>
+                    >
+                      <SelectTrigger className="h-7 w-28 text-xs">
+                        <SelectValue />
+                      </SelectTrigger>
+                      <SelectContent>
+                        <SelectItem value="none">{t('totals.discountNone')}</SelectItem>
+                        <SelectItem value="percentage">{t('totals.discountPercentage')}</SelectItem>
+                        <SelectItem value="fixed">{t('totals.discountFixed')}</SelectItem>
+                      </SelectContent>
+                    </Select>
+                    {state.discountType !== 'none' && (
+                      <Input
+                        type="number"
+                        min="0"
+                        step="0.01"
+                        value={state.discountValue}
+                        onChange={(e) => {
+                          state.setDiscountValue(e.target.value === '' ? 0 : Number(e.target.value))
+                          state.markDirty()
+                        }}
+                        className="h-7 w-20 text-right text-xs"
+                      />
+                    )}
+                    {state.discountType === 'percentage' && (
+                      <span className="text-muted-foreground">%</span>
+                    )}
                   </div>
-                  <span>{formatCurrency(state.taxAmount, currencyCode)}</span>
+                  {displayDiscountAmount > 0 && (
+                    <span className="text-destructive">
+                      {formatCurrency(-displayDiscountAmount, currencyCode)}
+                    </span>
+                  )}
                 </div>
-              ) : null}
-              <div className="flex items-center justify-between border-t pt-2 text-lg font-bold">
-                <span>{t('totals.total')}</span>
-                <span>{formatCurrency(state.totalAmount, currencyCode)}</span>
+                {state.taxEnabled && state.taxComponents && state.taxComponents.length > 0 ? (
+                  state.taxComponents.map((component) => (
+                    <div
+                      key={component.name}
+                      className="flex items-center justify-between text-sm"
+                      data-testid="tax-component-row"
+                    >
+                      <span className="text-muted-foreground">{taxComponentLabel(component)}</span>
+                      <span>{formatCurrency(component.amount, currencyCode)}</span>
+                    </div>
+                  ))
+                ) : state.taxEnabled ? (
+                  <div className="flex items-center justify-between text-sm">
+                    <div className="flex items-center gap-2">
+                      <span className="text-muted-foreground">{t('totals.tax')}</span>
+                      <Input
+                        type="number"
+                        min="0"
+                        step="0.1"
+                        value={state.taxRate}
+                        onChange={(e) => {
+                          state.setTaxRate(e.target.value === '' ? 0 : Number(e.target.value))
+                          state.markDirty()
+                        }}
+                        className="h-7 w-20 text-right text-xs"
+                      />
+                      <span className="text-muted-foreground">%</span>
+                    </div>
+                    <span>{formatCurrency(state.taxAmount, currencyCode)}</span>
+                  </div>
+                ) : null}
+                <div className="flex items-center justify-between border-t pt-2 text-lg font-bold">
+                  <span>{t('totals.total')}</span>
+                  <span>{formatCurrency(state.totalAmount, currencyCode)}</span>
+                </div>
+                {state.taxInclusive && (
+                  <p className="text-xs text-muted-foreground italic">
+                    {t('totals.inclusiveModeHint')}
+                  </p>
+                )}
               </div>
-              {state.taxInclusive && (
-                <p className="text-xs text-muted-foreground italic">
-                  {t('totals.inclusiveModeHint')}
-                </p>
-              )}
             </div>
-          </div>
-        )
-      })()}
+          )
+        })()}
+      </fieldset>
     </div>
   )
 })

+ 18 - 6
src/features/quotes/Components/QuoteShareDialog.tsx

@@ -94,6 +94,7 @@ export function QuoteShareDialog({
     setSending(true)
 
     const results: string[] = []
+    let failed = false
 
     if (notifyEmail && hasEmail) {
       const res = await sendQuoteEmail({
@@ -101,8 +102,13 @@ export function QuoteShareDialog({
         recipientEmail: customer.email!,
         attachPdf,
       })
-      if (res.success) results.push(t('shareDialog.emailSent'))
-      else toast.error(res.error || t('shareDialog.failedEmail'))
+      if (res.success) {
+        results.push(t('shareDialog.emailSent'))
+        setNotifyEmail(false)
+      } else {
+        failed = true
+        toast.error(res.error || t('shareDialog.failedEmail'))
+      }
     }
 
     if (notifySms && hasPhone) {
@@ -123,18 +129,24 @@ export function QuoteShareDialog({
         relatedEntityType: 'quote',
         relatedEntityId: quoteId,
       })
-      if (res.success) results.push(t('shareDialog.smsSent'))
-      else toast.error(res.error || t('shareDialog.failedSms'))
+      if (res.success) {
+        results.push(t('shareDialog.smsSent'))
+        setNotifySms(false)
+      } else {
+        failed = true
+        toast.error(res.error || t('shareDialog.failedSms'))
+      }
     }
 
     if (results.length > 0) {
       toast.success(results.join(' & '))
-      setNotifyEmail(false)
-      setNotifySms(false)
       // Either channel re-issues the quote, and the lock may have engaged.
       onSent?.()
     }
     setSending(false)
+    // Done once everything ticked has gone. A channel that failed stays ticked
+    // and the dialog stays open, so it can be tried again.
+    if (results.length > 0 && !failed) onOpenChange(false)
   }
 
   const canNotify = publicUrl && customer && (notifyEmail || notifySms)

+ 1 - 0
src/features/quotes/Components/quote-page-types.ts

@@ -34,6 +34,7 @@ export interface QuoteRecord {
   totalAmount: number
   notes: string | null
   customerMessage: string | null
+  responseDismissedAt?: Date | null
   publicToken: string | null
   sharedAt: Date | null
   viewCount: number

+ 28 - 1
src/features/quotes/Components/useQuoteFormState.ts

@@ -7,6 +7,7 @@ import { useGlassModal } from '@/components/glass-modal'
 import { useConfirm } from '@/components/confirm-dialog'
 import {
   updateQuote,
+  updateQuoteStatus,
   deleteQuote,
   convertQuoteToServiceRecord,
 } from '@/features/quotes/Actions/quoteActions'
@@ -169,6 +170,29 @@ export function useQuoteFormState({
     }, 5000)
   }, [locked])
 
+  // A locked quote refuses a save, so its status is written on its own the
+  // moment it is chosen. The server still refuses a status that would release
+  // the lock, which is the owner-or-admin unlock by another route.
+  const [changingStatus, setChangingStatus] = useState(false)
+  const changeStatus = async (next: string) => {
+    if (!locked) {
+      setStatus(next)
+      markDirty()
+      return
+    }
+    const previous = status
+    setStatus(next)
+    setChangingStatus(true)
+    const result = await updateQuoteStatus(quote.id, next)
+    setChangingStatus(false)
+    if (result.success) {
+      router.refresh()
+    } else {
+      setStatus(previous)
+      toast.error(result.error || t('page.failedSave'))
+    }
+  }
+
   // When the lock engages mid-session, a save already queued can only be
   // refused, and "Unsaved changes" would offer one that can never complete.
   useEffect(() => {
@@ -474,7 +498,8 @@ export function useQuoteFormState({
     setResolving(true)
     const result = await acknowledgeQuoteResponse(quote.id)
     if (result.success) {
-      setStatus('draft')
+      // A change request goes back to draft; an acceptance keeps its status.
+      if (result.data) setStatus(result.data.status)
       toast.success(t('page.responseResolved'))
       router.refresh()
     }
@@ -522,6 +547,8 @@ export function useQuoteFormState({
     setConvertVehicleId,
     converting,
     resolving,
+    changeStatus,
+    changingStatus,
     defaultValidDate,
     // Unsaved
     hasUnsavedChanges,

+ 18 - 6
src/features/vehicles/Components/service-detail/ShareDialog.tsx

@@ -98,6 +98,7 @@ export function ShareDialog({
     setSending(true)
 
     const results: string[] = []
+    let failed = false
 
     if (notifyEmail && hasEmail) {
       const res = await sendInvoiceEmail({
@@ -105,8 +106,13 @@ export function ShareDialog({
         recipientEmail: customer.email!,
         attachPdf,
       })
-      if (res.success) results.push(t('emailSent'))
-      else toast.error(res.error || t('failedEmail'))
+      if (res.success) {
+        results.push(t('emailSent'))
+        setNotifyEmail(false)
+      } else {
+        failed = true
+        toast.error(res.error || t('failedEmail'))
+      }
     }
 
     if (notifySms && hasPhone) {
@@ -127,20 +133,26 @@ export function ShareDialog({
         relatedEntityType: 'invoice',
         relatedEntityId: recordId,
       })
-      if (res.success) results.push(t('smsSent'))
-      else toast.error(res.error || t('failedSms'))
+      if (res.success) {
+        results.push(t('smsSent'))
+        setNotifySms(false)
+      } else {
+        failed = true
+        toast.error(res.error || t('failedSms'))
+      }
     }
 
     if (results.length > 0) {
       toast.success(results.join(' & '))
-      setNotifyEmail(false)
-      setNotifySms(false)
       // Both channels count as sending the invoice — email stamps sentAt on
       // the server, and the SMS carries the link — so the lock may have just
       // engaged and the page needs to hear about it.
       onSent?.()
     }
     setSending(false)
+    // Done once everything ticked has gone. A channel that failed stays ticked
+    // and the dialog stays open, so it can be tried again.
+    if (results.length > 0 && !failed) onOpenChange(false)
   }
 
   const canNotify = publicUrl && customer && (notifyEmail || notifySms)

+ 10 - 0
src/lib/document-lock.ts

@@ -198,6 +198,16 @@ const AGREED_QUOTE_STATUSES = ['accepted', 'converted']
 /** Statuses that mean the quote has left the workshop. */
 const ISSUED_QUOTE_STATUSES = ['sent', ...AGREED_QUOTE_STATUSES]
 
+/**
+ * Whether a locked quote may move to `status`. Status changes stay open on a
+ * locked quote, except one that would release the lock: that would be the
+ * owner-or-admin unlock by another route. `reason` is the lock's own, which
+ * names the trigger that set it.
+ */
+export function quoteStatusKeepsLock(reason: LockReason, status: string): boolean {
+  return (reason === 'sent' ? ISSUED_QUOTE_STATUSES : AGREED_QUOTE_STATUSES).includes(status)
+}
+
 export function quoteLockState(quote: LockableQuote, settings: DocumentLockSettings): LockState {
   if (!settings.quoteLockEnabled) return EDITABLE