Kaynağa Gözat

Shared invoice ignores line breaks (#354)

* Keep typed line breaks on the shared sheet

The share view renders the same document spec the PDF prints, but in HTML,
where a newline folds into a space. A part or labour description written on
several lines, and the bank block, came out as one run-on line there while
the PDF had them right.

Text nodes and table cells now print with pre-line, which keeps the breaks
and still wraps. Rich text opts out: its newlines sit between <p> tags and
are markup, not the writer's breaks.

Heights on the sheet are measured from the rendered DOM rather than counted,
so the taller rows lay out correctly with no change to the layout engine.

* Keep typed line breaks on the rest of the customer-facing pages

The shared invoice and quote are one renderer and were fixed with it. These
are the pages that draw their own markup and had the same fold-into-a-space:
the per-item notes on a shared inspection, the workshop address there and on
the terms page, a customer's own service request in the portal, and an
attachment caption on the invoice.

Swept the public and portal pages for the rest. The portal landing text and
the terms body already declared it, and the workshop line in the portal
directory is deliberately truncated to one line, so those are left alone.
Bernt Christian Egeland 3 hafta önce
ebeveyn
işleme
11b59bc22c

+ 49 - 0
src/__tests__/features/invoice-designer/line-breaks.test.tsx

@@ -0,0 +1,49 @@
+/**
+ * The share view renders the same document spec the PDF prints, but in HTML,
+ * where a newline folds into a space unless the CSS says otherwise. A part
+ * description or a bank block typed on several lines came out as one run-on
+ * line there while the PDF had it right.
+ */
+import { describe, expect, it } from 'vitest'
+import { render } from '@testing-library/react'
+import { RenderNode } from '@/features/invoice-designer/Render/renderHtml'
+
+describe('html sheet line breaks', () => {
+  it('keeps the breaks in a text node', () => {
+    const { container } = render(
+      <RenderNode node={{ kind: 'text', text: 'Bank 1234 5678\nIBAN NO12 3456', style: {} }} />
+    )
+    const el = container.firstElementChild as HTMLElement
+    expect(el.textContent).toContain('\n')
+    expect(el.style.whiteSpace).toBe('pre-line')
+  })
+
+  it('leaves rich text alone, where the newlines are markup rather than breaks', () => {
+    // Sanitized notes arrive as <p>...</p>\n<p>...</p>. Honouring those
+    // newlines would print a blank line between every paragraph.
+    const { container } = render(
+      <RenderNode node={{ kind: 'richtext', html: '<p>One</p>\n<p>Two</p>', style: {} }} />
+    )
+    const el = container.firstElementChild as HTMLElement
+    expect(el.style.whiteSpace).toBe('normal')
+  })
+
+  it('keeps the breaks in a table cell, which carries its own styles', () => {
+    const { container } = render(
+      <RenderNode
+        node={{
+          kind: 'table',
+          columns: [
+            { key: 'description', label: 'Description', width: 'flex' },
+            { key: 'total', label: 'Total', width: 60, align: 'right' },
+          ],
+          rows: [{ description: 'Brake pads\nfront axle', total: '120,00' }],
+        }}
+      />
+    )
+    const cell = Array.from(container.querySelectorAll('span')).find((s) =>
+      s.textContent?.includes('Brake pads')
+    ) as HTMLElement
+    expect(cell.style.whiteSpace).toBe('pre-line')
+  })
+})

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

@@ -78,7 +78,9 @@ export default async function PortalRequestServicePage({
                     <p className="text-sm font-medium">
                     <p className="text-sm font-medium">
                       {req.vehicle.make} {req.vehicle.model}
                       {req.vehicle.make} {req.vehicle.model}
                     </p>
                     </p>
-                    <p className="mt-1 text-sm text-muted-foreground">{req.description}</p>
+                    <p className="mt-1 whitespace-pre-line text-sm text-muted-foreground">
+                      {req.description}
+                    </p>
                     <p className="mt-1 text-xs text-muted-foreground">
                     <p className="mt-1 text-xs text-muted-foreground">
                       {new Date(req.createdAt).toLocaleDateString(undefined, { timeZone })}
                       {new Date(req.createdAt).toLocaleDateString(undefined, { timeZone })}
                       {req.preferredDate &&
                       {req.preferredDate &&

+ 7 - 3
src/app/(public)/share/inspection/[orgId]/[token]/inspection-view.tsx

@@ -297,7 +297,9 @@ export function InspectionView({
               <img src={logoUrl} alt="" className="mb-3 h-12 object-contain" />
               <img src={logoUrl} alt="" className="mb-3 h-12 object-contain" />
             )}
             )}
             <p className="text-xl font-bold">{workshop.name}</p>
             <p className="text-xl font-bold">{workshop.name}</p>
-            {workshop.address && <p className="text-sm text-gray-500">{workshop.address}</p>}
+            {workshop.address && (
+              <p className="whitespace-pre-wrap text-sm text-gray-500">{workshop.address}</p>
+            )}
             {workshop.phone && <p className="text-sm text-gray-500">{workshop.phone}</p>}
             {workshop.phone && <p className="text-sm text-gray-500">{workshop.phone}</p>}
             {workshop.email && <p className="text-sm text-gray-500">{workshop.email}</p>}
             {workshop.email && <p className="text-sm text-gray-500">{workshop.email}</p>}
           </div>
           </div>
@@ -511,7 +513,7 @@ export function InspectionView({
                       {gradedText(item.condition as Condition)}
                       {gradedText(item.condition as Condition)}
                     </span>
                     </span>
                   </div>
                   </div>
-                  {item.notes && <p className="mt-1 text-sm">{item.notes}</p>}
+                  {item.notes && <p className="mt-1 whitespace-pre-wrap text-sm">{item.notes}</p>}
                   {renderValue(item)}
                   {renderValue(item)}
                   {renderMedia(item)}
                   {renderMedia(item)}
                 </li>
                 </li>
@@ -558,7 +560,9 @@ export function InspectionView({
                         {gradedText(condition)}
                         {gradedText(condition)}
                       </span>
                       </span>
                     </div>
                     </div>
-                    {item.notes && <p className="mt-1 text-sm text-gray-500">{item.notes}</p>}
+                    {item.notes && (
+                      <p className="mt-1 whitespace-pre-wrap text-sm text-gray-500">{item.notes}</p>
+                    )}
                     {renderValue(item)}
                     {renderValue(item)}
                     {renderMedia(item)}
                     {renderMedia(item)}
                   </li>
                   </li>

+ 3 - 1
src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx

@@ -759,7 +759,9 @@ export function InvoiceView({
                       className="w-full"
                       className="w-full"
                     />
                     />
                     {att.description && (
                     {att.description && (
-                      <p className="px-3 py-2 text-sm text-gray-500">{att.description}</p>
+                      <p className="whitespace-pre-line px-3 py-2 text-sm text-gray-500">
+                        {att.description}
+                      </p>
                     )}
                     )}
                   </div>
                   </div>
                 ))}
                 ))}

+ 1 - 1
src/app/(public)/share/terms/[orgId]/page.tsx

@@ -79,7 +79,7 @@ export default async function PublicTermsPage({ params }: { params: Promise<{ or
           <div className="mt-8 border-t pt-4">
           <div className="mt-8 border-t pt-4">
             <p className="text-xs font-bold uppercase text-gray-400">{t('contact')}</p>
             <p className="text-xs font-bold uppercase text-gray-400">{t('contact')}</p>
             <div className="mt-1 space-y-0.5 text-sm text-gray-500">
             <div className="mt-1 space-y-0.5 text-sm text-gray-500">
-              {address && <p>{address}</p>}
+              {address && <p className="whitespace-pre-wrap">{address}</p>}
               {phone && <p>{t('tel', { phone })}</p>}
               {phone && <p>{t('tel', { phone })}</p>}
               {email && <p>{email}</p>}
               {email && <p>{email}</p>}
             </div>
             </div>

+ 19 - 2
src/features/invoice-designer/Render/renderHtml.tsx

@@ -43,6 +43,12 @@ export function textCss(style?: TextStyle): CSSProperties {
     textTransform: style.uppercase ? 'uppercase' : undefined,
     textTransform: style.uppercase ? 'uppercase' : undefined,
     letterSpacing: style.letterSpacing,
     letterSpacing: style.letterSpacing,
     lineHeight: style.lineHeight ?? 1.4,
     lineHeight: style.lineHeight ?? 1.4,
+    // A line break the workshop typed is a line break. HTML would fold it
+    // into a space, which is why a multi-line part description or a bank
+    // block ran together here while the PDF, where a newline is a newline,
+    // printed it correctly. pre-line keeps the breaks and still wraps and
+    // collapses runs of spaces, so nothing else about the sheet changes.
+    whiteSpace: 'pre-line',
   }
   }
 }
 }
 
 
@@ -168,7 +174,11 @@ function NodeBody({ node }: { node: Node }): ReactNode {
       return (
       return (
         <div
         <div
           {...id}
           {...id}
-          style={{ lineHeight: 1.5, ...textCss(node.style) }}
+          // Rich text carries its own <p> and <br>; the newlines between those
+          // tags are markup whitespace, not the writer's line breaks, so this
+          // block opts out of pre-line rather than printing a blank line
+          // between every paragraph.
+          style={{ lineHeight: 1.5, ...textCss(node.style), whiteSpace: 'normal' }}
           // The workshop's own rich-text notes, sanitized the same way the
           // The workshop's own rich-text notes, sanitized the same way the
           // PDF sanitizes them before parsing.
           // PDF sanitizes them before parsing.
           dangerouslySetInnerHTML={{ __html: sanitizeHtml(node.html) }}
           dangerouslySetInnerHTML={{ __html: sanitizeHtml(node.html) }}
@@ -250,7 +260,14 @@ function NodeBody({ node }: { node: Node }): ReactNode {
                 }}
                 }}
               >
               >
                 {node.columns.map((column) => (
                 {node.columns.map((column) => (
-                  <span key={column.key} style={{ ...cell(column.width), textAlign: column.align }}>
+                  <span
+                    key={column.key}
+                    style={{
+                      ...cell(column.width),
+                      textAlign: column.align,
+                      whiteSpace: 'pre-line',
+                    }}
+                  >
                     {row[column.key]}
                     {row[column.key]}
                     {node.subKey && column.width === 'flex' && row[node.subKey] ? (
                     {node.subKey && column.width === 'flex' && row[node.subKey] ? (
                       <span style={{ display: 'block', opacity: 0.6, fontSize: '0.85em' }}>
                       <span style={{ display: 'block', opacity: 0.6, fontSize: '0.85em' }}>