Pārlūkot izejas kodu

Make customer concerns a visible field on the work order (#409)

The concerns panel was one grey link until somebody clicked it, which read as the feature not being there. It is now always a named panel at the top of the job with a field to type in, the text runs over several lines, and adding another is a worded button. Nothing is stored until something is typed.
Bernt Christian Egeland 1 nedēļu atpakaļ
vecāks
revīzija
8a53a1892b

+ 1 - 1
messages/de/service.json

@@ -442,7 +442,7 @@
   },
   "concerns": {
     "title": "Kundenanliegen",
-    "addFirst": "Kundenanliegen hinzufügen",
+    "hint": "Warum der Kunde gekommen ist, in eigenen Worten. Eine Zeile für jeden genannten Punkt.",
     "add": "Weiteres hinzufügen",
     "placeholder": "Beim Bremsen kommt hinten ein Geräusch",
     "remove": "Dieses Anliegen entfernen",

+ 1 - 1
messages/en/service.json

@@ -442,7 +442,7 @@
   },
   "concerns": {
     "title": "Customer concerns",
-    "addFirst": "Add a customer concern",
+    "hint": "Why the customer came in, in their own words. One row for each thing they mentioned.",
     "add": "Add another",
     "placeholder": "There is a noise from the back when I brake",
     "remove": "Remove this concern",

+ 1 - 1
messages/es/service.json

@@ -442,7 +442,7 @@
   },
   "concerns": {
     "title": "Motivos del cliente",
-    "addFirst": "Añadir un motivo del cliente",
+    "hint": "Por qué vino el cliente, con sus propias palabras. Una fila por cada cosa que mencionó.",
     "add": "Añadir otro",
     "placeholder": "Hace un ruido detrás cuando freno",
     "remove": "Quitar este punto",

+ 1 - 1
messages/fr/service.json

@@ -442,7 +442,7 @@
   },
   "concerns": {
     "title": "Demandes du client",
-    "addFirst": "Ajouter une demande du client",
+    "hint": "Pourquoi le client est venu, avec ses propres mots. Une ligne par point mentionné.",
     "add": "En ajouter un autre",
     "placeholder": "Il y a un bruit à l'arrière quand je freine",
     "remove": "Retirer ce point",

+ 1 - 1
messages/it/service.json

@@ -442,7 +442,7 @@
   },
   "concerns": {
     "title": "Segnalazioni del cliente",
-    "addFirst": "Aggiungi una segnalazione del cliente",
+    "hint": "Perché il cliente è venuto, con parole sue. Una riga per ogni cosa segnalata.",
     "add": "Aggiungine un altro",
     "placeholder": "C'è un rumore dietro quando freno",
     "remove": "Rimuovi questo punto",

+ 1 - 1
messages/lt/service.json

@@ -442,7 +442,7 @@
   },
   "concerns": {
     "title": "Kliento pranešimai",
-    "addFirst": "Pridėti kliento pranešimą",
+    "hint": "Kodėl klientas atvyko, kliento žodžiais. Po vieną eilutę kiekvienam paminėtam dalykui.",
     "add": "Pridėti dar vieną",
     "placeholder": "Stabdant gale girdisi garsas",
     "remove": "Pašalinti šį punktą",

+ 1 - 1
messages/nb/service.json

@@ -442,7 +442,7 @@
   },
   "concerns": {
     "title": "Kundens beskrivelse",
-    "addFirst": "Legg til kundens beskrivelse",
+    "hint": "Hvorfor kunden kom, med kundens egne ord. Én rad for hver ting som ble nevnt.",
     "add": "Legg til en til",
     "placeholder": "Det kommer en lyd bakfra når jeg bremser",
     "remove": "Fjern dette punktet",

+ 1 - 1
messages/nl/service.json

@@ -442,7 +442,7 @@
   },
   "concerns": {
     "title": "Klantmeldingen",
-    "addFirst": "Klantmelding toevoegen",
+    "hint": "Waarom de klant is gekomen, in eigen woorden. Eén regel voor elk genoemd punt.",
     "add": "Nog een toevoegen",
     "placeholder": "Er komt een geluid van achteren als ik rem",
     "remove": "Dit punt verwijderen",

+ 1 - 1
messages/pl/service.json

@@ -442,7 +442,7 @@
   },
   "concerns": {
     "title": "Zgłoszenia klienta",
-    "addFirst": "Dodaj zgłoszenie klienta",
+    "hint": "Dlaczego klient przyjechał, własnymi słowami klienta. Jeden wiersz na każdą zgłoszoną rzecz.",
     "add": "Dodaj kolejne",
     "placeholder": "Przy hamowaniu słychać hałas z tyłu",
     "remove": "Usuń ten punkt",

+ 1 - 1
messages/pt-BR/service.json

@@ -442,7 +442,7 @@
   },
   "concerns": {
     "title": "Relatos do cliente",
-    "addFirst": "Adicionar um relato do cliente",
+    "hint": "Por que o cliente veio, nas palavras do próprio cliente. Uma linha para cada coisa mencionada.",
     "add": "Adicionar outro",
     "placeholder": "Faz um barulho atrás quando eu freio",
     "remove": "Remover este item",

+ 1 - 1
messages/pt-PT/service.json

@@ -442,7 +442,7 @@
   },
   "concerns": {
     "title": "Queixas do cliente",
-    "addFirst": "Adicionar uma queixa do cliente",
+    "hint": "O motivo da visita do cliente, pelas suas próprias palavras. Uma linha por cada coisa mencionada.",
     "add": "Adicionar outra",
     "placeholder": "Faz um barulho atrás quando travo",
     "remove": "Remover esta queixa",

+ 1 - 1
messages/ru/service.json

@@ -442,7 +442,7 @@
   },
   "concerns": {
     "title": "Обращения клиента",
-    "addFirst": "Добавить обращение клиента",
+    "hint": "Почему клиент обратился, своими словами. Отдельная строка для каждого названного пункта.",
     "add": "Добавить ещё",
     "placeholder": "При торможении сзади слышен шум",
     "remove": "Удалить этот пункт",

+ 1 - 1
messages/tr/service.json

@@ -442,7 +442,7 @@
   },
   "concerns": {
     "title": "Müşteri bildirimleri",
-    "addFirst": "Müşteri bildirimi ekle",
+    "hint": "Müşterinin neden geldiği, kendi sözleriyle. Bahsedilen her şey için bir satır.",
     "add": "Bir tane daha ekle",
     "placeholder": "Fren yapınca arkadan ses geliyor",
     "remove": "Bu maddeyi kaldır",

+ 120 - 0
src/__tests__/features/workorders/concerns-section.test.tsx

@@ -0,0 +1,120 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { useState } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { ConcernsSection } from '@/features/vehicles/Components/service-edit/ConcernsSection'
+import type { ServiceConcernInput } from '@/features/vehicles/Schema/serviceSchema'
+
+/**
+ * The customer's concerns are a field from the moment a job opens. They used
+ * to be one grey link until somebody clicked it, and a workshop asked for them
+ * to be made visible.
+ */
+
+function Harness({
+  initial = [],
+  onChange = vi.fn(),
+  answeredCounts,
+  onState,
+}: {
+  initial?: ServiceConcernInput[]
+  onChange?: () => void
+  answeredCounts?: Record<string, number>
+  onState?: (concerns: ServiceConcernInput[]) => void
+}) {
+  const [concerns, setConcerns] = useState(initial)
+  onState?.(concerns)
+  return (
+    <ConcernsSection
+      concerns={concerns}
+      setConcerns={setConcerns}
+      onChange={onChange}
+      answeredCounts={answeredCounts}
+    />
+  )
+}
+
+describe('the customer concerns field', () => {
+  it('is a named field with somewhere to type before anything is written', () => {
+    render(<Harness />)
+    expect(screen.getByRole('heading', { name: 'Customer concerns' })).toBeInTheDocument()
+    expect(screen.getAllByRole('textbox', { name: 'Customer concerns' })).toHaveLength(1)
+    expect(screen.getByText(/in their own words/)).toBeInTheDocument()
+    // Nothing to remove, and nothing said about findings, until there are words.
+    expect(screen.queryByRole('button', { name: 'Remove this concern' })).toBeNull()
+    expect(screen.queryByText('Nothing found against this yet')).toBeNull()
+  })
+
+  it('holds no concern until somebody types, so a blank row is never saved', () => {
+    let state: ServiceConcernInput[] = []
+    const onChange = vi.fn()
+    render(
+      <Harness
+        onChange={onChange}
+        onState={(c) => {
+          state = c
+        }}
+      />
+    )
+    expect(state).toEqual([])
+    expect(onChange).not.toHaveBeenCalled()
+
+    fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Pulls right when braking' } })
+    expect(state).toEqual([{ description: 'Pulls right when braking', sortOrder: 0 }])
+    expect(onChange).toHaveBeenCalledTimes(1)
+  })
+
+  it('keeps the same field under the cursor as the first concern becomes real', () => {
+    render(<Harness />)
+    const field = screen.getByRole('textbox')
+    fireEvent.change(field, { target: { value: 'A' } })
+    // Replaced, the field would drop focus after the first letter typed.
+    expect(screen.getByRole('textbox')).toBe(field)
+  })
+
+  it('takes more than one line, because customers speak in sentences', () => {
+    render(<Harness />)
+    expect(screen.getByRole('textbox').tagName).toBe('TEXTAREA')
+  })
+
+  it('adds a second concern in words, and not while a row is still blank', () => {
+    render(<Harness />)
+    const add = screen.getByRole('button', { name: 'Add another' })
+    expect(add).toBeDisabled()
+
+    fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Aircon smells' } })
+    expect(add).toBeEnabled()
+    fireEvent.click(add)
+    expect(screen.getAllByRole('textbox')).toHaveLength(2)
+    expect(add).toBeDisabled()
+  })
+
+  it('says which concern nobody has looked at yet', () => {
+    render(
+      <Harness
+        initial={[
+          { id: 'c1', description: 'Pulls right when braking', sortOrder: 0 },
+          { id: 'c2', description: 'Aircon smells', sortOrder: 1 },
+        ]}
+        answeredCounts={{ c1: 2 }}
+      />
+    )
+    expect(screen.getByText('2 finding(s) against this')).toBeInTheDocument()
+    expect(screen.getByText('Nothing found against this yet')).toBeInTheDocument()
+  })
+
+  it('goes back to the open field when the last concern is removed', () => {
+    let state: ServiceConcernInput[] = []
+    render(
+      <Harness
+        initial={[{ id: 'c1', description: 'Aircon smells', sortOrder: 0 }]}
+        onState={(c) => {
+          state = c
+        }}
+      />
+    )
+    fireEvent.click(screen.getByRole('button', { name: 'Remove this concern' }))
+    expect(state).toEqual([])
+    expect(screen.getAllByRole('textbox')).toHaveLength(1)
+    expect(screen.getByRole('textbox')).toHaveValue('')
+  })
+})

+ 78 - 70
src/features/vehicles/Components/service-edit/ConcernsSection.tsx

@@ -3,7 +3,7 @@
 import { useTranslations } from 'next-intl'
 import { MessageSquareQuote, Plus, X } from 'lucide-react'
 import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
 import type { ServiceConcernInput } from '@/features/vehicles/Schema/serviceSchema'
 
 /**
@@ -16,11 +16,12 @@ import type { ServiceConcernInput } from '@/features/vehicles/Schema/serviceSche
  * reason. Answering one and quietly dropping the other is the most common
  * reason a car comes back.
  *
- * Prominence follows content, but never at the cost of density: this is a page
- * where every other row is a record, so a concern is one row too. Empty, it is
- * a single quiet line inviting the first one. An always-empty box at the top of
- * every job teaches people to look past it, which is the one outcome that kills
- * the feature.
+ * It is always a field, with somewhere to type. It used to be one grey link
+ * until somebody clicked it, on the reasoning that an empty box teaches people
+ * to look past it; what it taught instead was that the feature was not there,
+ * and a workshop asked for it to be made visible. The customer's words are
+ * the reason for the job, so they get a named place at the top of it, the
+ * room to run over a line, and ordinary type rather than italics.
  */
 interface ConcernsSectionProps {
   concerns: ServiceConcernInput[]
@@ -38,13 +39,25 @@ export function ConcernsSection({
 }: ConcernsSectionProps) {
   const t = useTranslations('service.concerns')
 
+  // With nothing written yet there is still one row to type into. It is not a
+  // concern until it has words: the list stays empty, so nothing blank is
+  // saved, and the first keystroke is what creates the row. The key is the one
+  // that row will have, so the field keeps its focus as it becomes real.
+  const unwritten = concerns.length === 0
+  const rows: ServiceConcernInput[] = unwritten ? [{ description: '', sortOrder: 0 }] : concerns
+  const hasBlankRow = rows.some((c) => !c.description.trim())
+
   const add = () => {
     setConcerns([...concerns, { description: '', sortOrder: concerns.length }])
     onChange()
   }
 
   const update = (index: number, description: string) => {
-    setConcerns(concerns.map((c, i) => (i === index ? { ...c, description } : c)))
+    setConcerns(
+      unwritten
+        ? [{ description, sortOrder: 0 }]
+        : concerns.map((c, i) => (i === index ? { ...c, description } : c))
+    )
     onChange()
   }
 
@@ -53,77 +66,72 @@ export function ConcernsSection({
     onChange()
   }
 
-  // Empty, this is an offer, not a container. Full width it read as a broken
-  // card sitting above the work; sized to its own text it reads as the small
-  // action it is, and costs one line until somebody actually types something.
-  if (concerns.length === 0) {
-    return (
-      <button
-        type="button"
-        onClick={add}
-        className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground"
-      >
-        <MessageSquareQuote className="h-3.5 w-3.5 shrink-0" />
-        {t('addFirst')}
-      </button>
-    )
-  }
-
-  // One line per concern, the way parts and labour get one line each. This
-  // started as a two-row textarea with the answered state on a line of its own
-  // underneath, which spent four rows of the page on one sentence. On a screen
-  // where every other row is a record, a concern is a record too.
   return (
-    <div className="space-y-1.5 rounded-lg border border-l-4 border-l-primary/60 px-3 py-2">
-      <div className="flex items-center justify-between gap-2">
-        <div className="flex items-center gap-1.5">
-          <MessageSquareQuote className="h-3.5 w-3.5 text-muted-foreground" />
-          <h3 className="font-medium text-sm">{t('title')}</h3>
-        </div>
-        <Button
-          type="button"
-          variant="ghost"
-          size="icon"
-          onClick={add}
-          aria-label={t('add')}
-          className="h-6 w-6 shrink-0 text-muted-foreground"
-        >
-          <Plus className="h-3.5 w-3.5" />
-        </Button>
+    <div
+      className="space-y-2 rounded-lg border border-l-4 border-l-primary p-3"
+      data-testid="customer-concerns"
+    >
+      <div className="flex items-center gap-2">
+        <MessageSquareQuote className="h-4 w-4 shrink-0 text-primary" />
+        <h3 className="font-semibold text-sm">{t('title')}</h3>
       </div>
+      {unwritten && <p className="text-muted-foreground text-xs">{t('hint')}</p>}
 
-      {concerns.map((concern, index) => {
+      {rows.map((concern, index) => {
         const answered = concern.id ? (answeredCounts[concern.id] ?? 0) : 0
         return (
-          <div key={concern.id ?? `new-${index}`} className="flex items-center gap-2">
-            <Input
-              value={concern.description}
-              placeholder={t('placeholder')}
-              onChange={(e) => update(index, e.target.value)}
-              className="h-8 flex-1 italic"
-              aria-label={t('title')}
-            />
-            {/* Whether anybody has answered this one yet. The point of keeping
-                concerns apart is being able to see the one nobody looked at,
-                so it stays on the row rather than costing a line of its own. */}
-            <span
-              className={`shrink-0 text-xs ${answered > 0 ? 'text-muted-foreground' : 'text-amber-600 dark:text-amber-500'}`}
-            >
-              {answered > 0 ? t('answered', { count: answered }) : t('notAnswered')}
-            </span>
-            <Button
-              type="button"
-              variant="ghost"
-              size="icon"
-              onClick={() => remove(index)}
-              aria-label={t('remove')}
-              className="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
-            >
-              <X className="h-3.5 w-3.5" />
-            </Button>
+          <div key={concern.id ?? `new-${index}`} className="flex items-start gap-2">
+            <div className="min-w-0 flex-1 space-y-1">
+              {/* A textarea that grows: what a customer says is often a sentence
+                  or two, and a one-line input showed the first half of it. */}
+              <Textarea
+                value={concern.description}
+                placeholder={t('placeholder')}
+                onChange={(e) => update(index, e.target.value)}
+                rows={1}
+                className="min-h-9 w-full resize-none"
+                aria-label={t('title')}
+              />
+              {/* Whether anybody has answered this one yet. The point of keeping
+                  concerns apart is being able to see the one nobody looked at. */}
+              {!unwritten && concern.description.trim() && (
+                <p
+                  className={`text-xs ${answered > 0 ? 'text-muted-foreground' : 'text-amber-600 dark:text-amber-500'}`}
+                >
+                  {answered > 0 ? t('answered', { count: answered }) : t('notAnswered')}
+                </p>
+              )}
+            </div>
+            {!unwritten && (
+              <Button
+                type="button"
+                variant="ghost"
+                size="icon"
+                onClick={() => remove(index)}
+                aria-label={t('remove')}
+                className="h-9 w-8 shrink-0 text-muted-foreground hover:text-destructive"
+              >
+                <X className="h-3.5 w-3.5" />
+              </Button>
+            )}
           </div>
         )
       })}
+
+      {/* Said in words rather than a bare plus: the second thing a customer
+          mentions is the one that gets lost. Held back while a row is still
+          blank, so blank rows cannot pile up. */}
+      <Button
+        type="button"
+        variant="ghost"
+        size="sm"
+        onClick={add}
+        disabled={hasBlankRow}
+        className="h-7 px-2 text-muted-foreground text-xs"
+      >
+        <Plus className="mr-1 h-3.5 w-3.5" />
+        {t('add')}
+      </Button>
     </div>
   )
 }

+ 2 - 17
src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx

@@ -120,12 +120,6 @@ export function DetailsLeftColumn({
       />
     ) : null
 
-  // Two short things, one line. Stacked, an empty concerns prompt and a lone
-  // Store tires button read as two abandoned rows above the parts; side by
-  // side they read as the toolbar they actually are. Once somebody types a
-  // concern the block needs the full width, so it gets its own row back.
-  const pairable = formState.concerns.length === 0
-
   return (
     <div className="space-y-3">
       {/* First thing on the job, above the work itself: why the car is here,
@@ -133,17 +127,8 @@ export function DetailsLeftColumn({
           the tires are the first thing this job needs and the last thing the
           invoice sidebar cares about, so they belong here rather than spanning
           both columns. */}
-      {pairable ? (
-        <div className="flex items-center justify-between gap-2">
-          {concerns}
-          {storeTires}
-        </div>
-      ) : (
-        <>
-          {concerns}
-          {storeTires && <div className="flex justify-end">{storeTires}</div>}
-        </>
-      )}
+      {concerns}
+      {storeTires && <div className="flex justify-end">{storeTires}</div>}
 
       {tireSet && (
         <TireSetBanner set={tireSet} serviceRecordId={record.id} thresholds={tireThresholds} />