Преглед изворни кода

Let a quote's part and labour lines run to more than one line (#355)

The invoice editor takes a part name or a labour description on several
lines. The quote editor was single-line inputs, which swallow a newline
outright, so the same job written as a quote lost its formatting and there
was no way to get it back.

Both fields are now the same auto-growing textarea the invoice uses: one row
tall until there is more to show. The sheet already prints the breaks, so
nothing downstream changes.
Bernt Christian Egeland пре 3 недеља
родитељ
комит
3955d3cbc4

+ 70 - 0
src/__tests__/features/quotes/multiline-line-items.test.tsx

@@ -0,0 +1,70 @@
+/**
+ * A part or a labour line often needs a second line. The invoice editor has
+ * always taken one; the quote editor was single-line inputs, which swallow a
+ * newline entirely, so the same job written as a quote lost its formatting.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { QuotePartsEditor } from '@/features/quotes/Components/QuotePartsEditor'
+import { QuoteLaborEditor } from '@/features/quotes/Components/QuoteLaborEditor'
+
+const t = (key: string) => key
+
+const part = {
+  name: 'Brake pads\nfront axle',
+  partNumber: '',
+  quantity: 1,
+  unitPrice: 100,
+  total: 100,
+  excluded: false,
+}
+
+const labor = {
+  description: 'Replace pads\nbleed brakes',
+  hours: 2,
+  rate: 800,
+  total: 1600,
+  pricingType: 'hourly',
+  excluded: false,
+}
+
+describe('quote line items take more than one line', () => {
+  it('gives the part name a textarea holding the break', () => {
+    render(
+      <QuotePartsEditor
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        partItems={[part as any]}
+        currencyCode="NOK"
+        partsSubtotal={100}
+        onUpdate={vi.fn()}
+        onDelete={vi.fn()}
+        onAdd={vi.fn()}
+        t={t}
+      />
+    )
+    // getByDisplayValue normalizes whitespace, which is the very thing under
+    // test, so the field is found by its placeholder and read directly.
+    const field = screen.getByPlaceholderText('parts.namePlaceholder') as HTMLTextAreaElement
+    expect(field.tagName).toBe('TEXTAREA')
+    expect(field.value).toBe('Brake pads\nfront axle')
+  })
+
+  it('gives the labour description a textarea holding the break', () => {
+    render(
+      <QuoteLaborEditor
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        laborItems={[labor as any]}
+        currencyCode="NOK"
+        cs="kr"
+        laborSubtotal={1600}
+        onUpdate={vi.fn()}
+        onDelete={vi.fn()}
+        onAdd={vi.fn()}
+        t={t}
+      />
+    )
+    const field = screen.getByPlaceholderText('labor.descriptionPlaceholder') as HTMLTextAreaElement
+    expect(field.tagName).toBe('TEXTAREA')
+    expect(field.value).toBe('Replace pads\nbleed brakes')
+  })
+})

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

@@ -3,6 +3,7 @@
 import { memo } from 'react'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
 import { Layers, Plus, Trash2, Wrench } from 'lucide-react'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
@@ -46,11 +47,12 @@ const QuoteLaborRow = memo(function QuoteLaborRow({
       className={`grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto] ${labor.excluded ? 'line-through opacity-50' : ''}`}
     >
       <div className="col-span-2 flex gap-2 sm:col-span-1">
-        <Input
+        <Textarea
           placeholder={tDescriptionPlaceholder}
           value={labor.description}
           onChange={(e) => onUpdate(index, 'description', e.target.value)}
-          className="flex-1"
+          rows={1}
+          className="min-h-9 flex-1 resize-none"
         />
         <button
           type="button"

+ 9 - 2
src/features/quotes/Components/QuotePartsEditor.tsx

@@ -3,6 +3,7 @@
 import { memo, useCallback, useState } from 'react'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
 import { Package, Plus, Trash2 } from 'lucide-react'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { cn } from '@/lib/utils'
@@ -71,12 +72,18 @@ const QuotePartRow = memo(function QuotePartRow({
         onChange={(e) => onUpdate(index, 'partNumber', e.target.value)}
       />
       <div className="relative">
-        <Input
+        {/* A textarea, as on the invoice: a part often needs a second line,
+            and it grows with what is typed rather than starting tall. */}
+        <Textarea
           placeholder={tNamePlaceholder}
           value={part.name}
           onChange={(e) => onUpdate(index, 'name', e.target.value)}
+          rows={1}
           aria-invalid={nameMissing}
-          className={nameMissing ? 'border-destructive focus-visible:ring-destructive' : undefined}
+          className={cn(
+            'min-h-9 w-full resize-none',
+            nameMissing && 'border-destructive focus-visible:ring-destructive'
+          )}
         />
         <PartNameSuggestions
           query={part.name}