Bladeren bron

Let every edit form clear an optional field (#314)

The vehicle form's VIN fix showed a pattern repeated across the app: a form
turned an emptied input into undefined, the update action read undefined as
"not touched", and the old value came back after save. This applies the same
two-sided contract everywhere: when editing, an emptied field travels as ''
(or null for a number) and the action turns it into null.

Fixed: customer number, tax id and notes on customers; service request admin
notes; nine optional fields on inventory parts; labor preset descriptions;
custom field default values and options (including stale options after a
type switch); quote description, notes, valid-until date, customer, vehicle
and discount type; inspection template descriptions and item notes and text
readings; scheduled message subject and recipient overrides; the vehicle
owner; service record mileage, invoice number, invoice dates and rich-text
notes (an emptied editor reports one empty paragraph); reminder description,
due date and due mileage in both reminder forms; finding notes.

A shared src/lib/clearable helper carries both halves so new forms can copy
it, and regression tests pin the server behaviour.

Claude-Session: https://claude.ai/code/session_01Gist2g93t6XZ78juQ8C2vm
Bernt Christian Egeland 3 weken geleden
bovenliggende
commit
bf205f790c
25 gewijzigde bestanden met toevoegingen van 515 en 72 verwijderingen
  1. 300 0
      src/__tests__/features/forms/clear-optional-fields.test.ts
  2. 46 0
      src/__tests__/lib/clearable.test.ts
  3. 2 4
      src/app/(authenticated)/customers/[id]/customer-detail-client.tsx
  4. 13 2
      src/features/custom-fields/Actions/customFieldActions.ts
  5. 4 2
      src/features/custom-fields/Components/CustomFieldsManager.tsx
  6. 11 7
      src/features/customers/Actions/customerActions.ts
  7. 10 7
      src/features/customers/Components/CustomerForm.tsx
  8. 3 2
      src/features/inspections/Actions/inspectionActions.ts
  9. 3 2
      src/features/inspections/Actions/templateActions.ts
  10. 2 1
      src/features/inspections/Components/InspectionItemRow.tsx
  11. 2 1
      src/features/inspections/Components/TemplateForm.tsx
  12. 13 9
      src/features/inventory/Components/InventoryPartForm.tsx
  13. 4 2
      src/features/labor-presets/Components/LaborPresetForm.tsx
  14. 11 1
      src/features/quotes/Actions/quoteActions.ts
  15. 8 6
      src/features/quotes/Components/useQuoteFormState.ts
  16. 5 2
      src/features/scheduled-messages/Components/ScheduleMessageDialog.tsx
  17. 8 2
      src/features/vehicles/Actions/serviceActions.ts
  18. 2 1
      src/features/vehicles/Components/FindingForm.tsx
  19. 6 3
      src/features/vehicles/Components/ReminderForm.tsx
  20. 6 3
      src/features/vehicles/Components/ReminderFormDialog.tsx
  21. 7 5
      src/features/vehicles/Components/VehicleForm.tsx
  22. 19 8
      src/features/vehicles/Components/service-page/useServiceActions.ts
  23. 1 1
      src/features/vehicles/Schema/reminderSchema.ts
  24. 1 1
      src/features/vehicles/Schema/serviceSchema.ts
  25. 28 0
      src/lib/clearable.ts

+ 300 - 0
src/__tests__/features/forms/clear-optional-fields.test.ts

@@ -0,0 +1,300 @@
+/**
+ * Emptying an optional field in an edit form has to clear it.
+ *
+ * A form that turns an emptied input into undefined, or an action that reads
+ * '' as "not touched", brings the old value back after save. These tests pin
+ * the server half: '' reaches the database as null, and a key left out of the
+ * input leaves the stored value alone.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/resolve-upload-path', () => ({ resolveUploadPath: vi.fn((u: string) => u) }))
+vi.mock('fs/promises', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('fs/promises')>()
+  return { ...actual, default: actual, unlink: vi.fn().mockResolvedValue(undefined) }
+})
+vi.mock('@/lib/features', () => ({
+  getFeatures: vi.fn().mockResolvedValue({ maxCustomers: 1000 }),
+  requireFeature: vi.fn().mockResolvedValue(undefined),
+  FeatureGatedError: class FeatureGatedError extends Error {},
+}))
+vi.mock('@/lib/whatsapp', () => ({ claimWhatsappMessagesForCustomer: vi.fn() }))
+vi.mock('@/lib/notification-bus', () => ({ notificationBus: { emit: vi.fn() } }))
+vi.mock('@/lib/document-lock.server', () => ({
+  assertQuoteEditable: vi.fn().mockResolvedValue(undefined),
+  assertInvoiceEditable: vi.fn().mockResolvedValue(undefined),
+  getDocumentLockSettings: vi.fn().mockResolvedValue({}),
+}))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    appSetting: { findMany: vi.fn().mockResolvedValue([]) },
+    customer: { updateMany: vi.fn() },
+    serviceRequest: { updateMany: vi.fn() },
+    customFieldDefinition: { findFirst: vi.fn(), update: vi.fn() },
+    quote: { findFirst: vi.fn() },
+    inspectionItem: { findFirst: vi.fn(), update: vi.fn() },
+    vehicle: { findFirst: vi.fn(), update: vi.fn() },
+    serviceRecord: { findFirst: vi.fn(), findMany: vi.fn(), update: vi.fn() },
+    $transaction: vi.fn(),
+  },
+}))
+
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { updateCustomer, updateServiceRequest } from '@/features/customers/Actions/customerActions'
+import { updateFieldDefinition } from '@/features/custom-fields/Actions/customFieldActions'
+import { updateQuote } from '@/features/quotes/Actions/quoteActions'
+import { updateInspectionItem } from '@/features/inspections/Actions/inspectionActions'
+import { updateServiceRecord } from '@/features/vehicles/Actions/serviceActions'
+
+const ORG = 'org-a'
+
+function signInAsOwner() {
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'user-a', email: 'a@example.com' },
+  } as any)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as any)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as any)
+}
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  vi.mocked(db.appSetting.findMany).mockResolvedValue([])
+  signInAsOwner()
+})
+
+describe('updateCustomer', () => {
+  it('clears every optional field that arrives emptied', async () => {
+    vi.mocked(db.customer.updateMany).mockResolvedValue({ count: 1 } as any)
+
+    const result = await updateCustomer({
+      id: 'cust-a',
+      name: 'Alice',
+      customerNumber: '',
+      email: '',
+      phone: '',
+      address: '',
+      company: '',
+      taxId: '',
+      notes: '',
+    })
+
+    expect(result.success).toBe(true)
+    const { data } = vi.mocked(db.customer.updateMany).mock.calls[0][0] as any
+    expect(data).toMatchObject({
+      customerNumber: null,
+      email: null,
+      phone: null,
+      address: null,
+      company: null,
+      taxId: null,
+      notes: null,
+    })
+  })
+
+  it('leaves fields alone that the caller did not send', async () => {
+    vi.mocked(db.customer.updateMany).mockResolvedValue({ count: 1 } as any)
+
+    await updateCustomer({ id: 'cust-a', name: 'Alice Updated' })
+
+    const { data } = vi.mocked(db.customer.updateMany).mock.calls[0][0] as any
+    for (const key of [
+      'customerNumber',
+      'email',
+      'phone',
+      'address',
+      'company',
+      'taxId',
+      'notes',
+    ]) {
+      expect(data[key], key).toBeUndefined()
+    }
+  })
+})
+
+describe('updateServiceRequest', () => {
+  it('clears the admin notes when they arrive emptied', async () => {
+    vi.mocked(db.serviceRequest.updateMany).mockResolvedValue({ count: 1 } as any)
+
+    await updateServiceRequest('req-1', { adminNotes: '' })
+
+    const { data } = vi.mocked(db.serviceRequest.updateMany).mock.calls[0][0] as any
+    expect(data.adminNotes).toBeNull()
+    expect(data.status).toBeUndefined()
+  })
+
+  it('keeps the notes when only the status changes', async () => {
+    vi.mocked(db.serviceRequest.updateMany).mockResolvedValue({ count: 1 } as any)
+
+    await updateServiceRequest('req-1', { status: 'dismissed' })
+
+    const { data } = vi.mocked(db.serviceRequest.updateMany).mock.calls[0][0] as any
+    expect(data.adminNotes).toBeUndefined()
+    expect(data.status).toBe('dismissed')
+  })
+})
+
+describe('updateFieldDefinition', () => {
+  const definition = {
+    id: 'field-1',
+    name: 'tyre_size',
+    label: 'Tyre size',
+    fieldType: 'text',
+    entityType: 'service_record',
+    required: false,
+  }
+
+  it('clears the default value and the options list', async () => {
+    vi.mocked(db.customFieldDefinition.findFirst).mockResolvedValue({ id: 'field-1' } as any)
+    vi.mocked(db.customFieldDefinition.update).mockResolvedValue({ id: 'field-1' } as any)
+
+    const result = await updateFieldDefinition({ ...definition, defaultValue: '', options: '' })
+
+    expect(result.success).toBe(true)
+    const { data } = vi.mocked(db.customFieldDefinition.update).mock.calls[0][0] as any
+    expect(data.defaultValue).toBeNull()
+    expect(data.options).toBeNull()
+  })
+})
+
+describe('updateQuote', () => {
+  function runTransaction() {
+    const update = vi.fn().mockResolvedValue({ id: 'quote-a' })
+    vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
+      fn({
+        quote: { update },
+        quotePart: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }), createMany: vi.fn() },
+        quoteLabor: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }), createMany: vi.fn() },
+      })
+    )
+    return update
+  }
+
+  it('clears description, notes, dates, customer, vehicle and discount', async () => {
+    vi.mocked(db.quote.findFirst).mockResolvedValue({ id: 'quote-a', organizationId: ORG } as any)
+    const update = runTransaction()
+
+    const result = await updateQuote({
+      id: 'quote-a',
+      title: 'Brakes',
+      description: '',
+      notes: '',
+      validUntil: '',
+      customerId: '',
+      vehicleId: '',
+      discountType: 'none',
+    })
+
+    expect(result.success).toBe(true)
+    const { data } = update.mock.calls[0][0]
+    expect(data).toMatchObject({
+      description: null,
+      notes: null,
+      validUntil: null,
+      customerId: null,
+      vehicleId: null,
+      discountType: null,
+    })
+  })
+
+  it('leaves those fields alone when they are not sent', async () => {
+    vi.mocked(db.quote.findFirst).mockResolvedValue({ id: 'quote-a', organizationId: ORG } as any)
+    const update = runTransaction()
+
+    await updateQuote({ id: 'quote-a', title: 'Brakes' })
+
+    const { data } = update.mock.calls[0][0]
+    for (const key of ['description', 'notes', 'validUntil', 'customerId', 'vehicleId']) {
+      expect(data[key], key).toBeUndefined()
+    }
+  })
+})
+
+describe('updateInspectionItem', () => {
+  it('clears the notes and the text reading', async () => {
+    vi.mocked(db.inspectionItem.findFirst).mockResolvedValue({ id: 'item-a' } as any)
+    vi.mocked(db.inspectionItem.update).mockResolvedValue({ id: 'item-a' } as any)
+
+    await updateInspectionItem('item-a', { condition: 'pass', notes: '', textValue: '' })
+
+    const { data } = vi.mocked(db.inspectionItem.update).mock.calls[0][0] as any
+    expect(data.notes).toBeNull()
+    expect(data.textValue).toBeNull()
+  })
+})
+
+describe('updateServiceRecord', () => {
+  const existing = {
+    id: 'sr-a',
+    organizationId: ORG,
+    vehicleId: 'veh-a',
+    customerId: null,
+    title: 'Oil change',
+    status: 'pending',
+    serviceDate: new Date('2026-01-10'),
+    attachments: [],
+    vehicle: { id: 'veh-a', mileage: 50000 },
+  }
+
+  function runTransaction() {
+    const update = vi.fn().mockResolvedValue({ ...existing })
+    vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
+      fn({ serviceRecord: { update } })
+    )
+    return update
+  }
+
+  it('clears mileage, invoice number, invoice dates and notes', async () => {
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(existing as any)
+    const update = runTransaction()
+
+    const result = await updateServiceRecord({
+      id: 'sr-a',
+      mileage: null,
+      invoiceNumber: '',
+      invoiceDate: '',
+      invoiceDueDate: '',
+      description: '',
+      diagnosticNotes: '',
+      invoiceNotes: '',
+    })
+
+    expect(result.success).toBe(true)
+    const { data } = update.mock.calls[0][0]
+    expect(data).toMatchObject({
+      mileage: null,
+      invoiceNumber: null,
+      invoiceDate: null,
+      invoiceDueDate: null,
+      description: null,
+      diagnosticNotes: null,
+      invoiceNotes: null,
+    })
+  })
+
+  it('keeps a mileage of zero and leaves unsent fields alone', async () => {
+    vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(existing as any)
+    const update = runTransaction()
+
+    await updateServiceRecord({ id: 'sr-a', mileage: 0 })
+
+    const { data } = update.mock.calls[0][0]
+    expect(data.mileage).toBe(0)
+    for (const key of ['invoiceNumber', 'invoiceDate', 'invoiceDueDate', 'description']) {
+      expect(data[key], key).toBeUndefined()
+    }
+  })
+})

+ 46 - 0
src/__tests__/lib/clearable.test.ts

@@ -0,0 +1,46 @@
+import { describe, it, expect } from 'vitest'
+import { clearableInput, clearedToNull } from '@/lib/clearable'
+
+describe('clearableInput', () => {
+  it('sends the trimmed text when there is any', () => {
+    expect(clearableInput('  ABC123 ', true)).toBe('ABC123')
+    expect(clearableInput('  ABC123 ', false)).toBe('ABC123')
+  })
+
+  it("sends '' for an emptied field when editing, so the action clears it", () => {
+    expect(clearableInput('', true)).toBe('')
+    expect(clearableInput('   ', true)).toBe('')
+    expect(clearableInput(null, true)).toBe('')
+  })
+
+  it('leaves an empty field out when creating', () => {
+    expect(clearableInput('', false)).toBeUndefined()
+    expect(clearableInput(null, false)).toBeUndefined()
+    expect(clearableInput(undefined, false)).toBeUndefined()
+  })
+})
+
+describe('clearedToNull', () => {
+  it('keeps an untouched field untouched', () => {
+    expect(clearedToNull(undefined)).toBeUndefined()
+  })
+
+  it('turns an emptied field into null', () => {
+    expect(clearedToNull('')).toBeNull()
+    expect(clearedToNull('   ')).toBeNull()
+    expect(clearedToNull(null)).toBeNull()
+  })
+
+  it('stores other values trimmed', () => {
+    expect(clearedToNull(' hello ')).toBe('hello')
+  })
+})
+
+describe('schemas behind clearable number fields', () => {
+  it('let a cleared reading through as null instead of coercing it to 0', async () => {
+    const { updateReminderSchema } = await import('@/features/vehicles/Schema/reminderSchema')
+    const { updateServiceSchema } = await import('@/features/vehicles/Schema/serviceSchema')
+    expect(updateReminderSchema.parse({ id: 'r1', dueMileage: null }).dueMileage).toBeNull()
+    expect(updateServiceSchema.parse({ id: 's1', mileage: null }).mileage).toBeNull()
+  })
+})

+ 2 - 4
src/app/(authenticated)/customers/[id]/customer-detail-client.tsx

@@ -1073,7 +1073,7 @@ function ServiceRequestCard({
     startTransition(async () => {
       const result = await updateServiceRequest(request.id, {
         status: 'dismissed',
-        adminNotes: notes || undefined,
+        adminNotes: notes,
       })
       if (result.success) {
         toast.success(t('requestDismissed'))
@@ -1086,9 +1086,7 @@ function ServiceRequestCard({
 
   const handleSaveNotes = () => {
     startTransition(async () => {
-      const result = await updateServiceRequest(request.id, {
-        adminNotes: notes || undefined,
-      })
+      const result = await updateServiceRequest(request.id, { adminNotes: notes })
       if (result.success) {
         toast.success(t('notesSaved'))
         router.refresh()

+ 13 - 2
src/features/custom-fields/Actions/customFieldActions.ts

@@ -9,6 +9,7 @@ import {
 import { revalidatePath } from 'next/cache'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { requireFeature } from '@/lib/features'
+import { clearedToNull } from '@/lib/clearable'
 
 export async function getFieldDefinitions(entityType?: string) {
   return withAuth(
@@ -41,7 +42,13 @@ export async function createFieldDefinition(input: unknown) {
       if (existing) throw new Error('A field with this name already exists for this entity type')
 
       const field = await db.customFieldDefinition.create({
-        data: { ...data, userId, organizationId },
+        data: {
+          ...data,
+          options: clearedToNull(data.options),
+          defaultValue: clearedToNull(data.defaultValue),
+          userId,
+          organizationId,
+        },
       })
 
       revalidatePath('/settings/custom-fields')
@@ -75,7 +82,11 @@ export async function updateFieldDefinition(input: unknown) {
 
       const field = await db.customFieldDefinition.update({
         where: { id },
-        data: rest,
+        data: {
+          ...rest,
+          options: clearedToNull(rest.options),
+          defaultValue: clearedToNull(rest.defaultValue),
+        },
       })
 
       revalidatePath('/settings/custom-fields')

+ 4 - 2
src/features/custom-fields/Components/CustomFieldsManager.tsx

@@ -174,10 +174,12 @@ export function CustomFieldsManager({
 
   const handleSave = async () => {
     setLoading(true)
+    // Sent as-is: '' clears a default, and a type without options clears
+    // the list a select used to have.
     const payload = {
       ...formData,
-      options: formData.fieldType === 'select' ? formData.options : undefined,
-      defaultValue: formData.defaultValue || undefined,
+      options: formData.fieldType === 'select' ? formData.options : '',
+      defaultValue: formData.defaultValue,
     }
 
     const result = editing

+ 11 - 7
src/features/customers/Actions/customerActions.ts

@@ -9,6 +9,7 @@ import { getFeatures, FeatureGatedError } from '@/lib/features'
 import { createDraftServiceRecord } from '@/features/vehicles/Actions/createDraftServiceRecord'
 import { claimWhatsappMessagesForCustomer } from '@/lib/whatsapp'
 import { serviceDateOrderBy } from '@/lib/date-sort'
+import { clearedToNull } from '@/lib/clearable'
 
 /**
  * A design a customer is being pointed at has to be this workshop's and an
@@ -157,14 +158,17 @@ export async function updateCustomer(input: unknown) {
       try {
         result = await db.customer.updateMany({
           where: { id, organizationId },
+          // Fields left out of the input stay as they are; an emptied one
+          // ('') is cleared.
           data: {
             ...data,
-            customerNumber:
-              data.customerNumber !== undefined ? data.customerNumber.trim() || null : undefined,
-            email: data.email || null,
-            company: data.company || null,
-            phone: data.phone || null,
-            address: data.address || null,
+            customerNumber: clearedToNull(data.customerNumber),
+            email: clearedToNull(data.email),
+            company: clearedToNull(data.company),
+            phone: clearedToNull(data.phone),
+            address: clearedToNull(data.address),
+            taxId: clearedToNull(data.taxId),
+            notes: clearedToNull(data.notes),
           },
         })
       } catch (err: unknown) {
@@ -418,7 +422,7 @@ export async function updateServiceRequest(
     async ({ userId, organizationId }) => {
       const result = await db.serviceRequest.updateMany({
         where: { id: requestId, organizationId },
-        data,
+        data: { status: data.status, adminNotes: clearedToNull(data.adminNotes) },
       })
       if (result.count === 0) throw new Error('Service request not found')
       revalidatePath('/customers')

+ 10 - 7
src/features/customers/Components/CustomerForm.tsx

@@ -32,6 +32,7 @@ import { createVehicle } from '@/features/vehicles/Actions/vehicleActions'
 import { ScanDocumentButton } from '@/features/vehicles/Components/ScanDocumentButton'
 import type { VehicleDocumentScan } from '@/features/vehicles/Actions/aiAnalyzeVehicleDocument'
 import { Loader2 } from 'lucide-react'
+import { clearableInput } from '@/lib/clearable'
 
 interface CustomerFormProps {
   open: boolean
@@ -125,17 +126,19 @@ export function CustomerForm({
     setLoading(true)
 
     const formData = new FormData(e.currentTarget)
+    // An emptied field reaches the update action as '' so it can be cleared.
+    const optional = (name: string) => clearableInput(formData.get(name), Boolean(customer))
     const data = {
       name: formData.get('name') as string,
-      customerNumber: (formData.get('customerNumber') as string) || undefined,
-      email: (formData.get('email') as string) || undefined,
-      phone: (formData.get('phone') as string) || undefined,
-      address: (formData.get('address') as string) || undefined,
-      company: (formData.get('company') as string) || undefined,
-      taxId: (formData.get('taxId') as string) || undefined,
+      customerNumber: optional('customerNumber'),
+      email: optional('email'),
+      phone: optional('phone'),
+      address: optional('address'),
+      company: optional('company'),
+      taxId: optional('taxId'),
       taxExempt,
       reminderOptOut,
-      notes: (formData.get('notes') as string) || undefined,
+      notes: optional('notes'),
       invoiceDesignId: invoiceDesignId === FOLLOW_DEFAULT ? null : invoiceDesignId,
     }
 

+ 3 - 2
src/features/inspections/Actions/inspectionActions.ts

@@ -12,6 +12,7 @@ import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { notificationBus } from '@/lib/notification-bus'
 import { isDefect } from '../Lib/conditions'
 import { findCompletionBlockers, summariseBlockers } from '../Lib/completion'
+import { clearedToNull } from '@/lib/clearable'
 
 export async function getInspectionsPaginated(params: {
   page?: number
@@ -294,10 +295,10 @@ export async function updateInspectionItem(itemId: string, input: unknown) {
         where: { id: itemId },
         data: {
           condition: data.condition,
-          notes: data.notes,
+          notes: clearedToNull(data.notes),
           imageUrls: data.imageUrls,
           measuredValue: data.measuredValue,
-          textValue: data.textValue,
+          textValue: clearedToNull(data.textValue),
         },
       })
 

+ 3 - 2
src/features/inspections/Actions/templateActions.ts

@@ -7,6 +7,7 @@ import { revalidatePath } from 'next/cache'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import type { TemplateSectionInput } from '../Schema/templateSchema'
 import { TEMPLATE_PRESETS, presetPackageId, presetToTemplateCreate } from '../Lib/templatePresets'
+import { clearedToNull } from '@/lib/clearable'
 
 /**
  * Sections and their checks are always rewritten wholesale rather than diffed,
@@ -106,7 +107,7 @@ export async function createTemplate(input: unknown) {
         const created = await tx.inspectionTemplate.create({
           data: {
             name: data.name,
-            description: data.description,
+            description: clearedToNull(data.description),
             isDefault: data.isDefault,
             country: data.country ?? null,
             standard: data.standard ?? 'custom',
@@ -168,7 +169,7 @@ export async function updateTemplate(input: unknown) {
           where: { id: data.id },
           data: {
             name: data.name,
-            description: data.description,
+            description: clearedToNull(data.description),
             isDefault: data.isDefault,
             country: data.country ?? null,
             standard: data.standard ?? 'custom',

+ 2 - 1
src/features/inspections/Components/InspectionItemRow.tsx

@@ -248,7 +248,8 @@ export function InspectionItemRow({
     startSaving(async () => {
       const result = await updateInspectionItem(item.id, {
         condition: next.condition,
-        notes: next.notes || undefined,
+        // '' clears notes; the action turns it into null.
+        notes: next.notes,
         imageUrls: next.imageUrls,
         measuredValue: next.measuredValue,
         textValue: next.textValue,

+ 2 - 1
src/features/inspections/Components/TemplateForm.tsx

@@ -55,6 +55,7 @@ import { toast } from 'sonner'
 import { createTemplate, updateTemplate } from '../Actions/templateActions'
 import { COMMON_UNITS, INPUT_TYPES, type InputType, type SeverityScale } from '../Lib/conditions'
 import { TEMPLATE_COUNTRIES } from '../Lib/templatePresets'
+import { clearableInput } from '@/lib/clearable'
 
 /**
  * Sections and checks are lists, so sideways movement during a drag is only
@@ -711,7 +712,7 @@ export function TemplateForm({
     const payload = {
       ...(isEdit ? { id: template.id } : {}),
       name,
-      description: description || undefined,
+      description: clearableInput(description, isEdit),
       isDefault,
       country: country === 'none' ? null : country,
       standard: template?.standard ?? 'custom',

+ 13 - 9
src/features/inventory/Components/InventoryPartForm.tsx

@@ -51,6 +51,7 @@ import { cn } from '@/lib/utils'
 import { compressImage } from '@/lib/compress-image'
 import { priceFromCostAndMultiplier } from '@/features/inventory/Lib/partPricing'
 import { UnitCombobox } from '@/features/inventory/Components/UnitCombobox'
+import { clearableInput } from '@/lib/clearable'
 
 interface InventoryPartFormProps {
   open: boolean
@@ -349,21 +350,24 @@ export function InventoryPartForm({
     setLoading(true)
 
     const formData = new FormData(e.currentTarget)
+    // An emptied field reaches the update action as '' so it can be cleared.
+    const optional = (value: FormDataEntryValue | string | null) =>
+      clearableInput(value, Boolean(part))
     const data = {
       name: formData.get('name') as string,
-      partNumber: (formData.get('partNumber') as string) || undefined,
-      barcode: (formData.get('barcode') as string) || undefined,
-      description: (formData.get('description') as string) || undefined,
-      category: category || undefined,
+      partNumber: optional(formData.get('partNumber')),
+      barcode: optional(formData.get('barcode')),
+      description: optional(formData.get('description')),
+      category: optional(category),
       quantity: Number(formData.get('quantity')) || 0,
       minQuantity: Number(formData.get('minQuantity')) || 0,
       unit: unit.trim(),
       unitCost: Number(formData.get('unitCost')) || 0,
       sellPrice: Number(formData.get('sellPrice')) || 0,
-      supplier: (formData.get('supplier') as string) || undefined,
-      supplierPhone: (formData.get('supplierPhone') as string) || undefined,
-      supplierEmail: (formData.get('supplierEmail') as string) || undefined,
-      supplierUrl: supplierUrl || undefined,
+      supplier: optional(formData.get('supplier')),
+      supplierPhone: optional(formData.get('supplierPhone')),
+      supplierEmail: optional(formData.get('supplierEmail')),
+      supplierUrl: optional(supplierUrl),
       gallery: gallery.map((g, i) => ({
         id: g.id,
         url: g.url,
@@ -371,7 +375,7 @@ export function InventoryPartForm({
         description: g.description || undefined,
         sortOrder: i,
       })),
-      location: (formData.get('location') as string) || undefined,
+      location: optional(formData.get('location')),
     }
 
     const result = part

+ 4 - 2
src/features/labor-presets/Components/LaborPresetForm.tsx

@@ -14,6 +14,7 @@ import { createLaborPreset, updateLaborPreset } from '../Actions/laborPresetActi
 import { Loader2, Plus, Trash2 } from 'lucide-react'
 import { PresetPartsEditor } from './PresetPartsEditor'
 import type { PresetPartItem } from './PresetPartsEditor'
+import { clearableInput } from '@/lib/clearable'
 
 type PricingType = 'hourly' | 'service'
 
@@ -276,7 +277,8 @@ export function LaborPresetForm({
         const result = await updateLaborPreset({
           id: preset.id,
           name: '',
-          description: item.description || undefined,
+          // '' clears a description the preset used to have.
+          description: item.description.trim(),
           items: [
             {
               description: item.name,
@@ -332,7 +334,7 @@ export function LaborPresetForm({
 
       const data = {
         name,
-        description: description || undefined,
+        description: clearableInput(description, Boolean(preset)),
         items: validItems.map((item, index) => ({
           description: item.description,
           hours: Number(item.hours) || 0,

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

@@ -12,6 +12,7 @@ import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { reconcileInventoryForParts } from '@/features/inventory/Lib/reconcileStock'
 import { copyFile, mkdir } from 'fs/promises'
 import path from 'path'
+import { clearedToNull } from '@/lib/clearable'
 
 /**
  * Default valid-until for new quotes: today plus workshop.quoteValidDays
@@ -304,9 +305,18 @@ export async function updateQuote(input: unknown) {
       const quote = await db.$transaction(async (tx) => {
         const updated = await tx.quote.update({
           where: { id },
+          // Fields left out of the input stay as they are; emptied ones are
+          // cleared.
           data: {
             ...quoteData,
-            validUntil: toSafeDate(quoteData.validUntil),
+            description: clearedToNull(quoteData.description),
+            notes: clearedToNull(quoteData.notes),
+            customerId: clearedToNull(quoteData.customerId),
+            vehicleId: clearedToNull(quoteData.vehicleId),
+            validUntil:
+              quoteData.validUntil !== undefined
+                ? (toSafeDate(quoteData.validUntil) ?? null)
+                : undefined,
             discountType: quoteData.discountType === 'none' ? null : quoteData.discountType,
           },
         })

+ 8 - 6
src/features/quotes/Components/useQuoteFormState.ts

@@ -353,19 +353,21 @@ export function useQuoteFormState({
     const result = await updateQuote({
       id: quote.id,
       title: formData.get('title') as string,
-      description: description || undefined,
+      // Emptied fields go as '' (and a removed discount as 'none') so the
+      // update action clears them; undefined would leave the old value.
+      description,
       status,
-      validUntil: (formData.get('validUntil') as string) || undefined,
-      customerId: customerId || undefined,
-      vehicleId: vehicleId || undefined,
-      notes: notes || undefined,
+      validUntil: (formData.get('validUntil') as string | null) ?? '',
+      customerId,
+      vehicleId,
+      notes,
       partItems: partItems.filter((p) => p.name),
       laborItems: laborItems.filter((l) => l.description),
       subtotal,
       taxRate,
       taxInclusive,
       taxAmount,
-      discountType: discountType === 'none' ? undefined : discountType,
+      discountType,
       discountValue,
       discountAmount,
       totalAmount,

+ 5 - 2
src/features/scheduled-messages/Components/ScheduleMessageDialog.tsx

@@ -21,6 +21,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
 import { DocsLink } from '@/components/docs-link'
 import { CustomerCombobox } from '@/features/quotes/Components/CustomerCombobox'
 import { createScheduledMessage, updateScheduledMessage } from '../Actions/scheduledMessageActions'
+import { clearableInput } from '@/lib/clearable'
 import {
   MESSAGE_FREQUENCIES,
   type MessageChannel,
@@ -166,9 +167,11 @@ export function ScheduleMessageDialog({
 
     const payload = {
       channel,
-      subject: subject.trim() || undefined,
+      // When editing, an emptied subject or recipient override goes as ''
+      // so the update action clears it.
+      subject: clearableInput(subject, isEdit),
       body: body.trim(),
-      recipient: recipient.trim() || undefined,
+      recipient: clearableInput(recipient, isEdit),
       customerId: customerId || null,
       sendAt: `${date}T${time}`,
       frequency,

+ 8 - 2
src/features/vehicles/Actions/serviceActions.ts

@@ -623,8 +623,14 @@ export async function updateServiceRecord(input: unknown) {
               recordData.invoiceNumber !== undefined ? recordData.invoiceNumber || null : undefined,
             mileage: recordData.mileage !== undefined ? (recordData.mileage ?? null) : undefined,
             serviceDate: toSafeDate(data.serviceDate),
-            invoiceDate: toSafeDate(data.invoiceDate),
-            invoiceDueDate: toSafeDate(data.invoiceDueDate),
+            // An emptied date clears it; the invoice then falls back to the
+            // scheduled start or the service date, as it did before one was set.
+            invoiceDate:
+              data.invoiceDate !== undefined ? (toSafeDate(data.invoiceDate) ?? null) : undefined,
+            invoiceDueDate:
+              data.invoiceDueDate !== undefined
+                ? (toSafeDate(data.invoiceDueDate) ?? null)
+                : undefined,
             warrantyMonths:
               data.warrantyMonths !== undefined ? data.warrantyMonths || null : undefined,
             warrantyMileage:

+ 2 - 1
src/features/vehicles/Components/FindingForm.tsx

@@ -19,6 +19,7 @@ import { toast } from 'sonner'
 import { createFinding, updateFinding } from '../Actions/findingActions'
 import { Loader2 } from 'lucide-react'
 import { useTranslations } from 'next-intl'
+import { clearableInput } from '@/lib/clearable'
 
 interface FindingData {
   id: string
@@ -83,7 +84,7 @@ export function FindingForm({
       vehicleId,
       description,
       severity: severity as 'needs_work' | 'monitor' | 'urgent',
-      notes: notes || undefined,
+      notes: clearableInput(notes, isEdit),
       serviceRecordId: serviceRecordId || undefined,
       concernId: concernId === 'none' ? null : concernId,
     }

+ 6 - 3
src/features/vehicles/Components/ReminderForm.tsx

@@ -14,6 +14,7 @@ import { toast } from 'sonner'
 import { createReminder, updateReminder } from '../Actions/reminderActions'
 import { Loader2 } from 'lucide-react'
 import { useTranslations } from 'next-intl'
+import { clearableInput } from '@/lib/clearable'
 
 interface ReminderData {
   id: string
@@ -72,9 +73,11 @@ export function ReminderForm({ vehicleId, open, onOpenChange, reminder }: Remind
     const payload = {
       vehicleId,
       title,
-      description: description || undefined,
-      dueDate: dueDate || undefined,
-      dueMileage: dueMileage ? Number(dueMileage) : undefined,
+      // When editing, emptied fields are sent as cleared ('' or null) so the
+      // update action drops the old value.
+      description: clearableInput(description, Boolean(reminder)),
+      dueDate: clearableInput(dueDate, Boolean(reminder)),
+      dueMileage: dueMileage ? Number(dueMileage) : reminder ? null : undefined,
       notifyInApp,
       notifyEmail,
     }

+ 6 - 3
src/features/vehicles/Components/ReminderFormDialog.tsx

@@ -26,6 +26,7 @@ import { toast } from 'sonner'
 import { cn } from '@/lib/utils'
 import { useGlassModal } from '@/components/glass-modal'
 import { createReminder, updateReminder } from '../Actions/reminderActions'
+import { clearableInput } from '@/lib/clearable'
 
 export interface ReminderFormVehicle {
   id: string
@@ -136,11 +137,13 @@ export function ReminderFormDialog({
       vehicleId: formVehicleId || null,
       customerId: formCustomerId || null,
       title: formTitle,
-      description: formDescription || undefined,
+      // When editing, emptied fields are sent as cleared ('' or null) so the
+      // update action drops the old value.
+      description: clearableInput(formDescription, isEdit),
       // Midday on the local day, so the reminder stays on the day that was
       // picked whichever side of UTC the workshop sits on
-      dueDate: formDueDate ? `${toLocalDateStr(formDueDate)}T12:00:00` : undefined,
-      dueMileage: formDueMileage ? Number(formDueMileage) : undefined,
+      dueDate: formDueDate ? `${toLocalDateStr(formDueDate)}T12:00:00` : isEdit ? '' : undefined,
+      dueMileage: formDueMileage ? Number(formDueMileage) : isEdit ? null : undefined,
       notifyInApp: formNotifyInApp,
       notifyEmail: formNotifyEmail,
     }

+ 7 - 5
src/features/vehicles/Components/VehicleForm.tsx

@@ -47,6 +47,7 @@ import { createCustomer } from '@/features/customers/Actions/customerActions'
 import { useFormatter, useTranslations } from 'next-intl'
 import { useServiceType } from '@/components/service-type-context'
 import type { CreateVehicleInput } from '../Schema/vehicleSchema'
+import { clearableInput } from '@/lib/clearable'
 
 /**
  * How alike two names must read before the scanned keeper is offered as an
@@ -300,7 +301,11 @@ export function VehicleForm({
 
       // The keeper read off the papers is nobody until a customer row exists,
       // and losing them to an unnoticed checkbox is worse than an extra record.
-      let customerId = selectedCustomerId === 'none' ? undefined : selectedCustomerId || undefined
+      // '' when editing tells the update action to drop the owner.
+      let customerId = clearableInput(
+        selectedCustomerId === 'none' ? '' : selectedCustomerId,
+        Boolean(vehicle)
+      )
       if (!customerId && addOwner && scannedOwner?.name) {
         const created = await createCustomer({
           name: scannedOwner.name,
@@ -317,10 +322,7 @@ export function VehicleForm({
       // the update action turns into null; undefined there means "not
       // touched" and would bring the old value back. On create, leaving an
       // empty field out is the same thing as clearing it.
-      const optional = (name: string): string | undefined => {
-        const value = ((formData.get(name) as string | null) ?? '').trim()
-        return value || (vehicle ? '' : undefined)
-      }
+      const optional = (name: string) => clearableInput(formData.get(name), Boolean(vehicle))
       const data: CreateVehicleInput & { imageUrl?: string } = {
         make: formData.get('make') as string,
         model: formData.get('model') as string,

+ 19 - 8
src/features/vehicles/Components/service-page/useServiceActions.ts

@@ -116,14 +116,25 @@ export function useServiceActions({
       const last = inputs[inputs.length - 1]
       return last?.value ?? (new FormData(form).get(name) as string)
     }
-    const rawMileage = getVisible('mileage')
-    const parsedMileage = rawMileage ? Number(rawMileage) || undefined : undefined
+    // A field the layout does not render at all is left alone; an emptied one
+    // is sent so the update action clears it ('' for text, null for mileage).
+    const optionalText = (name: string): string | undefined => getVisible(name) ?? undefined
+    const rawMileage = getVisible('mileage') as string | null | undefined
+    const mileageNumber = rawMileage == null ? undefined : Number(rawMileage)
+    const parsedMileage =
+      rawMileage == null || Number.isNaN(mileageNumber)
+        ? undefined
+        : rawMileage.trim() === ''
+          ? null
+          : mileageNumber
+    // The editor reports an emptied box as one empty paragraph.
+    const richText = (html: string) => (html === '<p></p>' ? '' : html)
 
     const payload = {
       id: initialData.id,
       vehicleId: selectedVehicleId,
       title: getVisible('title'),
-      description: notesRef.current.description || undefined,
+      description: richText(notesRef.current.description),
       // What the customer said at drop-off. Typed at intake beside the vehicle,
       // not in the notes section, so it is read from the form rather than from
       // the notes state.
@@ -136,11 +147,11 @@ export function useServiceActions({
       // and the Schedule card writes it directly via assignTechnician. Sending
       // the form-state value here would clobber a tech change made via the
       // Schedule card with a stale name from initial load.
-      diagnosticNotes: notesRef.current.diagnosticNotes || undefined,
-      invoiceNotes: notesRef.current.invoiceNotes || undefined,
-      invoiceNumber: getVisible('invoiceNumber') || undefined,
-      invoiceDate: getVisible('invoiceDate') || undefined,
-      invoiceDueDate: getVisible('invoiceDueDate') || undefined,
+      diagnosticNotes: richText(notesRef.current.diagnosticNotes),
+      invoiceNotes: richText(notesRef.current.invoiceNotes),
+      invoiceNumber: optionalText('invoiceNumber'),
+      invoiceDate: optionalText('invoiceDate'),
+      invoiceDueDate: optionalText('invoiceDueDate'),
       // Blank rows are somebody halfway through typing, not a concern.
       concerns: concerns
         .filter((c) => c.description.trim())

+ 1 - 1
src/features/vehicles/Schema/reminderSchema.ts

@@ -7,7 +7,7 @@ export const createReminderSchema = z.object({
   title: z.string().min(1, 'Title is required'),
   description: z.string().optional(),
   dueDate: z.string().optional(),
-  dueMileage: z.coerce.number().optional(),
+  dueMileage: z.coerce.number().nullable().optional(),
   // How the workshop wants to be notified when the reminder comes due
   notifyInApp: z.boolean().optional(),
   notifyEmail: z.boolean().optional(),

+ 1 - 1
src/features/vehicles/Schema/serviceSchema.ts

@@ -60,7 +60,7 @@ export const createServiceSchema = z.object({
   type: z.enum(['maintenance', 'repair', 'upgrade', 'inspection']).default('maintenance'),
   status: z.enum(['pending', 'in-progress', 'waiting-parts', 'completed']).default('pending'),
   cost: z.coerce.number().min(0).default(0),
-  mileage: z.coerce.number().optional(),
+  mileage: z.coerce.number().nullable().optional(),
   serviceDate: z.string().default(() => new Date().toISOString()),
   shopName: z.string().optional(),
   techName: z.string().optional(),

+ 28 - 0
src/lib/clearable.ts

@@ -0,0 +1,28 @@
+/**
+ * Optional text fields that a user is allowed to empty.
+ *
+ * A form and its update action have to agree on what "cleared" looks like.
+ * Prisma skips a key whose value is undefined, so an update action reads
+ * undefined as "not touched" and leaves the stored value alone. An emptied
+ * input therefore has to travel as '' and be turned into null on the server,
+ * or the old value comes back after save. Create paths have no old value, so
+ * leaving an empty field out is the same thing as clearing it.
+ */
+
+/** What an optional text input sends to a server action. */
+export function clearableInput(
+  value: FormDataEntryValue | string | null | undefined,
+  editing: boolean
+): string | undefined {
+  const text = (typeof value === 'string' ? value : '').trim()
+  return text || (editing ? '' : undefined)
+}
+
+/**
+ * What an update action writes for an optional text field: untouched stays
+ * untouched, emptied becomes null, anything else is stored trimmed.
+ */
+export function clearedToNull(value: string | null | undefined): string | null | undefined {
+  if (value === undefined) return undefined
+  return value?.trim() || null
+}