Bläddra i källkod

Notifications open what they are about (#398)

Clicking a notification opens the right conversation, invoice, tab or quote,
including the page already open. A quote's customer response sits at the top.
Bernt Christian Egeland 2 veckor sedan
förälder
incheckning
2e9749a677

+ 0 - 10
.devcontainer/docker-compose.yml

@@ -11,9 +11,6 @@ services:
     command: sleep infinity
     command: sleep infinity
     ports:
     ports:
       - 3000:3000
       - 3000:3000
-    networks:
-      - default
-      - torqvoice-shared
     depends_on:
     depends_on:
       db:
       db:
         condition: service_healthy
         condition: service_healthy
@@ -35,9 +32,6 @@ services:
       interval: 5s
       interval: 5s
       timeout: 5s
       timeout: 5s
       retries: 5
       retries: 5
-    networks:
-      - default
-      - torqvoice-shared
 
 
   pgadmin:
   pgadmin:
     image: dpage/pgadmin4:latest
     image: dpage/pgadmin4:latest
@@ -60,7 +54,3 @@ services:
 volumes:
 volumes:
   postgres-data:
   postgres-data:
   pgadmin-data:
   pgadmin-data:
-
-networks:
-  torqvoice-shared:
-    external: true

+ 152 - 0
e2e/specs/notifications/links.spec.ts

@@ -0,0 +1,152 @@
+import { expect, type Page, test } from '@playwright/test'
+import {
+  deleteMessagesWithBody,
+  deleteNotifications,
+  jobWithCustomer,
+  linkTelegramChat,
+  ownerOrganizationId,
+  type PlantedNotification,
+  plantInboundMessage,
+  plantNotification,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+
+/**
+ * Every notification lands on the thing it is about.
+ *
+ * An audit of the bell (15 Sep 2026) found links that opened the right page
+ * and showed nothing: an inbound SMS, Telegram or WhatsApp message opened the
+ * inbox with no conversation selected, a customer's payment opened the
+ * vehicle instead of the invoice, and feedback on a status report did not
+ * switch tab when the job was already open.
+ *
+ * The notifications are planted with the address the code builds, and in the
+ * old shape where the pages now have to honour links already stored in
+ * people's bells. The triggers themselves (a Twilio webhook, a Telegram
+ * update) need providers the harness cannot play.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const planted: string[] = []
+const bodies: string[] = []
+let organizationId = ''
+let job: Awaited<ReturnType<typeof jobWithCustomer>>
+/** The customer's Telegram chat before the spec linked one, put back afterwards. */
+let previousTelegramChat: string | null | undefined
+
+test.beforeAll(async () => {
+  organizationId = await ownerOrganizationId()
+  job = await jobWithCustomer(organizationId)
+})
+
+test.afterAll(async () => {
+  await deleteNotifications(planted)
+  if (previousTelegramChat !== undefined) {
+    await linkTelegramChat(job.customerId, previousTelegramChat)
+  }
+  for (const body of bodies) await deleteMessagesWithBody(body)
+})
+
+async function plant(notification: PlantedNotification): Promise<void> {
+  planted.push(await plantNotification(organizationId, notification))
+}
+
+/** Opens the bell and clicks the notification with this title. */
+async function openFromBell(page: Page, title: string): Promise<void> {
+  const panel = page.getByRole('dialog', { name: 'Notifications' })
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Open notifications' }).click()
+    await expect(panel).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await panel.getByRole('button').filter({ hasText: title }).first().click()
+  await expect(panel).toBeHidden()
+}
+
+test('an inbound SMS opens that conversation, not an empty inbox', async ({ page }) => {
+  const body = `E2E SMS from the customer ${stamp}`
+  const title = `E2E new SMS ${stamp}`
+  bodies.push(body)
+  await plantInboundMessage('sms', organizationId, job.customerId, body)
+  await plant({
+    type: 'sms_inbound',
+    title,
+    message: `${job.customerName}: ${body}`,
+    entityType: 'sms_message',
+    entityId: job.customerId,
+    entityUrl: `/messages?customerId=${job.customerId}`,
+  })
+
+  await page.goto('/')
+  await settle(page)
+  await openFromBell(page, title)
+
+  await page.waitForURL(/\/messages\?customerId=/)
+  await expect(page.getByText('Pick a conversation')).toHaveCount(0)
+  // Once in the list's preview and once in the open conversation.
+  await expect(page.getByText(body).filter({ visible: true })).toHaveCount(2)
+})
+
+test('a Telegram message stored with the old link opens its conversation', async ({ page }) => {
+  const body = `E2E Telegram from the customer ${stamp}`
+  const title = `E2E new Telegram ${stamp}`
+  bodies.push(body)
+  previousTelegramChat = await linkTelegramChat(job.customerId, '777000')
+  await plantInboundMessage('telegram', organizationId, job.customerId, body)
+  await plant({
+    type: 'telegram_inbound',
+    title,
+    message: `${job.customerName}: ${body}`,
+    entityType: 'telegram_message',
+    entityId: job.customerId,
+    entityUrl: `/messages?tab=telegram&customerId=${job.customerId}`,
+  })
+
+  await page.goto('/')
+  await settle(page)
+  await openFromBell(page, title)
+
+  await page.waitForURL(/\/messages\?tab=telegram/)
+  await expect(page.getByText('Pick a conversation')).toHaveCount(0)
+  await expect(page.getByText(body).filter({ visible: true })).toHaveCount(2)
+})
+
+test('a payment stored with the old vehicle link opens the invoice', async ({ page }) => {
+  const title = `E2E payment received ${stamp}`
+  await plant({
+    type: 'invoice_payment',
+    title,
+    message: `${job.customerName} paid 100.00`,
+    entityType: 'invoice',
+    entityId: job.serviceRecordId,
+    entityUrl: `/vehicles/${job.vehicleId}?tab=service&record=${job.serviceRecordId}`,
+  })
+
+  await page.goto('/')
+  await settle(page)
+  await openFromBell(page, title)
+
+  await page.waitForURL(`**/vehicles/${job.vehicleId}/service/${job.serviceRecordId}`)
+})
+
+test('feedback on a status report switches the open job to its reports', async ({ page }) => {
+  const title = `E2E status report feedback ${stamp}`
+  await plant({
+    type: 'status_report_feedback',
+    title,
+    message: `${job.customerName} responded to the status report`,
+    entityType: 'ServiceRecord',
+    entityId: job.serviceRecordId,
+    entityUrl: `/vehicles/${job.vehicleId}/service/${job.serviceRecordId}?tab=statusReports`,
+  })
+
+  // The job is already open on its details when the notification is clicked.
+  await page.goto(`/vehicles/${job.vehicleId}/service/${job.serviceRecordId}`)
+  await settle(page)
+  await expect(page.getByRole('button', { name: 'New Status Report' })).toHaveCount(0)
+  await openFromBell(page, title)
+
+  await page.waitForURL(/tab=statusReports/)
+  await expect(page.getByRole('button', { name: 'New Status Report' })).toBeVisible()
+})

+ 88 - 0
e2e/specs/notifications/quote-response.spec.ts

@@ -0,0 +1,88 @@
+import { type Browser, expect, type Page, test } from '@playwright/test'
+import { settle } from '../../support/hydration'
+import { addQuotePart, newQuote, quoteShareLink, saveQuote } from '../../support/quote'
+
+/**
+ * A customer's answer to a quote, followed from the bell to the quote.
+ *
+ * A workshop reported that clicking "Quote Changes Requested" in the
+ * notification panel did nothing, when they expected the quote to open with
+ * the customer's request on it. Both ways a desk meets the notification are
+ * walked: opened later from another page, and arriving live while the quote
+ * itself is already on screen.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+
+/** A shared quote the customer can answer, open in the editor. */
+async function sharedQuote(page: Page, title: string) {
+  const quoteUrl = await newQuote(page, title)
+  await addQuotePart(page, { name: `E2E notification part ${stamp}`, quantity: 1, unitPrice: 300 })
+  await saveQuote(page)
+  const shareUrl = await quoteShareLink(page)
+  return { quoteUrl, shareUrl }
+}
+
+/** The customer asks for a change on the public page, signed out. */
+async function requestChanges(browser: Browser, shareUrl: string, request: string) {
+  const customer = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+  const customerPage = await customer.newPage()
+  await customerPage.goto(shareUrl)
+  await expect(async () => {
+    await customerPage.getByRole('button', { name: 'Request Changes' }).click()
+    await expect(customerPage.getByRole('textbox')).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await customerPage.getByRole('textbox').fill(request)
+  await customerPage.getByRole('button', { name: 'Submit Request' }).click()
+  await expect(customerPage.getByText('Changes Requested').first()).toBeVisible()
+  await customer.close()
+}
+
+/** Opens the bell and clicks the newest notification with this title. */
+async function clickNotification(page: Page, title: string) {
+  const panel = page.getByRole('dialog', { name: 'Notifications' })
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Open notifications' }).click()
+    await expect(panel).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await panel.getByRole('button').filter({ hasText: title }).first().click()
+}
+
+test('opened from the bell, a change request lands on the quote showing the request', async ({
+  page,
+  browser,
+}) => {
+  const request = `Please use a genuine filter ${stamp}`
+  const { quoteUrl, shareUrl } = await sharedQuote(page, `E2E notified quote ${stamp}`)
+  await requestChanges(browser, shareUrl, request)
+
+  await page.goto('/')
+  await settle(page)
+  await clickNotification(page, 'Quote Changes Requested')
+
+  await page.waitForURL(quoteUrl, { timeout: 15_000 })
+  await expect(page.getByText(request)).toBeVisible()
+  // At the top of the quote, seen without scrolling past the lines first.
+  await expect(page.getByText(request)).toBeInViewport()
+})
+
+test('arriving while the quote is open, clicking it shows the request without a reload', async ({
+  page,
+  browser,
+}) => {
+  const request = `Could you add wiper blades ${stamp}`
+  const { quoteUrl, shareUrl } = await sharedQuote(page, `E2E live notified quote ${stamp}`)
+
+  await page.goto(quoteUrl)
+  await settle(page)
+  await requestChanges(browser, shareUrl, request)
+
+  await clickNotification(page, 'Quote Changes Requested')
+  // Still this quote: the newest notification is the one that just arrived.
+  await expect(page).toHaveURL(quoteUrl)
+  await expect(page.getByText(request)).toBeVisible({ timeout: 15_000 })
+  await expect(page.getByText(request)).toBeInViewport()
+  await expect(page.getByText('changes_requested', { exact: true }).first()).toBeVisible()
+})

+ 115 - 0
e2e/support/db.ts

@@ -1220,3 +1220,118 @@ export async function plantJob(
     return { serviceRecordId: job.rows[0].id, vehicleId }
     return { serviceRecordId: job.rows[0].id, vehicleId }
   })
   })
 }
 }
+
+// ─── Notifications ───────────────────────────────────────────────────────────
+
+export interface PlantedNotification {
+  type: string
+  title: string
+  message: string
+  entityType: string
+  entityId: string
+  entityUrl: string
+}
+
+/**
+ * A notification written straight into the bell, with the address the code
+ * that raises it builds. Planting it rather than provoking it lets a spec
+ * follow links whose trigger needs a provider the harness cannot play (an
+ * inbound SMS, a Telegram webhook), and also links already stored in the old
+ * shape, which the pages still have to honour.
+ */
+export async function plantNotification(
+  organizationId: string,
+  n: PlantedNotification
+): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `insert into notifications (id, type, title, message, "entityType", "entityId", "entityUrl", read, "organizationId", "createdAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5, $6, false, $7, now())
+       returning id`,
+      [n.type, n.title, n.message, n.entityType, n.entityId, n.entityUrl, organizationId]
+    )
+    return result.rows[0].id
+  })
+}
+
+export async function deleteNotifications(ids: string[]): Promise<void> {
+  if (ids.length === 0) return
+  await withDb((db) => db.query('delete from notifications where id = any($1)', [ids]))
+}
+
+/** An inbound message on a customer's thread, as the webhook would have stored it. */
+export async function plantInboundMessage(
+  channel: 'sms' | 'telegram',
+  organizationId: string,
+  customerId: string,
+  body: string
+): Promise<void> {
+  await withDb((db) =>
+    channel === 'sms'
+      ? db.query(
+          `insert into sms_messages (id, direction, "fromNumber", "toNumber", body, status, "organizationId", "customerId", "createdAt", "updatedAt")
+           values (md5(random()::text || clock_timestamp()::text), 'inbound', '+4790000000', '+4790000001', $1, 'received', $2, $3, now(), now())`,
+          [body, organizationId, customerId]
+        )
+      : db.query(
+          `insert into telegram_messages (id, direction, "chatId", body, status, "organizationId", "customerId", "createdAt", "updatedAt")
+           values (md5(random()::text || clock_timestamp()::text), 'inbound', '777000', $1, 'received', $2, $3, now(), now())`,
+          [body, organizationId, customerId]
+        )
+  )
+}
+
+/**
+ * Links a customer to a Telegram chat and hands back what was there before.
+ * A real inbound Telegram message only ever comes from a linked chat, and the
+ * conversation shows nothing but "not connected yet" without one.
+ */
+export async function linkTelegramChat(
+  customerId: string,
+  chatId: string | null
+): Promise<string | null> {
+  return withDb(async (db) => {
+    const before = await db.query<{ telegramChatId: string | null }>(
+      'select "telegramChatId" from customers where id = $1',
+      [customerId]
+    )
+    await db.query('update customers set "telegramChatId" = $1 where id = $2', [chatId, customerId])
+    return before.rows[0]?.telegramChatId ?? null
+  })
+}
+
+export async function deleteMessagesWithBody(body: string): Promise<void> {
+  await withDb(async (db) => {
+    await db.query('delete from sms_messages where body = $1', [body])
+    await db.query('delete from telegram_messages where body = $1', [body])
+  })
+}
+
+/** A vehicle job with the customer it belongs to, taken from one row so the ids agree. */
+export async function jobWithCustomer(organizationId: string): Promise<{
+  vehicleId: string
+  serviceRecordId: string
+  customerId: string
+  customerName: string
+}> {
+  return withDb(async (db) => {
+    const result = await db.query<{
+      vehicleId: string
+      serviceRecordId: string
+      customerId: string
+      customerName: string
+    }>(
+      `select v.id as "vehicleId", s.id as "serviceRecordId", c.id as "customerId", c.name as "customerName"
+         from service_records s
+         join vehicles v on v.id = s."vehicleId"
+         join customers c on c.id = v."customerId"
+        where s."organizationId" = $1
+        order by s."createdAt" asc
+        limit 1`,
+      [organizationId]
+    )
+    const row = result.rows[0]
+    if (!row) throw new Error('the seeded workshop has no vehicle job with a customer')
+    return row
+  })
+}

+ 1 - 1
src/__tests__/features/reminder-alerts.test.ts

@@ -292,7 +292,7 @@ describe('reminder targets — vehicle, customer, or workshop', () => {
     expect(vi.mocked(notify)).toHaveBeenCalledWith(
     expect(vi.mocked(notify)).toHaveBeenCalledWith(
       expect.objectContaining({
       expect.objectContaining({
         message: 'EU-kontroll — Petter',
         message: 'EU-kontroll — Petter',
-        entityUrl: '/customers/cust-a',
+        entityUrl: '/reminders',
       })
       })
     )
     )
   })
   })

+ 10 - 1
src/app/(authenticated)/dashboard-client.tsx

@@ -71,6 +71,8 @@ import {
 } from '@/features/customers/Actions/customerActions'
 } from '@/features/customers/Actions/customerActions'
 import type { TireHotelSummary } from '@/features/tire-hotel/Actions/getTireHotelSummary'
 import type { TireHotelSummary } from '@/features/tire-hotel/Actions/getTireHotelSummary'
 import { acknowledgeQuoteResponse } from '@/features/quotes/Actions/quoteResponseActions'
 import { acknowledgeQuoteResponse } from '@/features/quotes/Actions/quoteResponseActions'
+import { markNotificationRead } from '@/features/notifications/Actions/notificationActions'
+import { useNotificationStore } from '@/features/notifications/store/notificationStore'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import { convertQuoteToServiceRecord, createQuote } from '@/features/quotes/Actions/quoteActions'
 import { convertQuoteToServiceRecord, createQuote } from '@/features/quotes/Actions/quoteActions'
 import type { ReactNode } from 'react'
 import type { ReactNode } from 'react'
@@ -1042,7 +1044,14 @@ export function DashboardClient({
                       <div
                       <div
                         key={n.id}
                         key={n.id}
                         className="flex items-center justify-between px-5 py-3 cursor-pointer hover:bg-muted/50 transition-colors"
                         className="flex items-center justify-between px-5 py-3 cursor-pointer hover:bg-muted/50 transition-colors"
-                        {...interactiveRow(() => router.push(n.entityUrl))}
+                        {...interactiveRow(() => {
+                          // Opening one from here reads it, as the bell does.
+                          if (!n.read) {
+                            useNotificationStore.getState().markRead(n.id)
+                            void markNotificationRead(n.id)
+                          }
+                          router.push(n.entityUrl)
+                        })}
                       >
                       >
                         <div className="flex items-center gap-3 min-w-0">
                         <div className="flex items-center gap-3 min-w-0">
                           <div
                           <div

+ 59 - 8
src/app/(authenticated)/messages/page.tsx

@@ -1,25 +1,75 @@
 import { Suspense } from 'react'
 import { Suspense } from 'react'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { redirect } from 'next/navigation'
 import { redirect } from 'next/navigation'
+import { db } from '@/lib/db'
 import { getScheduledMessages } from '@/features/scheduled-messages/Actions/scheduledMessageActions'
 import { getScheduledMessages } from '@/features/scheduled-messages/Actions/scheduledMessageActions'
 import { getAvailableChannels } from '@/features/scheduled-messages/Lib/availableChannels'
 import { getAvailableChannels } from '@/features/scheduled-messages/Lib/availableChannels'
-import { getInboxThreads, type InboxPage } from '@/features/messaging/Actions/inboxActions'
+import {
+  getInboxThreads,
+  type InboxPage,
+  type MessagingChannel,
+} from '@/features/messaging/Actions/inboxActions'
 import { MessagesPageClient } from '@/features/messaging/Components/MessagesPageClient'
 import { MessagesPageClient } from '@/features/messaging/Components/MessagesPageClient'
+import type { InboxFocus } from '@/features/messaging/Components/UnifiedInbox'
 import { PageHeader } from '@/components/page-header'
 import { PageHeader } from '@/components/page-header'
 import { listInspectionReminderCampaigns } from '@/features/inspection-reminders/Actions/inspectionReminderActions'
 import { listInspectionReminderCampaigns } from '@/features/inspection-reminders/Actions/inspectionReminderActions'
 
 
-export default async function MessagesPage() {
+const CHANNELS: MessagingChannel[] = ['sms', 'whatsapp', 'telegram']
+
+type SearchParams = Record<string, string | string[] | undefined>
+
+function param(sp: SearchParams, key: string): string | null {
+  const value = sp[key]
+  return typeof value === 'string' && value ? value : null
+}
+
+/**
+ * The conversation a link names. Notifications carry `customerId`, with the
+ * channel as `channel` or, in links already stored, as `tab=telegram` or
+ * `tab=whatsapp`; a WhatsApp message from a number with no customer carries
+ * `contact`. The customer is read here, inside the workshop, so a link to
+ * someone else's customer opens nothing.
+ */
+async function resolveFocus(sp: SearchParams, organizationId: string): Promise<InboxFocus | null> {
+  const named = param(sp, 'channel') ?? param(sp, 'tab')
+  const channel = CHANNELS.find((c) => c === named) ?? 'sms'
+  const customerId = param(sp, 'customerId')
+  const contact = param(sp, 'contact')
+
+  if (customerId) {
+    const customer = await db.customer.findFirst({
+      where: { id: customerId, organizationId },
+      select: { id: true, name: true, phone: true },
+    })
+    if (!customer) return null
+    return { channel, customerId: customer.id, name: customer.name, contact: customer.phone ?? '' }
+  }
+  if (contact && channel === 'whatsapp') {
+    return { channel, customerId: null, name: contact, contact }
+  }
+  return null
+}
+
+export default async function MessagesPage({
+  searchParams,
+}: {
+  searchParams: Promise<SearchParams>
+}) {
   const ctx = await getAuthContext()
   const ctx = await getAuthContext()
   if (!ctx) redirect('/auth/sign-in')
   if (!ctx) redirect('/auth/sign-in')
+  const sp = await searchParams
 
 
   // One inbox for every channel, so the page loads them together rather than
   // One inbox for every channel, so the page loads them together rather than
   // asking which one the workshop meant.
   // asking which one the workshop meant.
-  const [inboxResult, scheduledResult, messageChannels, campaignsResult] = await Promise.all([
-    getInboxThreads(),
-    getScheduledMessages(),
-    getAvailableChannels(ctx.organizationId),
-    listInspectionReminderCampaigns(),
-  ])
+  const [inboxResult, scheduledResult, messageChannels, campaignsResult, focus] = await Promise.all(
+    [
+      getInboxThreads(),
+      getScheduledMessages(),
+      getAvailableChannels(ctx.organizationId),
+      listInspectionReminderCampaigns(),
+      resolveFocus(sp, ctx.organizationId),
+    ]
+  )
 
 
   const inbox: InboxPage =
   const inbox: InboxPage =
     inboxResult.success && inboxResult.data
     inboxResult.success && inboxResult.data
@@ -42,6 +92,7 @@ export default async function MessagesPage() {
             initialScheduled={scheduled}
             initialScheduled={scheduled}
             campaigns={campaigns}
             campaigns={campaigns}
             availableChannels={messageChannels}
             availableChannels={messageChannels}
+            focus={focus}
           />
           />
         </Suspense>
         </Suspense>
       </div>
       </div>

+ 8 - 0
src/app/(authenticated)/vehicles/[id]/page.tsx

@@ -17,6 +17,7 @@ import { db } from '@/lib/db'
 import { getTireSetsForVehicle } from '@/features/tire-hotel/Actions/tireJobActions'
 import { getTireSetsForVehicle } from '@/features/tire-hotel/Actions/tireJobActions'
 import { VehicleDetailClient } from './vehicle-detail-client'
 import { VehicleDetailClient } from './vehicle-detail-client'
 import { PageHeader } from '@/components/page-header'
 import { PageHeader } from '@/components/page-header'
+import { redirect } from 'next/navigation'
 
 
 export default async function VehicleDetailPage({
 export default async function VehicleDetailPage({
   params,
   params,
@@ -28,6 +29,13 @@ export default async function VehicleDetailPage({
   const { id } = await params
   const { id } = await params
   const sp = await searchParams
   const sp = await searchParams
 
 
+  // Payment notifications used to link here with the invoice as `record`,
+  // which this page never read, so they opened the vehicle instead of the
+  // invoice. Those links are still in people's bells.
+  if (typeof sp.record === 'string' && sp.record) {
+    redirect(`/vehicles/${id}/service/${sp.record}`)
+  }
+
   const page = Number(sp.page) || 1
   const page = Number(sp.page) || 1
   const pageSize = Number(sp.pageSize) || 10
   const pageSize = Number(sp.pageSize) || 10
   const search = typeof sp.search === 'string' ? sp.search : ''
   const search = typeof sp.search === 'string' ? sp.search : ''

+ 2 - 3
src/app/api/public/share/invoice/[orgId]/[token]/verify/route.ts

@@ -6,6 +6,7 @@ import { paymentMatchesRecord } from '@/lib/payment-providers/attribution'
 import { recordVendorPayment } from '@/lib/payment-providers/record-payment'
 import { recordVendorPayment } from '@/lib/payment-providers/record-payment'
 import { rateLimit } from '@/lib/rate-limit'
 import { rateLimit } from '@/lib/rate-limit'
 import { notify } from '@/lib/notify'
 import { notify } from '@/lib/notify'
+import { serviceRecordHref } from '@/lib/service-record'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 
 
 const verifySchema = z.object({
 const verifySchema = z.object({
@@ -94,9 +95,7 @@ export async function POST(
         message: `${(record.customer ?? record.vehicle?.customer)?.name || 'A customer'} paid ${result.amount.toFixed(2)} for invoice ${record.invoiceNumber || record.title}`,
         message: `${(record.customer ?? record.vehicle?.customer)?.name || 'A customer'} paid ${result.amount.toFixed(2)} for invoice ${record.invoiceNumber || record.title}`,
         entityType: 'invoice',
         entityType: 'invoice',
         entityId: record.id,
         entityId: record.id,
-        entityUrl: record.vehicle
-          ? `/vehicles/${record.vehicle.id}?tab=service&record=${record.id}`
-          : `/sales/${record.id}`,
+        entityUrl: serviceRecordHref(record),
       })
       })
     }
     }
 
 

+ 7 - 3
src/features/inspection-reminders/Actions/bookingActions.ts

@@ -341,9 +341,13 @@ export async function cancelBooking(rawToken: string) {
         timeZone: (await loadInspectionReminderSettings(send.organizationId)).timeZone,
         timeZone: (await loadInspectionReminderSettings(send.organizationId)).timeZone,
       }).format(booking.start),
       }).format(booking.start),
     }),
     }),
-    entityType: 'vehicle',
-    entityId: send.vehicleId,
-    entityUrl: `/vehicles/${send.vehicleId}`,
+    // A cancelled request stays listed on the customer, marked cancelled; a
+    // booked slot is deleted, which leaves the vehicle as the place to look.
+    entityType: send.bookedServiceRequestId ? 'service_request' : 'vehicle',
+    entityId: send.bookedServiceRequestId ?? send.vehicleId,
+    entityUrl: send.bookedServiceRequestId
+      ? `/customers/${send.customerId}?tab=requests`
+      : `/vehicles/${send.vehicleId}`,
   })
   })
   return { ok: true }
   return { ok: true }
 }
 }

+ 5 - 1
src/features/messaging/Components/MessagesPageClient.tsx

@@ -19,7 +19,7 @@ import {
 } from '@/features/scheduled-messages/Actions/scheduledMessageActions'
 } from '@/features/scheduled-messages/Actions/scheduledMessageActions'
 import type { MessageChannel } from '@/features/scheduled-messages/Schema/scheduledMessageSchema'
 import type { MessageChannel } from '@/features/scheduled-messages/Schema/scheduledMessageSchema'
 import type { InboxThread, MessagingChannel } from '../Actions/inboxActions'
 import type { InboxThread, MessagingChannel } from '../Actions/inboxActions'
-import { UnifiedInbox } from './UnifiedInbox'
+import { type InboxFocus, UnifiedInbox } from './UnifiedInbox'
 
 
 /**
 /**
  * The Messages page: one inbox across every channel, and the scheduled queue.
  * The Messages page: one inbox across every channel, and the scheduled queue.
@@ -38,6 +38,7 @@ export function MessagesPageClient({
   initialScheduled = [],
   initialScheduled = [],
   campaigns = [],
   campaigns = [],
   availableChannels = [],
   availableChannels = [],
+  focus = null,
 }: {
 }: {
   threads: InboxThread[]
   threads: InboxThread[]
   /** Where the inbox's next page starts. */
   /** Where the inbox's next page starts. */
@@ -47,6 +48,8 @@ export function MessagesPageClient({
   initialScheduled?: ScheduledMessageListItem[]
   initialScheduled?: ScheduledMessageListItem[]
   campaigns?: CampaignListItem[]
   campaigns?: CampaignListItem[]
   availableChannels?: MessageChannel[]
   availableChannels?: MessageChannel[]
+  /** The conversation the address names, if any. */
+  focus?: InboxFocus | null
 }) {
 }) {
   const t = useTranslations('messaging.inbox')
   const t = useTranslations('messaging.inbox')
   const tp = useTranslations('messages.page')
   const tp = useTranslations('messages.page')
@@ -148,6 +151,7 @@ export function MessagesPageClient({
           </div>
           </div>
         ) : (
         ) : (
           <UnifiedInbox
           <UnifiedInbox
+            focus={focus}
             threads={threads}
             threads={threads}
             initialCursor={initialCursor}
             initialCursor={initialCursor}
             channels={channels}
             channels={channels}

+ 43 - 0
src/features/messaging/Components/UnifiedInbox.tsx

@@ -76,6 +76,19 @@ import { useDebouncedSearch } from '@/hooks/use-debounced-search'
  * own conversation component rather than sharing a compose box.
  * own conversation component rather than sharing a compose box.
  */
  */
 
 
+/**
+ * A conversation named by the page's address, so a link can open it: a
+ * notification about a message lands on that thread instead of on an inbox
+ * with nothing selected. The name comes from the server, because the thread
+ * may not be on the first page of the list.
+ */
+export interface InboxFocus {
+  channel: MessagingChannel
+  customerId: string | null
+  name: string
+  contact: string
+}
+
 /** Channels a workshop can start a conversation on. */
 /** Channels a workshop can start a conversation on. */
 const INITIABLE: MessagingChannel[] = ['sms', 'whatsapp']
 const INITIABLE: MessagingChannel[] = ['sms', 'whatsapp']
 
 
@@ -95,6 +108,7 @@ export function UnifiedInbox({
   initialCursor = null,
   initialCursor = null,
   channels,
   channels,
   onChanged,
   onChanged,
+  focus = null,
 }: {
 }: {
   threads: InboxThread[]
   threads: InboxThread[]
   /** Where the next page starts, or null when the first page is all of it. */
   /** Where the next page starts, or null when the first page is all of it. */
@@ -102,6 +116,8 @@ export function UnifiedInbox({
   channels: MessagingChannel[]
   channels: MessagingChannel[]
   /** Asks the page to reload its server data after something is sent. */
   /** Asks the page to reload its server data after something is sent. */
   onChanged?: () => void
   onChanged?: () => void
+  /** The conversation the address asks for, opened on arrival. */
+  focus?: InboxFocus | null
 }) {
 }) {
   const t = useTranslations('messaging.inbox')
   const t = useTranslations('messaging.inbox')
   const router = useRouter()
   const router = useRouter()
@@ -244,6 +260,33 @@ export function UnifiedInbox({
     [markRead]
     [markRead]
   )
   )
 
 
+  // Opened whenever the address names a different conversation, which is also
+  // what a notification clicked while this page is already open does.
+  const focusKey = focus ? `${focus.channel}:${focus.customerId ?? focus.contact}` : null
+  useEffect(() => {
+    if (!focus || !focusKey) return
+    const existing = threads.find(
+      (thread) =>
+        thread.channel === focus.channel &&
+        (focus.customerId
+          ? thread.customerId === focus.customerId
+          : !thread.customerId && thread.contact === focus.contact)
+    )
+    select(
+      existing ?? {
+        key: focusKey,
+        channel: focus.channel,
+        customerId: focus.customerId,
+        name: focus.name,
+        contact: focus.contact,
+        lastMessage: '',
+        lastDirection: 'inbound',
+        lastAt: new Date(0).toISOString(),
+        unread: 0,
+      }
+    )
+  }, [focusKey])
+
   /** Removes our copy of a conversation; the customer's phone keeps theirs. */
   /** Removes our copy of a conversation; the customer's phone keeps theirs. */
   const confirmDelete = async () => {
   const confirmDelete = async () => {
     const thread = deleteTarget
     const thread = deleteTarget

+ 7 - 1
src/features/notifications/Components/NotificationPanel.tsx

@@ -69,7 +69,13 @@ export function NotificationPanel() {
     }
     }
     setPanelOpen(false)
     setPanelOpen(false)
     setTimeout(() => {
     setTimeout(() => {
-      router.push(entityUrl)
+      // Pushing the address already open is not a navigation: nothing is
+      // fetched, so a notification about the page on screen appeared to do
+      // nothing. Refresh it instead, which brings in what the notification
+      // is about.
+      const here = `${window.location.pathname}${window.location.search}`
+      if (entityUrl === here) router.refresh()
+      else router.push(entityUrl)
     }, 300)
     }, 300)
   }
   }
 
 

+ 94 - 0
src/features/quotes/Components/QuoteCustomerResponse.tsx

@@ -0,0 +1,94 @@
+'use client'
+
+import { Check, Loader2, MessageSquare } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { useFormatDate } from '@/lib/use-format-date'
+
+/**
+ * What the customer said back, at the top of the quote.
+ *
+ * It used to sit in the right column below the details and the custom fields,
+ * and below every line on a phone, so a workshop opening the quote from a
+ * "Changes requested" notification had to scroll to find the one thing it came
+ * for. It is not part of the form, so a locked quote leaves it usable.
+ */
+export function QuoteCustomerResponse({
+  status,
+  message,
+  respondedAt,
+  resolving,
+  onResolve,
+  t,
+}: {
+  status: 'changes_requested' | 'accepted' | string
+  message: string
+  respondedAt: Date
+  resolving: boolean
+  onResolve: () => void
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  t: (key: string, values?: any) => string
+}) {
+  // The workshop's zone, so the server's render and the browser's agree.
+  const { formatDateTime } = useFormatDate()
+  const changes = status === 'changes_requested'
+
+  return (
+    <div
+      className={`rounded-lg border p-3 ${
+        changes
+          ? '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 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
+        <div className="min-w-0 space-y-1">
+          <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
+            <h3
+              className={`flex items-center gap-1.5 text-sm font-semibold ${
+                changes
+                  ? 'text-orange-700 dark:text-orange-400'
+                  : 'text-emerald-700 dark:text-emerald-400'
+              }`}
+            >
+              <MessageSquare className="h-3.5 w-3.5" />
+              {changes ? t('page.changesRequested') : t('page.quoteAccepted')}
+            </h3>
+            <span
+              className={`text-xs ${
+                changes
+                  ? 'text-orange-500 dark:text-orange-500'
+                  : 'text-emerald-500 dark:text-emerald-500'
+              }`}
+            >
+              {formatDateTime(respondedAt)}
+            </span>
+          </div>
+          <p
+            className={`whitespace-pre-line break-words text-sm ${
+              changes
+                ? 'text-orange-600 dark:text-orange-400'
+                : 'text-emerald-600 dark:text-emerald-400'
+            }`}
+          >
+            &ldquo;{message}&rdquo;
+          </p>
+        </div>
+        <Button
+          type="button"
+          variant="outline"
+          size="sm"
+          className="shrink-0"
+          disabled={resolving}
+          onClick={onResolve}
+        >
+          {resolving ? (
+            <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+          ) : (
+            <Check className="mr-1 h-3.5 w-3.5" />
+          )}
+          {t('page.markResolved')}
+        </Button>
+      </div>
+    </div>
+  )
+}

+ 17 - 0
src/features/quotes/Components/QuotePageClient.tsx

@@ -46,6 +46,7 @@ import type { InventoryPartOption } from '@/features/vehicles/Components/service
 import { QuoteLaborEditor } from './QuoteLaborEditor'
 import { QuoteLaborEditor } from './QuoteLaborEditor'
 import { QuoteNotesEditor } from './QuoteNotesEditor'
 import { QuoteNotesEditor } from './QuoteNotesEditor'
 import { QuoteRightColumn } from './QuoteRightColumn'
 import { QuoteRightColumn } from './QuoteRightColumn'
+import { QuoteCustomerResponse } from './QuoteCustomerResponse'
 import { VehicleCombobox } from './VehicleCombobox'
 import { VehicleCombobox } from './VehicleCombobox'
 import { lineTotal } from '@/features/inventory/Lib/partPricing'
 import { lineTotal } from '@/features/inventory/Lib/partPricing'
 
 
@@ -394,6 +395,22 @@ export function QuotePageClient({
         </div>
         </div>
       </div>
       </div>
 
 
+      {/* Above everything else: it is usually why the quote was opened. */}
+      {quote.customerMessage &&
+        !quote.responseDismissedAt &&
+        (state.status === 'changes_requested' || state.status === 'accepted') && (
+          <div className="shrink-0 px-4 pt-3">
+            <QuoteCustomerResponse
+              status={state.status}
+              message={quote.customerMessage}
+              respondedAt={quote.updatedAt}
+              resolving={state.resolving}
+              onResolve={state.handleResolveResponse}
+              t={t}
+            />
+          </div>
+        )}
+
       {(lockState.locked || lockState.unlockedAt) && (
       {(lockState.locked || lockState.unlockedAt) && (
         <div className="shrink-0 px-4 pt-3">
         <div className="shrink-0 px-4 pt-3">
           <DocumentLockBanner
           <DocumentLockBanner

+ 13 - 78
src/features/quotes/Components/QuoteRightColumn.tsx

@@ -13,23 +13,13 @@ import {
   SelectTrigger,
   SelectTrigger,
   SelectValue,
   SelectValue,
 } from '@/components/ui/select'
 } from '@/components/ui/select'
-import {
-  ArrowRight,
-  Car,
-  Check,
-  ClipboardCheck,
-  Loader2,
-  MessageSquare,
-  Users,
-  X,
-} from 'lucide-react'
+import { ArrowRight, Car, ClipboardCheck, Users, X } from 'lucide-react'
 import { SharedLinkCard } from '@/components/shared-link-card'
 import { SharedLinkCard } from '@/components/shared-link-card'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { netLineTotal } from '@/lib/tax'
 import { netLineTotal } from '@/lib/tax'
 import { taxComponentLabel } from '@/lib/tax-components'
 import { taxComponentLabel } from '@/lib/tax-components'
 import { CustomFieldsForm } from '@/features/custom-fields/Components/CustomFieldsForm'
 import { CustomFieldsForm } from '@/features/custom-fields/Components/CustomFieldsForm'
 import { type LockReason, quoteStatusKeepsLock } from '@/lib/document-lock'
 import { type LockReason, quoteStatusKeepsLock } from '@/lib/document-lock'
-import { useFormatDate } from '@/lib/use-format-date'
 import type { QuoteFormState } from './useQuoteFormState'
 import type { QuoteFormState } from './useQuoteFormState'
 import type { QuoteRecord } from './quote-page-types'
 import type { QuoteRecord } from './quote-page-types'
 import { VehicleCombobox } from './VehicleCombobox'
 import { VehicleCombobox } from './VehicleCombobox'
@@ -61,7 +51,6 @@ export const QuoteRightColumn = memo(function QuoteRightColumn({
   lockReason,
   lockReason,
 }: QuoteRightColumnProps) {
 }: QuoteRightColumnProps) {
   const formatCurrency = useFormatCurrency()
   const formatCurrency = useFormatCurrency()
-  const { formatDateTime } = useFormatDate()
   const [validUntil, setValidUntil] = useState(state.defaultValidDate)
   const [validUntil, setValidUntil] = useState(state.defaultValidDate)
   const locked = lockReason !== null
   const locked = lockReason !== null
   const statusAllowed = (value: string) => !lockReason || quoteStatusKeepsLock(lockReason, value)
   const statusAllowed = (value: string) => !lockReason || quoteStatusKeepsLock(lockReason, value)
@@ -98,9 +87,8 @@ export const QuoteRightColumn = memo(function QuoteRightColumn({
       )}
       )}
 
 
       {/* The fields that edit the quote sit in fieldsets a lock disables. The
       {/* 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. */}
+          actions do not: converting, the shared link and the status, because a
+          disabled fieldset disables the buttons inside it too. */}
       <fieldset disabled={locked} className="contents">
       <fieldset disabled={locked} className="contents">
         {/* Vehicle & Customer */}
         {/* Vehicle & Customer */}
         <div className="rounded-lg border p-3 space-y-3">
         <div className="rounded-lg border p-3 space-y-3">
@@ -258,6 +246,16 @@ export const QuoteRightColumn = memo(function QuoteRightColumn({
                 <SelectItem value="rejected" disabled={!statusAllowed('rejected')}>
                 <SelectItem value="rejected" disabled={!statusAllowed('rejected')}>
                   {t('details.statusRejected')}
                   {t('details.statusRejected')}
                 </SelectItem>
                 </SelectItem>
+                {/* Statuses the customer or a conversion sets, never picked by
+                      hand. Listed so the select can show them: with no option
+                      to match, it reports an empty value and the page would
+                      save a status the server refuses. */}
+                <SelectItem value="changes_requested" disabled>
+                  {t('page.changesRequested')}
+                </SelectItem>
+                <SelectItem value="converted" disabled>
+                  {t('list.statusConverted')}
+                </SelectItem>
               </SelectContent>
               </SelectContent>
             </Select>
             </Select>
           </div>
           </div>
@@ -299,69 +297,6 @@ export const QuoteRightColumn = memo(function QuoteRightColumn({
         />
         />
       </fieldset>
       </fieldset>
 
 
-      {/* Customer Response */}
-      {quote.customerMessage &&
-        !quote.responseDismissedAt &&
-        (state.status === 'changes_requested' || state.status === 'accepted') && (
-          <div
-            className={`rounded-lg border p-3 space-y-2 ${
-              state.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 ${
-                  state.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" />
-                {state.status === 'changes_requested'
-                  ? t('page.changesRequested')
-                  : t('page.quoteAccepted')}
-              </h3>
-              <span
-                className={`text-[10px] ${
-                  state.status === 'changes_requested'
-                    ? 'text-orange-500 dark:text-orange-500'
-                    : 'text-emerald-500 dark:text-emerald-500'
-                }`}
-              >
-                {/* 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
-              className={`text-sm ${
-                state.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={state.resolving}
-              onClick={state.handleResolveResponse}
-            >
-              {state.resolving ? (
-                <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
-              ) : (
-                <Check className="mr-1 h-3.5 w-3.5" />
-              )}
-              {t('page.markResolved')}
-            </Button>
-          </div>
-        )}
-
       {/* Totals */}
       {/* Totals */}
       <fieldset disabled={locked} className="contents">
       <fieldset disabled={locked} className="contents">
         {(() => {
         {(() => {

+ 9 - 0
src/features/quotes/Components/useQuoteFormState.ts

@@ -65,6 +65,12 @@ export function useQuoteFormState({
   const [saving, setSaving] = useState(false)
   const [saving, setSaving] = useState(false)
   const [downloading, setDownloading] = useState(false)
   const [downloading, setDownloading] = useState(false)
   const [status, setStatus] = useState(quote.status)
   const [status, setStatus] = useState(quote.status)
+  // The customer can answer while the quote is open. A refresh brings the new
+  // status in as a prop, and the page has to show it rather than the status it
+  // was opened with; a status chosen here and not yet saved is left alone.
+  useEffect(() => {
+    setStatus(quote.status)
+  }, [quote.status])
   const [customerId, setCustomerId] = useState(quote.customer?.id || '')
   const [customerId, setCustomerId] = useState(quote.customer?.id || '')
   const [vehicleId, setVehicleId] = useState(quote.vehicle?.id || '')
   const [vehicleId, setVehicleId] = useState(quote.vehicle?.id || '')
   const [partItems, setPartItems] = useState<QuotePartInput[]>(
   const [partItems, setPartItems] = useState<QuotePartInput[]>(
@@ -175,6 +181,9 @@ export function useQuoteFormState({
   // the lock, which is the owner-or-admin unlock by another route.
   // the lock, which is the owner-or-admin unlock by another route.
   const [changingStatus, setChangingStatus] = useState(false)
   const [changingStatus, setChangingStatus] = useState(false)
   const changeStatus = async (next: string) => {
   const changeStatus = async (next: string) => {
+    // The select also reports a value when the status changes under it, as
+    // an empty string when no option matches; neither is somebody choosing.
+    if (!next || next === status) return
     if (!locked) {
     if (!locked) {
       setStatus(next)
       setStatus(next)
       markDirty()
       markDirty()

+ 6 - 1
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -2,7 +2,7 @@
 
 
 import { DocumentLockBanner } from '@/components/document-lock-banner'
 import { DocumentLockBanner } from '@/components/document-lock-banner'
 import { setInvoiceEditUnlocked } from '@/features/settings/Actions/documentLockActions'
 import { setInvoiceEditUnlocked } from '@/features/settings/Actions/documentLockActions'
-import { useState, useCallback, useMemo, useRef } from 'react'
+import { useState, useCallback, useMemo, useRef, useEffect } from 'react'
 import { useRouter } from 'next/navigation'
 import { useRouter } from 'next/navigation'
 import { sendInvoiceEmail } from '@/features/email/Actions/emailActions'
 import { sendInvoiceEmail } from '@/features/email/Actions/emailActions'
 import { updateServiceStatus } from '@/features/vehicles/Actions/serviceActions'
 import { updateServiceStatus } from '@/features/vehicles/Actions/serviceActions'
@@ -115,6 +115,11 @@ export function ServicePageClient({
       : 'details'
       : 'details'
 
 
   const [activeTab, setActiveTab] = useState<ServiceTab>(resolvedInitialTab)
   const [activeTab, setActiveTab] = useState<ServiceTab>(resolvedInitialTab)
+  // A link to this record with another tab (feedback on a status report, say)
+  // changes only the query, so the page is not remounted and has to follow it.
+  useEffect(() => {
+    setActiveTab(resolvedInitialTab)
+  }, [resolvedInitialTab])
 
 
   const handleTabChange = useCallback((tab: ServiceTab) => {
   const handleTabChange = useCallback((tab: ServiceTab) => {
     setActiveTab(tab)
     setActiveTab(tab)

+ 2 - 1
src/lib/cron/reminder-alerts.ts

@@ -35,7 +35,8 @@ function targetLabel(r: DueReminder) {
 
 
 function targetUrl(r: DueReminder) {
 function targetUrl(r: DueReminder) {
   if (r.vehicle) return `/vehicles/${r.vehicle.id}?tab=reminders`
   if (r.vehicle) return `/vehicles/${r.vehicle.id}?tab=reminders`
-  if (r.customer) return `/customers/${r.customer.id}`
+  // The customer page has no reminders of its own, so a customer's reminder
+  // is found on the reminders list rather than on a page that never shows it.
   return '/reminders'
   return '/reminders'
 }
 }