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

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 неделя назад
Родитель
Сommit
8a53a1892b

+ 1 - 1
messages/de/service.json

@@ -442,7 +442,7 @@
   },
   },
   "concerns": {
   "concerns": {
     "title": "Kundenanliegen",
     "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",
     "add": "Weiteres hinzufügen",
     "placeholder": "Beim Bremsen kommt hinten ein Geräusch",
     "placeholder": "Beim Bremsen kommt hinten ein Geräusch",
     "remove": "Dieses Anliegen entfernen",
     "remove": "Dieses Anliegen entfernen",

+ 1 - 1
messages/en/service.json

@@ -442,7 +442,7 @@
   },
   },
   "concerns": {
   "concerns": {
     "title": "Customer 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",
     "add": "Add another",
     "placeholder": "There is a noise from the back when I brake",
     "placeholder": "There is a noise from the back when I brake",
     "remove": "Remove this concern",
     "remove": "Remove this concern",

+ 1 - 1
messages/es/service.json

@@ -442,7 +442,7 @@
   },
   },
   "concerns": {
   "concerns": {
     "title": "Motivos del cliente",
     "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",
     "add": "Añadir otro",
     "placeholder": "Hace un ruido detrás cuando freno",
     "placeholder": "Hace un ruido detrás cuando freno",
     "remove": "Quitar este punto",
     "remove": "Quitar este punto",

+ 1 - 1
messages/fr/service.json

@@ -442,7 +442,7 @@
   },
   },
   "concerns": {
   "concerns": {
     "title": "Demandes du client",
     "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",
     "add": "En ajouter un autre",
     "placeholder": "Il y a un bruit à l'arrière quand je freine",
     "placeholder": "Il y a un bruit à l'arrière quand je freine",
     "remove": "Retirer ce point",
     "remove": "Retirer ce point",

+ 1 - 1
messages/it/service.json

@@ -442,7 +442,7 @@
   },
   },
   "concerns": {
   "concerns": {
     "title": "Segnalazioni del cliente",
     "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",
     "add": "Aggiungine un altro",
     "placeholder": "C'è un rumore dietro quando freno",
     "placeholder": "C'è un rumore dietro quando freno",
     "remove": "Rimuovi questo punto",
     "remove": "Rimuovi questo punto",

+ 1 - 1
messages/lt/service.json

@@ -442,7 +442,7 @@
   },
   },
   "concerns": {
   "concerns": {
     "title": "Kliento pranešimai",
     "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ą",
     "add": "Pridėti dar vieną",
     "placeholder": "Stabdant gale girdisi garsas",
     "placeholder": "Stabdant gale girdisi garsas",
     "remove": "Pašalinti šį punktą",
     "remove": "Pašalinti šį punktą",

+ 1 - 1
messages/nb/service.json

@@ -442,7 +442,7 @@
   },
   },
   "concerns": {
   "concerns": {
     "title": "Kundens beskrivelse",
     "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",
     "add": "Legg til en til",
     "placeholder": "Det kommer en lyd bakfra når jeg bremser",
     "placeholder": "Det kommer en lyd bakfra når jeg bremser",
     "remove": "Fjern dette punktet",
     "remove": "Fjern dette punktet",

+ 1 - 1
messages/nl/service.json

@@ -442,7 +442,7 @@
   },
   },
   "concerns": {
   "concerns": {
     "title": "Klantmeldingen",
     "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",
     "add": "Nog een toevoegen",
     "placeholder": "Er komt een geluid van achteren als ik rem",
     "placeholder": "Er komt een geluid van achteren als ik rem",
     "remove": "Dit punt verwijderen",
     "remove": "Dit punt verwijderen",

+ 1 - 1
messages/pl/service.json

@@ -442,7 +442,7 @@
   },
   },
   "concerns": {
   "concerns": {
     "title": "Zgłoszenia klienta",
     "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",
     "add": "Dodaj kolejne",
     "placeholder": "Przy hamowaniu słychać hałas z tyłu",
     "placeholder": "Przy hamowaniu słychać hałas z tyłu",
     "remove": "Usuń ten punkt",
     "remove": "Usuń ten punkt",

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

@@ -442,7 +442,7 @@
   },
   },
   "concerns": {
   "concerns": {
     "title": "Relatos do cliente",
     "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",
     "add": "Adicionar outro",
     "placeholder": "Faz um barulho atrás quando eu freio",
     "placeholder": "Faz um barulho atrás quando eu freio",
     "remove": "Remover este item",
     "remove": "Remover este item",

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

@@ -442,7 +442,7 @@
   },
   },
   "concerns": {
   "concerns": {
     "title": "Queixas do cliente",
     "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",
     "add": "Adicionar outra",
     "placeholder": "Faz um barulho atrás quando travo",
     "placeholder": "Faz um barulho atrás quando travo",
     "remove": "Remover esta queixa",
     "remove": "Remover esta queixa",

+ 1 - 1
messages/ru/service.json

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

+ 1 - 1
messages/tr/service.json

@@ -442,7 +442,7 @@
   },
   },
   "concerns": {
   "concerns": {
     "title": "Müşteri bildirimleri",
     "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",
     "add": "Bir tane daha ekle",
     "placeholder": "Fren yapınca arkadan ses geliyor",
     "placeholder": "Fren yapınca arkadan ses geliyor",
     "remove": "Bu maddeyi kaldır",
     "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 { useTranslations } from 'next-intl'
 import { MessageSquareQuote, Plus, X } from 'lucide-react'
 import { MessageSquareQuote, Plus, X } from 'lucide-react'
 import { Button } from '@/components/ui/button'
 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'
 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. Answering one and quietly dropping the other is the most common
  * reason a car comes back.
  * 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 {
 interface ConcernsSectionProps {
   concerns: ServiceConcernInput[]
   concerns: ServiceConcernInput[]
@@ -38,13 +39,25 @@ export function ConcernsSection({
 }: ConcernsSectionProps) {
 }: ConcernsSectionProps) {
   const t = useTranslations('service.concerns')
   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 = () => {
   const add = () => {
     setConcerns([...concerns, { description: '', sortOrder: concerns.length }])
     setConcerns([...concerns, { description: '', sortOrder: concerns.length }])
     onChange()
     onChange()
   }
   }
 
 
   const update = (index: number, description: string) => {
   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()
     onChange()
   }
   }
 
 
@@ -53,77 +66,72 @@ export function ConcernsSection({
     onChange()
     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 (
   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>
       </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
         const answered = concern.id ? (answeredCounts[concern.id] ?? 0) : 0
         return (
         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>
           </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>
     </div>
   )
   )
 }
 }

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

@@ -120,12 +120,6 @@ export function DetailsLeftColumn({
       />
       />
     ) : null
     ) : 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 (
   return (
     <div className="space-y-3">
     <div className="space-y-3">
       {/* First thing on the job, above the work itself: why the car is here,
       {/* 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
           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
           invoice sidebar cares about, so they belong here rather than spanning
           both columns. */}
           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 && (
       {tireSet && (
         <TireSetBanner set={tireSet} serviceRecordId={record.id} thresholds={tireThresholds} />
         <TireSetBanner set={tireSet} serviceRecordId={record.id} thresholds={tireThresholds} />