Просмотр исходного кода

Show quote statuses as words, and highlight quotes that need attention (#399)

The raw status (changes_requested) spilled out of the quotes table and read in
English everywhere. Quotes with changes requested or accepted but not converted
get a tinted row and a Needs attention tab.
Bernt Christian Egeland 2 недель назад
Родитель
Сommit
e6fbc2ec2a

+ 11 - 2
e2e/specs/notifications/quote-response.spec.ts

@@ -55,7 +55,8 @@ test('opened from the bell, a change request lands on the quote showing the requ
   browser,
 }) => {
   const request = `Please use a genuine filter ${stamp}`
-  const { quoteUrl, shareUrl } = await sharedQuote(page, `E2E notified quote ${stamp}`)
+  const title = `E2E notified quote ${stamp}`
+  const { quoteUrl, shareUrl } = await sharedQuote(page, title)
   await requestChanges(browser, shareUrl, request)
 
   await page.goto('/')
@@ -64,6 +65,14 @@ test('opened from the bell, a change request lands on the quote showing the requ
 
   await page.waitForURL(quoteUrl, { timeout: 15_000 })
   await expect(page.getByText(request)).toBeVisible()
+
+  // And on the list, under "Needs attention", highlighted as a change request.
+  await page.goto(`/quotes?status=attention&search=${encodeURIComponent(title)}`)
+  await settle(page)
+  const row = page.getByRole('row').filter({ hasText: title })
+  await expect(row).toHaveAttribute('data-attention', 'changes_requested')
+  await expect(page.getByRole('button', { name: /Needs attention/ })).toBeVisible()
+  await page.goto(quoteUrl)
   // At the top of the quote, seen without scrolling past the lines first.
   await expect(page.getByText(request)).toBeInViewport()
 })
@@ -84,5 +93,5 @@ test('arriving while the quote is open, clicking it shows the request without a
   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()
+  await expect(page.getByText('Changes Requested', { exact: true }).first()).toBeVisible()
 })

+ 1 - 1
e2e/specs/quotes/lock.spec.ts

@@ -61,7 +61,7 @@ async function convertToWorkOrder(page: Page, quoteUrl: string, partName: string
   // 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.getByText('Converted', { exact: true }).first()).toBeVisible()
   await expect(
     page.getByRole('button', { name: 'Convert to Work Order', exact: true })
   ).toHaveCount(0)

+ 10 - 0
messages/de/quotes.json

@@ -1,6 +1,7 @@
 {
   "list": {
     "statusAll": "Alle",
+    "statusAttention": "Erfordert Aufmerksamkeit",
     "statusDraft": "Entwurf",
     "statusSent": "Gesendet",
     "statusAccepted": "Akzeptiert",
@@ -212,5 +213,14 @@
     "failedDelete": "Datei konnte nicht gelöscht werden",
     "failedUpdate": "Aktualisierung fehlgeschlagen",
     "pdf": "PDF"
+  },
+  "statusLabels": {
+    "draft": "Entwurf",
+    "sent": "Gesendet",
+    "accepted": "Akzeptiert",
+    "rejected": "Abgelehnt",
+    "expired": "Abgelaufen",
+    "converted": "Umgewandelt",
+    "changes_requested": "Änderungen angefordert"
   }
 }

+ 10 - 0
messages/en/quotes.json

@@ -1,6 +1,7 @@
 {
   "list": {
     "statusAll": "All",
+    "statusAttention": "Needs attention",
     "statusDraft": "Draft",
     "statusSent": "Sent",
     "statusAccepted": "Accepted",
@@ -212,5 +213,14 @@
     "failedDelete": "Failed to delete file",
     "failedUpdate": "Failed to update",
     "pdf": "PDF"
+  },
+  "statusLabels": {
+    "draft": "Draft",
+    "sent": "Sent",
+    "accepted": "Accepted",
+    "rejected": "Rejected",
+    "expired": "Expired",
+    "converted": "Converted",
+    "changes_requested": "Changes Requested"
   }
 }

+ 10 - 0
messages/es/quotes.json

@@ -1,6 +1,7 @@
 {
   "list": {
     "statusAll": "Todos",
+    "statusAttention": "Requiere atención",
     "statusDraft": "Borrador",
     "statusSent": "Enviado",
     "statusAccepted": "Aceptado",
@@ -212,5 +213,14 @@
     "failedDelete": "Error al eliminar el archivo",
     "failedUpdate": "Error al actualizar",
     "pdf": "PDF"
+  },
+  "statusLabels": {
+    "draft": "Borrador",
+    "sent": "Enviado",
+    "accepted": "Aceptado",
+    "rejected": "Rechazado",
+    "expired": "Caducado",
+    "converted": "Convertido",
+    "changes_requested": "Cambios solicitados"
   }
 }

+ 10 - 0
messages/fr/quotes.json

@@ -1,6 +1,7 @@
 {
   "list": {
     "statusAll": "Tous",
+    "statusAttention": "À traiter",
     "statusDraft": "Brouillon",
     "statusSent": "Envoyé",
     "statusAccepted": "Accepté",
@@ -212,5 +213,14 @@
     "failedDelete": "Échec de la suppression du fichier",
     "failedUpdate": "Échec de la mise à jour",
     "pdf": "PDF"
+  },
+  "statusLabels": {
+    "draft": "Brouillon",
+    "sent": "Envoyé",
+    "accepted": "Accepté",
+    "rejected": "Rejeté",
+    "expired": "Expiré",
+    "converted": "Converti",
+    "changes_requested": "Modifications demandées"
   }
 }

+ 10 - 0
messages/it/quotes.json

@@ -1,6 +1,7 @@
 {
   "list": {
     "statusAll": "Tutti",
+    "statusAttention": "Da gestire",
     "statusDraft": "Bozza",
     "statusSent": "Inviato",
     "statusAccepted": "Accettato",
@@ -212,5 +213,14 @@
     "failedDelete": "Eliminazione file non riuscita",
     "failedUpdate": "Aggiornamento non riuscito",
     "pdf": "PDF"
+  },
+  "statusLabels": {
+    "draft": "Bozza",
+    "sent": "Inviato",
+    "accepted": "Accettato",
+    "rejected": "Rifiutato",
+    "expired": "Scaduto",
+    "converted": "Convertito",
+    "changes_requested": "Modifiche richieste"
   }
 }

+ 10 - 0
messages/lt/quotes.json

@@ -1,6 +1,7 @@
 {
   "list": {
     "statusAll": "Visi",
+    "statusAttention": "Reikia dėmesio",
     "statusDraft": "Juodraštis",
     "statusSent": "Išsiųstas",
     "statusAccepted": "Priimtas",
@@ -212,5 +213,14 @@
     "failedDelete": "Nepavyko ištrinti failo",
     "failedUpdate": "Nepavyko atnaujinti",
     "pdf": "PDF"
+  },
+  "statusLabels": {
+    "draft": "Juodraštis",
+    "sent": "Išsiųstas",
+    "accepted": "Priimtas",
+    "rejected": "Atmestas",
+    "expired": "Pasibaigęs",
+    "converted": "Konvertuotas",
+    "changes_requested": "Prašomi pakeitimai"
   }
 }

+ 10 - 0
messages/nb/quotes.json

@@ -1,6 +1,7 @@
 {
   "list": {
     "statusAll": "Alle",
+    "statusAttention": "Trenger oppfølging",
     "statusDraft": "Utkast",
     "statusSent": "Sendt",
     "statusAccepted": "Akseptert",
@@ -212,5 +213,14 @@
     "failedDelete": "Kunne ikke slette filen",
     "failedUpdate": "Kunne ikke oppdatere",
     "pdf": "PDF"
+  },
+  "statusLabels": {
+    "draft": "Utkast",
+    "sent": "Sendt",
+    "accepted": "Akseptert",
+    "rejected": "Avslått",
+    "expired": "Utløpt",
+    "converted": "Konvertert",
+    "changes_requested": "Endringer forespurt"
   }
 }

+ 10 - 0
messages/nl/quotes.json

@@ -1,6 +1,7 @@
 {
   "list": {
     "statusAll": "Alle",
+    "statusAttention": "Actie vereist",
     "statusDraft": "Concept",
     "statusSent": "Verzonden",
     "statusAccepted": "Geaccepteerd",
@@ -212,5 +213,14 @@
     "failedDelete": "Bestand verwijderen mislukt",
     "failedUpdate": "Bijwerken mislukt",
     "pdf": "PDF"
+  },
+  "statusLabels": {
+    "draft": "Concept",
+    "sent": "Verzonden",
+    "accepted": "Geaccepteerd",
+    "rejected": "Afgewezen",
+    "expired": "Verlopen",
+    "converted": "Geconverteerd",
+    "changes_requested": "Wijzigingen gevraagd"
   }
 }

+ 10 - 0
messages/pl/quotes.json

@@ -1,6 +1,7 @@
 {
   "list": {
     "statusAll": "Wszystkie",
+    "statusAttention": "Wymaga uwagi",
     "statusDraft": "Wersja robocza",
     "statusSent": "Wysłana",
     "statusAccepted": "Zaakceptowana",
@@ -212,5 +213,14 @@
     "failedDelete": "Nie udało się usunąć pliku",
     "failedUpdate": "Nie udało się zaktualizować",
     "pdf": "PDF"
+  },
+  "statusLabels": {
+    "draft": "Wersja robocza",
+    "sent": "Wysłana",
+    "accepted": "Zaakceptowana",
+    "rejected": "Odrzucona",
+    "expired": "Wygasła",
+    "converted": "Przekonwertowana",
+    "changes_requested": "Zmiany wymagane"
   }
 }

+ 10 - 0
messages/pt-BR/quotes.json

@@ -1,6 +1,7 @@
 {
   "list": {
     "statusAll": "Todos",
+    "statusAttention": "Requer atenção",
     "statusDraft": "Rascunho",
     "statusSent": "Enviado",
     "statusAccepted": "Aceito",
@@ -212,5 +213,14 @@
     "failedDelete": "Falha ao excluir o arquivo",
     "failedUpdate": "Falha ao atualizar",
     "pdf": "PDF"
+  },
+  "statusLabels": {
+    "draft": "Rascunho",
+    "sent": "Enviado",
+    "accepted": "Aceito",
+    "rejected": "Rejeitado",
+    "expired": "Expirado",
+    "converted": "Convertido",
+    "changes_requested": "Alterações solicitadas"
   }
 }

+ 10 - 0
messages/ru/quotes.json

@@ -1,6 +1,7 @@
 {
   "list": {
     "statusAll": "Все",
+    "statusAttention": "Требует внимания",
     "statusDraft": "Черновик",
     "statusSent": "Отправлено",
     "statusAccepted": "Принято",
@@ -212,5 +213,14 @@
     "failedDelete": "Не удалось удалить файл",
     "failedUpdate": "Не удалось обновить",
     "pdf": "PDF"
+  },
+  "statusLabels": {
+    "draft": "Черновик",
+    "sent": "Отправлено",
+    "accepted": "Принято",
+    "rejected": "Отклонено",
+    "expired": "Истекло",
+    "converted": "Конвертировано",
+    "changes_requested": "Запрошены изменения"
   }
 }

+ 10 - 0
messages/tr/quotes.json

@@ -1,6 +1,7 @@
 {
   "list": {
     "statusAll": "Tümü",
+    "statusAttention": "İlgi bekliyor",
     "statusDraft": "Taslak",
     "statusSent": "Gönderildi",
     "statusAccepted": "Kabul Edildi",
@@ -212,5 +213,14 @@
     "failedDelete": "Dosya silinemedi",
     "failedUpdate": "Güncelleme başarısız",
     "pdf": "PDF"
+  },
+  "statusLabels": {
+    "draft": "Taslak",
+    "sent": "Gönderildi",
+    "accepted": "Kabul Edildi",
+    "rejected": "Reddedildi",
+    "expired": "Süresi doldu",
+    "converted": "Dönüştürüldü",
+    "changes_requested": "Değişiklik Talep Edildi"
   }
 }

+ 25 - 7
src/app/(authenticated)/quotes/quotes-client.tsx

@@ -8,6 +8,12 @@ import { useDebouncedSearch } from '@/hooks/use-debounced-search'
 import { useState, useCallback, useTransition, useEffect } from 'react'
 import { useRouter, usePathname, useSearchParams } from 'next/navigation'
 import { useTranslations } from 'next-intl'
+import { cn } from '@/lib/utils'
+import {
+  attentionClasses,
+  quoteNeedsAttention,
+  quoteStatusLabel,
+} from '@/features/quotes/Lib/quoteStatus'
 import { useFormatDate } from '@/lib/use-format-date'
 import { Badge } from '@/components/ui/badge'
 import { Button } from '@/components/ui/button'
@@ -74,6 +80,7 @@ interface PaginatedData {
 
 const statusTabs = [
   { key: 'all', titleKey: 'list.statusAll' },
+  { key: 'attention', titleKey: 'list.statusAttention' },
   { key: 'draft', titleKey: 'list.statusDraft' },
   { key: 'sent', titleKey: 'list.statusSent' },
   { key: 'accepted', titleKey: 'list.statusAccepted' },
@@ -88,6 +95,7 @@ const statusColors: Record<string, string> = {
   rejected: 'bg-red-500/10 text-red-500 border-red-500/20',
   expired: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
   converted: 'bg-purple-500/10 text-purple-500 border-purple-500/20',
+  changes_requested: 'bg-orange-500/10 text-orange-600 border-orange-500/20',
 }
 
 export function QuotesClient({
@@ -114,6 +122,7 @@ export function QuotesClient({
   const tableNav = useTableKeyboardNav()
   useRememberedSort('quotes')
   const t = useTranslations('quotes')
+  const tStatus = useTranslations('quotes.statusLabels')
   const tcm = useTranslations('common.contextMenu')
 
   // New quote dialog state
@@ -252,7 +261,11 @@ export function QuotesClient({
                 key={q.id}
                 type="button"
                 onClick={() => router.push(`/quotes/${q.id}`)}
-                className="w-full rounded-lg border bg-card p-3 text-left active:bg-muted/50"
+                data-attention={quoteNeedsAttention(q.status) ? q.status : undefined}
+                className={cn(
+                  'w-full rounded-lg border bg-card p-3 text-left active:bg-muted/50',
+                  attentionClasses(q.status).card
+                )}
               >
                 <div className="flex items-start justify-between gap-3">
                   <span className="min-w-0 flex-1 truncate font-medium">{q.title}</span>
@@ -279,7 +292,7 @@ export function QuotesClient({
                 )}
                 <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs text-muted-foreground">
                   <Badge variant="outline" className={`text-xs ${statusColors[q.status] || ''}`}>
-                    {q.status}
+                    {quoteStatusLabel(q.status, tStatus)}
                   </Badge>
                   {q.quoteNumber && <span className="font-mono">{q.quoteNumber}</span>}
                   <span className="font-mono">{formatDate(new Date(q.createdAt))}</span>
@@ -337,7 +350,7 @@ export function QuotesClient({
                   <SortIcon column="vehicle" />
                 </button>
               </TableHead>
-              <TableHead className="w-27.5">
+              <TableHead className="w-36">
                 <button
                   type="button"
                   className="flex items-center hover:text-foreground"
@@ -381,10 +394,13 @@ export function QuotesClient({
                 <ContextMenu key={q.id} modal={false}>
                   <ContextMenuTrigger asChild>
                     <TableRow
-                      className="cursor-pointer"
+                      data-attention={quoteNeedsAttention(q.status) ? q.status : undefined}
+                      className={`cursor-pointer ${attentionClasses(q.status).row}`}
                       {...interactiveRow(() => router.push(`/quotes/${q.id}`))}
                     >
-                      <TableCell className="font-mono text-xs text-muted-foreground">
+                      <TableCell
+                        className={`font-mono text-xs text-muted-foreground ${attentionClasses(q.status).edge}`}
+                      >
                         {q.quoteNumber || '-'}
                       </TableCell>
                       <TableCell className="truncate font-medium">{q.title}</TableCell>
@@ -409,9 +425,11 @@ export function QuotesClient({
                       <TableCell>
                         <Badge
                           variant="outline"
-                          className={`text-xs ${statusColors[q.status] || ''}`}
+                          // Wraps rather than spilling into the date column
+                          // when a language's word is wider than the cell.
+                          className={`max-w-full whitespace-normal text-center text-xs leading-tight ${statusColors[q.status] || ''}`}
                         >
-                          {q.status}
+                          {quoteStatusLabel(q.status, tStatus)}
                         </Badge>
                       </TableCell>
                       <TableCell className="font-mono text-xs">

+ 6 - 3
src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx

@@ -99,6 +99,7 @@ import {
   AlertDialogTitle,
 } from '@/components/ui/alert-dialog'
 import { useTranslations } from 'next-intl'
+import { quoteStatusLabel } from '@/features/quotes/Lib/quoteStatus'
 import { useServiceType } from '@/components/service-type-context'
 
 interface CustomerOption {
@@ -237,6 +238,7 @@ interface QuoteRecord {
 }
 
 const quoteStatusColors: Record<string, string> = {
+  changes_requested: 'bg-orange-500/10 text-orange-600 border-orange-500/20',
   draft: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
   sent: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
   accepted: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
@@ -344,6 +346,7 @@ export function VehicleDetailClient({
   const tc = useTranslations('common.buttons')
 
   const tq = useTranslations('vehicles.quotes')
+  const tQuoteStatus = useTranslations('quotes.statusLabels')
   const tf = useTranslations('vehicles.findings')
   const validTabs = ['services', 'quotes', 'inspections', 'findings', 'notes', 'reminders'] as const
   const tabParam = searchParams.get('tab')
@@ -1378,7 +1381,7 @@ export function VehicleDetailClient({
                         variant="outline"
                         className={`text-xs ${quoteStatusColors[q.status] || ''}`}
                       >
-                        {q.status}
+                        {quoteStatusLabel(q.status, tQuoteStatus)}
                       </Badge>
                       {q.quoteNumber && <span className="font-mono">{q.quoteNumber}</span>}
                       <span className="font-mono">{formatDate(new Date(q.createdAt))}</span>
@@ -1413,9 +1416,9 @@ export function VehicleDetailClient({
                         <TableCell>
                           <Badge
                             variant="outline"
-                            className={`text-xs ${quoteStatusColors[q.status] || ''}`}
+                            className={`max-w-full whitespace-normal text-center text-xs leading-tight ${quoteStatusColors[q.status] || ''}`}
                           >
-                            {q.status}
+                            {quoteStatusLabel(q.status, tQuoteStatus)}
                           </Badge>
                         </TableCell>
                         <TableCell className="font-mono text-xs text-muted-foreground">

+ 3 - 1
src/app/(public)/portal/[orgId]/dashboard/page.tsx

@@ -15,6 +15,7 @@ import {
 } from 'lucide-react'
 import Link from 'next/link'
 import { getTranslations } from 'next-intl/server'
+import { quoteStatusLabel } from '@/features/quotes/Lib/quoteStatus'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 
@@ -28,6 +29,7 @@ export default async function PortalDashboardPage({
   const org = await resolvePortalOrg(orgId)
   const timeZone = org ? await workshopTimeZone(org.id) : 'UTC'
   const t = await getTranslations('portal.dashboard')
+  const tQuoteStatus = await getTranslations('quotes.statusLabels')
   const tInvoices = await getTranslations('portal.invoices')
   const result = await getPortalDashboard()
 
@@ -201,7 +203,7 @@ export default async function PortalDashboardPage({
                     </div>
                     <div className="flex shrink-0 items-center gap-2">
                       <Badge variant="outline" className="text-xs">
-                        {q.status}
+                        {quoteStatusLabel(q.status, tQuoteStatus)}
                       </Badge>
                       {q.publicToken && (
                         <Link

+ 4 - 2
src/app/(public)/portal/[orgId]/quotes/page.tsx

@@ -12,6 +12,7 @@ import {
 import { FileQuestion } from 'lucide-react'
 import Link from 'next/link'
 import { getTranslations } from 'next-intl/server'
+import { quoteStatusLabel } from '@/features/quotes/Lib/quoteStatus'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { workshopTimeZone } from '@/lib/workshop-timezone'
 
@@ -21,6 +22,7 @@ export default async function PortalQuotesPage({ params }: { params: Promise<{ o
   const org = await resolvePortalOrg(orgId)
   const timeZone = org ? await workshopTimeZone(org.id) : 'UTC'
   const t = await getTranslations('portal.quotes')
+  const tQuoteStatus = await getTranslations('quotes.statusLabels')
   const result = await getPortalQuotes()
 
   if (!result.success || !result.data) {
@@ -68,7 +70,7 @@ export default async function PortalQuotesPage({ params }: { params: Promise<{ o
                             : 'outline'
                       }
                     >
-                      {q.status}
+                      {quoteStatusLabel(q.status, tQuoteStatus)}
                     </Badge>
                     {q.vehicle && (
                       <span className="truncate">
@@ -126,7 +128,7 @@ export default async function PortalQuotesPage({ params }: { params: Promise<{ o
                                 : 'outline'
                           }
                         >
-                          {q.status}
+                          {quoteStatusLabel(q.status, tQuoteStatus)}
                         </Badge>
                       </TableCell>
                       <TableCell>${q.totalAmount.toFixed(2)}</TableCell>

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

@@ -1,5 +1,6 @@
 'use server'
 
+import { ATTENTION_STATUSES } from '@/features/quotes/Lib/quoteStatus'
 import { assertQuoteEditable, getDocumentLockSettings } from '@/lib/document-lock.server'
 import { DocumentLockedError, quoteLockState } from '@/lib/document-lock'
 import { db } from '@/lib/db'
@@ -54,7 +55,9 @@ export async function getQuotesPaginated(params: {
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
       const where: any = { organizationId }
 
-      if (params.status && params.status !== 'all') {
+      if (params.status === 'attention') {
+        where.status = { in: ATTENTION_STATUSES }
+      } else if (params.status && params.status !== 'all') {
         where.status = params.status
       }
 
@@ -117,6 +120,7 @@ export async function getQuotesPaginated(params: {
       for (const g of statusCounts) {
         counts[g.status] = g._count
       }
+      counts.attention = ATTENTION_STATUSES.reduce((sum, status) => sum + (counts[status] ?? 0), 0)
 
       return {
         records,

+ 4 - 2
src/features/quotes/Components/QuotePageClient.tsx

@@ -7,6 +7,7 @@ import { useState, useCallback, useEffect } from 'react'
 import { useRouter } from 'next/navigation'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
+import { quoteStatusLabel } from '@/features/quotes/Lib/quoteStatus'
 import { Badge } from '@/components/ui/badge'
 import { Button } from '@/components/ui/button'
 import { ButtonGroup } from '@/components/ui/button-group'
@@ -107,6 +108,7 @@ export function QuotePageClient({
   const router = useRouter()
   const isLarge = useIsLargeScreen()
   const t = useTranslations('quotes')
+  const tStatus = useTranslations('quotes.statusLabels')
   const tPreview = useTranslations('common.pdfPreview')
 
   const state = useQuoteFormState({
@@ -257,9 +259,9 @@ export function QuotePageClient({
               <div className="flex items-center gap-2">
                 <Badge
                   variant="outline"
-                  className={`shrink-0 text-xs capitalize ${statusColors[state.status] || ''}`}
+                  className={`shrink-0 text-xs ${statusColors[state.status] || ''}`}
                 >
-                  {state.status}
+                  {quoteStatusLabel(state.status, tStatus)}
                 </Badge>
                 <h1 className="truncate text-lg font-semibold leading-tight">{quote.title}</h1>
               </div>

+ 60 - 0
src/features/quotes/Lib/quoteStatus.ts

@@ -0,0 +1,60 @@
+/**
+ * Every status a quote can hold, as the server's schema allows them.
+ *
+ * Screens used to print the stored value itself, so a workshop read
+ * "changes_requested" in English whatever its language, and the word was wide
+ * enough to spill out of the quotes table's status column.
+ */
+export const QUOTE_STATUSES = [
+  'draft',
+  'sent',
+  'accepted',
+  'rejected',
+  'expired',
+  'converted',
+  'changes_requested',
+] as const
+
+export type QuoteStatus = (typeof QUOTE_STATUSES)[number]
+
+/**
+ * The status as a person reads it, from the `quotes.statusLabels` messages.
+ * An unknown value is shown as it is rather than as a missing-message key.
+ */
+export function quoteStatusLabel(status: string, t: (key: QuoteStatus) => string): string {
+  return (QUOTE_STATUSES as readonly string[]).includes(status) ? t(status as QuoteStatus) : status
+}
+
+/**
+ * Statuses where the workshop has the next move: the customer asked for
+ * changes, or accepted and the job has not been created yet. Only these are
+ * highlighted, so the colour keeps meaning something in a long list.
+ */
+export const ATTENTION_STATUSES: QuoteStatus[] = ['changes_requested', 'accepted']
+
+export function quoteNeedsAttention(status: string): boolean {
+  return (ATTENTION_STATUSES as string[]).includes(status)
+}
+
+/**
+ * The highlight for a row or card that needs attention, in its status colour.
+ * A light tint for the row and a bar on its leading edge, which is what the eye
+ * finds when scanning; the status badge still says it in words.
+ */
+export function attentionClasses(status: string): { row: string; edge: string; card: string } {
+  if (status === 'changes_requested') {
+    return {
+      row: 'bg-orange-500/5 hover:bg-orange-500/10',
+      edge: 'shadow-[inset_3px_0_0_var(--color-orange-500)]',
+      card: 'border-l-[3px] border-l-orange-500 bg-orange-500/5',
+    }
+  }
+  if (status === 'accepted') {
+    return {
+      row: 'bg-emerald-500/5 hover:bg-emerald-500/10',
+      edge: 'shadow-[inset_3px_0_0_var(--color-emerald-500)]',
+      card: 'border-l-[3px] border-l-emerald-500 bg-emerald-500/5',
+    }
+  }
+  return { row: '', edge: '', card: '' }
+}