瀏覽代碼

Keep the price when the cost is entered after it (#289)

Entering the unit price before the cost discarded the price: a fresh row
carries a markup of 0, so entering the cost re-priced the line down to
exactly that cost. Entering the two in the other order already worked.

A price typed by hand is now an override, tracked as state rather than
inferred from the numbers, and a later edit to the cost restates the
margin instead of overwriting the price. The margin is restated once
typing pauses, since a margin derived from a cost of 1 reads 99900%.
The markup schemas accept a negative margin, which selling under cost
now produces and which they previously refused to save.
Bernt Christian Egeland 1 月之前
父節點
當前提交
a263b1c8e5

+ 194 - 0
src/__tests__/features/inventory/part-pricing.test.ts

@@ -17,7 +17,9 @@ import { describe, it, expect } from 'vitest'
 import {
   lineTotal,
   markupFromCostAndPrice,
+  isPriceOverridden,
   parseQuantity,
+  repricePartRow,
   priceFromCostAndMarkup,
   priceFromCostAndMultiplier,
   readPartsPricingSettings,
@@ -399,3 +401,195 @@ describe('settings to line total, end to end', () => {
     expect(catalog).toBe(resolved)
   })
 })
+
+/**
+ * These drive the reducer the way the editor does: the number inputs fire on
+ * every keystroke, so entering "1500" is four separate edits carrying "1",
+ * "15", "150" and "1500". Testing whole values in one step hides anything that
+ * only goes wrong on the way there, which is exactly how a version of this
+ * shipped that multiplied a price of 1000 up to 1500000.
+ */
+type Row = {
+  unitCost: string | number
+  markupPercent: string | number
+  unitPrice: string | number
+  priceOverridden?: boolean
+}
+
+/** Applies one keystroke, as the editor's updatePart does. */
+function edit(row: Row, field: 'unitCost' | 'markupPercent' | 'unitPrice', value: string): Row {
+  const next = { ...row, [field]: value }
+  return { ...next, ...repricePartRow(next, field) }
+}
+
+/** Leaves the field, as the editor's onBlur does. */
+function commit(row: Row, field: 'unitCost' | 'markupPercent' | 'unitPrice'): Row {
+  return { ...row, ...repricePartRow(row, field, { commit: true }) }
+}
+
+/**
+ * Types a value one character at a time, reducing on each keystroke, and
+ * returns every state the row passes through.
+ *
+ * The intermediate states are the point. Asserting only the settled row is
+ * what let a markup of 99900% ship: it was on screen for three keystrokes and
+ * the final value was correct, so a test of the end state saw nothing wrong.
+ */
+function keystrokes(
+  row: Row,
+  field: 'unitCost' | 'markupPercent' | 'unitPrice',
+  text: string
+): Row[] {
+  const states: Row[] = []
+  let current = row
+  for (let i = 1; i <= text.length; i++) {
+    current = edit(current, field, text.slice(0, i))
+    states.push(current)
+  }
+  return states
+}
+
+/** The row as it settles, once the field is left. */
+function type(row: Row, field: 'unitCost' | 'markupPercent' | 'unitPrice', text: string): Row {
+  const states = keystrokes(row, field, text)
+  return commit(states[states.length - 1] ?? row, field)
+}
+
+const freshRow: Row = { unitCost: 0, markupPercent: 0, unitPrice: 0 }
+
+describe('repricePartRow, typed one keystroke at a time', () => {
+  it('keeps the price when the cost is entered after it', () => {
+    // The reported bug: 1000 entered as the price, then 1500 as the cost.
+    const row = type(type(freshRow, 'unitPrice', '1000'), 'unitCost', '1500')
+    expect(row.unitPrice).toBe(1000)
+    // 1000 sold on a cost of 1500 is a third under cost, not a 99900% margin.
+    expect(row.markupPercent).toBe(-33.3)
+  })
+
+  it('is not disturbed by an intermediate cost that happens to explain the price', () => {
+    // Passing through a cost of 1 makes 1000 exactly what 99900% implies. A
+    // rule that re-reads the numbers each keystroke takes the bait here.
+    const afterFirstDigit = edit(type(freshRow, 'unitPrice', '1000'), 'unitCost', '1')
+    expect(afterFirstDigit.priceOverridden).toBe(true)
+    expect(edit(afterFirstDigit, 'unitCost', '15').unitPrice).toBe(1000)
+  })
+
+  it('reaches the same row whichever of price and cost is entered first', () => {
+    const priceFirst = type(type(freshRow, 'unitPrice', '1000'), 'unitCost', '1500')
+    const costFirst = type(type(freshRow, 'unitCost', '1500'), 'unitPrice', '1000')
+    expect(priceFirst.unitPrice).toBe(costFirst.unitPrice)
+    expect(priceFirst.markupPercent).toBe(costFirst.markupPercent)
+  })
+
+  it('prices from cost while nothing has been typed over it', () => {
+    const row = type(freshRow, 'unitCost', '1500')
+    expect(row.unitPrice).toBe(1500)
+    expect(row.markupPercent).toBe(0)
+  })
+
+  it('follows the cost on a line that is sold at cost', () => {
+    const atCost = type(freshRow, 'unitCost', '50')
+    expect(type(atCost, 'unitCost', '60').unitPrice).toBe(60)
+  })
+
+  it('follows the cost on a line priced by markup', () => {
+    const row = type(type(freshRow, 'unitCost', '50'), 'markupPercent', '100')
+    expect(row.unitPrice).toBe(100)
+    expect(type(row, 'unitCost', '60').unitPrice).toBe(120)
+  })
+
+  it('hands pricing back to the formula when a markup is typed over an override', () => {
+    // Entering a markup says "price this from cost", which supersedes a price
+    // that was typed earlier.
+    const overridden = type(type(freshRow, 'unitPrice', '1000'), 'unitCost', '1500')
+    const remarked = type(overridden, 'markupPercent', '50')
+    expect(remarked.unitPrice).toBe(2250)
+    expect(remarked.priceOverridden).toBe(false)
+    expect(type(remarked, 'unitCost', '2000').unitPrice).toBe(3000)
+  })
+
+  it('survives a cleared field mid-edit', () => {
+    // Selecting the cost and retyping it empties the box first.
+    const row = type(type(freshRow, 'unitPrice', '1000'), 'unitCost', '1500')
+    const cleared = edit(row, 'unitCost', '')
+    expect(cleared.unitPrice).toBe(1000)
+    expect(type(cleared, 'unitCost', '2000').unitPrice).toBe(1000)
+  })
+})
+
+describe('isPriceOverridden', () => {
+  it('recognises a price that its cost and markup explain', () => {
+    expect(isPriceOverridden({ unitCost: 50, markupPercent: 100, unitPrice: 100 })).toBe(false)
+    expect(isPriceOverridden({ unitCost: 50, markupPercent: 0, unitPrice: 50 })).toBe(false)
+    expect(isPriceOverridden({ unitCost: 0, markupPercent: 0, unitPrice: 0 })).toBe(false)
+  })
+
+  it('recognises a price that they do not', () => {
+    expect(isPriceOverridden({ unitCost: 1500, markupPercent: 0, unitPrice: 1000 })).toBe(true)
+    expect(isPriceOverridden({ unitCost: 0, markupPercent: 0, unitPrice: 1000 })).toBe(true)
+  })
+
+  it('reloads a hand-priced line as still hand-priced', () => {
+    const saved = { unitCost: 1500, markupPercent: -33.3, unitPrice: 1000 }
+    const row: Row = { ...saved, priceOverridden: isPriceOverridden(saved) }
+    expect(type(row, 'unitCost', '2000').unitPrice).toBe(1000)
+  })
+})
+
+describe('a line priced below its cost', () => {
+  it('is saveable, because the markup schemas accept a negative margin', async () => {
+    // Guards the fix above: entering a price under the cost now yields a
+    // negative markup, and a schema flooring markup at 0 would reject the row
+    // on save rather than at the point it was typed.
+    const { quotePartSchema } = await import('@/features/quotes/Schema/quoteSchema')
+    const { servicePartSchema } = await import('@/features/vehicles/Schema/serviceSchema')
+    const row = {
+      name: 'Brake pads',
+      quantity: 1,
+      unitCost: 1500,
+      markupPercent: -33.3,
+      unitPrice: 1000,
+      total: 1000,
+    }
+    expect(quotePartSchema.parse(row).markupPercent).toBe(-33.3)
+    expect(servicePartSchema.parse(row).markupPercent).toBe(-33.3)
+  })
+
+  it('drops the client-only override flag rather than sending it on', async () => {
+    // It describes how the row was edited, and Prisma would reject the column.
+    const { quotePartSchema } = await import('@/features/quotes/Schema/quoteSchema')
+    const parsed = quotePartSchema.parse({
+      name: 'Brake pads',
+      unitPrice: 1000,
+      priceOverridden: true,
+    })
+    expect(parsed).not.toHaveProperty('priceOverridden')
+  })
+})
+
+describe('what the markup field shows while a cost is being typed', () => {
+  const withPrice = type(freshRow, 'unitPrice', '1000')
+
+  it('never shows a margin invented from a half-typed cost', () => {
+    // Typing 1500 passes through 1, 15 and 150. Deriving a margin from those
+    // put 99900% on screen, then 6566.7%, then 566.7%.
+    const shown = keystrokes(withPrice, 'unitCost', '1500').map((r) => r.markupPercent)
+    expect(shown).toEqual([0, 0, 0, 0])
+  })
+
+  it('states the real margin once the field is left', () => {
+    expect(type(withPrice, 'unitCost', '1500').markupPercent).toBe(-33.3)
+  })
+
+  it('holds the price throughout', () => {
+    const shown = keystrokes(withPrice, 'unitCost', '1500').map((r) => r.unitPrice)
+    expect(shown).toEqual([1000, 1000, 1000, 1000])
+  })
+
+  it('still tracks the cost live when the price is following it', () => {
+    // Nothing typed over the price, so it should mirror the cost as it is
+    // entered rather than sitting still.
+    const shown = keystrokes(freshRow, 'unitCost', '1500').map((r) => r.unitPrice)
+    expect(shown).toEqual([1, 15, 150, 1500])
+  })
+})

+ 103 - 0
src/__tests__/hooks/use-deferred-commit.test.ts

@@ -0,0 +1,103 @@
+/**
+ * Tests for useDeferredCommit — the pause detector behind the parts editor's
+ * margin field.
+ *
+ * The case it guards is a field derived from another that updates on every
+ * keystroke. Entering a cost of 1500 against a price of 1000 passes through 1,
+ * and a margin honestly derived from a cost of 1 reads 99900%. Restating the
+ * margin only once typing stops keeps those intermediate values off screen
+ * without making anyone leave the field to see the real one.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { act, renderHook } from '@testing-library/react'
+import { useDeferredCommit } from '@/hooks/use-deferred-commit'
+
+const DELAY = 400
+
+describe('useDeferredCommit', () => {
+  beforeEach(() => vi.useFakeTimers())
+  afterEach(() => vi.useRealTimers())
+
+  it('runs the work once, after typing pauses', () => {
+    const work = vi.fn()
+    const { result } = renderHook(() => useDeferredCommit(DELAY))
+
+    // Four keystrokes of "1500", each replacing the last scheduled restate.
+    act(() => result.current.schedule(work))
+    act(() => result.current.schedule(work))
+    act(() => result.current.schedule(work))
+    act(() => result.current.schedule(work))
+    expect(work).not.toHaveBeenCalled()
+
+    act(() => void vi.advanceTimersByTime(DELAY))
+    expect(work).toHaveBeenCalledTimes(1)
+  })
+
+  it('does not run while keystrokes keep arriving', () => {
+    const work = vi.fn()
+    const { result } = renderHook(() => useDeferredCommit(DELAY))
+
+    act(() => result.current.schedule(work))
+    act(() => void vi.advanceTimersByTime(DELAY - 1))
+    act(() => result.current.schedule(work))
+    act(() => void vi.advanceTimersByTime(DELAY - 1))
+    expect(work).not.toHaveBeenCalled()
+
+    act(() => void vi.advanceTimersByTime(1))
+    expect(work).toHaveBeenCalledTimes(1)
+  })
+
+  it('runs the latest work, not an earlier one', () => {
+    const stale = vi.fn()
+    const latest = vi.fn()
+    const { result } = renderHook(() => useDeferredCommit(DELAY))
+
+    act(() => result.current.schedule(stale))
+    act(() => result.current.schedule(latest))
+    act(() => void vi.advanceTimersByTime(DELAY))
+
+    expect(stale).not.toHaveBeenCalled()
+    expect(latest).toHaveBeenCalledTimes(1)
+  })
+
+  it('drops the pending work when cancelled', () => {
+    const work = vi.fn()
+    const { result } = renderHook(() => useDeferredCommit(DELAY))
+
+    act(() => result.current.schedule(work))
+    act(() => result.current.cancel())
+    act(() => void vi.advanceTimersByTime(DELAY * 4))
+
+    expect(work).not.toHaveBeenCalled()
+  })
+
+  it('runs pending work immediately on flush, and only once', () => {
+    const work = vi.fn()
+    const { result } = renderHook(() => useDeferredCommit(DELAY))
+
+    act(() => result.current.schedule(work))
+    act(() => result.current.flush())
+    expect(work).toHaveBeenCalledTimes(1)
+
+    // The timer must not fire it a second time.
+    act(() => void vi.advanceTimersByTime(DELAY * 4))
+    expect(work).toHaveBeenCalledTimes(1)
+  })
+
+  it('does nothing on flush with nothing pending', () => {
+    const { result } = renderHook(() => useDeferredCommit(DELAY))
+    expect(() => act(() => result.current.flush())).not.toThrow()
+  })
+
+  it('does not fire after the row is unmounted mid-edit', () => {
+    const work = vi.fn()
+    const { result, unmount } = renderHook(() => useDeferredCommit(DELAY))
+
+    act(() => result.current.schedule(work))
+    unmount()
+    act(() => void vi.advanceTimersByTime(DELAY * 4))
+
+    expect(work).not.toHaveBeenCalled()
+  })
+})

+ 92 - 0
src/features/inventory/Lib/partPricing.ts

@@ -158,3 +158,95 @@ export function resolvePartPrice(
   // worse than billing at cost and far easier to miss.
   return { unitPrice: sell > 0 ? sell : cost, markupPercent: 0 }
 }
+
+/**
+ * The three linked money fields on a parts line, plus whether the price has
+ * been set by hand.
+ */
+export interface PricedRow {
+  unitCost: unknown
+  markupPercent: unknown
+  unitPrice: unknown
+  /** See {@link repricePartRow}. Absent means "still following the formula". */
+  priceOverridden?: boolean
+}
+
+export type PricedRowField = 'unitCost' | 'markupPercent' | 'unitPrice'
+
+export interface RepricedRow extends ResolvedPrice {
+  priceOverridden: boolean
+}
+
+/**
+ * Does this row's price disagree with what its cost and markup imply?
+ *
+ * Only for seeding {@link repricePartRow} when a row first arrives from the
+ * database, where it is evaluated once against settled values. It must never
+ * be re-evaluated while someone is typing: each keystroke moves the numbers,
+ * and the answer flips as soon as an intermediate value happens to line up.
+ */
+export function isPriceOverridden(row: {
+  unitCost: unknown
+  markupPercent: unknown
+  unitPrice: unknown
+}): boolean {
+  return roundMoney(row.unitPrice) !== priceFromCostAndMarkup(row.unitCost, row.markupPercent)
+}
+
+/**
+ * Reconciles the three linked fields on a parts line after one of them is
+ * edited, so the row stays consistent and the markup never lies about the
+ * real margin.
+ *
+ * Editing the price makes it an override: it is a decision, and a later edit
+ * to the cost restates the margin instead of overwriting what was typed.
+ * Editing the markup is the opposite instruction, price this from cost, so it
+ * hands pricing back to the formula and clears the override.
+ *
+ * Two things here exist because these fields update on every keystroke, and a
+ * value being typed is not a value someone meant:
+ *
+ *  - The override is remembered rather than inferred from the numbers. Typing
+ *    a cost of 1500 against a price of 1000 passes through 1, and at a cost of
+ *    1 that price is exactly what a markup of 99900% implies, so anything
+ *    reading the numbers alone concludes the price is derived and multiplies
+ *    it up to 1500000.
+ *  - A margin is only restated on `commit`, when the field is left. Derived
+ *    live, it reported 99900%, then 6566.7%, then 566.7% before settling, and
+ *    a wildly wrong margin on screen reads as broken even when the price
+ *    beside it is right.
+ *
+ * The price still tracks a cost as it is typed, because there it simply
+ * mirrors the digits going in and stays recognisable the whole way.
+ */
+export function repricePartRow(
+  row: PricedRow,
+  field: PricedRowField,
+  { commit = false }: { commit?: boolean } = {}
+): RepricedRow {
+  if (field === 'unitPrice') {
+    return {
+      unitPrice: roundMoney(row.unitPrice),
+      markupPercent: markupFromCostAndPrice(row.unitCost, row.unitPrice),
+      priceOverridden: true,
+    }
+  }
+
+  if (field === 'markupPercent' || !row.priceOverridden) {
+    return {
+      unitPrice: priceFromCostAndMarkup(row.unitCost, row.markupPercent),
+      markupPercent: parseNumber(row.markupPercent),
+      priceOverridden: false,
+    }
+  }
+
+  // Cost changed under a hand-set price: hold the price, and restate the
+  // margin only once the cost has stopped moving.
+  return {
+    unitPrice: roundMoney(row.unitPrice),
+    markupPercent: commit
+      ? markupFromCostAndPrice(row.unitCost, row.unitPrice)
+      : parseNumber(row.markupPercent),
+    priceOverridden: true,
+  }
+}

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

@@ -39,7 +39,8 @@ const QuotePartRow = memo(function QuotePartRow({
   onUpdate: (
     index: number,
     field: keyof QuotePartInput,
-    value: string | number | boolean | null
+    value: string | number | boolean | null,
+    options?: { commit?: boolean }
   ) => void
   onDelete: (index: number) => void
   inventoryParts: PartSuggestion[]
@@ -107,6 +108,9 @@ const QuotePartRow = memo(function QuotePartRow({
         step="0.01"
         value={part.unitCost}
         onChange={(e) => onUpdate(index, 'unitCost', e.target.value)}
+        // The margin is restated here rather than on each keystroke: a cost
+        // still being typed is not a cost anyone meant.
+        onBlur={(e) => onUpdate(index, 'unitCost', e.target.value, { commit: true })}
       />
       <Input
         type="number"
@@ -156,7 +160,8 @@ interface QuotePartsEditorProps {
   onUpdate: (
     index: number,
     field: keyof QuotePartInput,
-    value: string | number | boolean | null
+    value: string | number | boolean | null,
+    options?: { commit?: boolean }
   ) => void
   onDelete: (index: number) => void
   onAdd: () => void

+ 1 - 0
src/features/quotes/Components/quote-page-types.ts

@@ -101,6 +101,7 @@ export const emptyPart = (): QuotePartInput => ({
   unitPrice: 0,
   total: 0,
   excluded: false,
+  priceOverridden: false,
   // Free-text line by default; set when picked from stock.
   inventoryPartId: null,
 })

+ 41 - 14
src/features/quotes/Components/useQuoteFormState.ts

@@ -12,11 +12,8 @@ import {
 } from '@/features/quotes/Actions/quoteActions'
 import { acknowledgeQuoteResponse } from '@/features/quotes/Actions/quoteResponseActions'
 import { calculateTotals } from '@/lib/tax'
-import {
-  lineTotal,
-  priceFromCostAndMarkup,
-  markupFromCostAndPrice,
-} from '@/features/inventory/Lib/partPricing'
+import { useDeferredCommit } from '@/hooks/use-deferred-commit'
+import { isPriceOverridden, lineTotal, repricePartRow } from '@/features/inventory/Lib/partPricing'
 import type { QuoteRecord, QuotePartInput, QuoteLaborInput } from './quote-page-types'
 import { emptyPart, makeEmptyLabor, makeEmptyService } from './quote-page-types'
 
@@ -77,8 +74,16 @@ export function useQuoteFormState({
       total: p.total,
       excluded: p.excluded ?? false,
       inventoryPartId: p.inventoryPartId ?? null,
+      // Evaluated once, on settled values from the database; see
+      // isPriceOverridden for why it must not be recomputed while typing.
+      priceOverridden: isPriceOverridden({
+        unitCost: p.unitCost ?? 0,
+        markupPercent: p.markupPercent ?? 0,
+        unitPrice: p.unitPrice,
+      }),
     }))
   )
+  const { schedule: scheduleCommit, cancel: cancelCommit } = useDeferredCommit()
   const [laborItems, setLaborItems] = useState<QuoteLaborInput[]>(
     quote.laborItems.map((l) => ({
       description: l.description,
@@ -185,18 +190,25 @@ export function useQuoteFormState({
     taxInclusive,
   })
 
-  // Same linked pricing model as work order parts:
-  //   unitPrice = unitCost × (1 + markupPercent / 100)
-  // Editing any one of the three keeps the others consistent.
+  // Cost, markup and price stay consistent with each other; repricePartRow
+  // owns which of them moves, and work order parts use the same rules.
   const updatePart = useCallback(
-    (index: number, field: keyof QuotePartInput, value: string | number | boolean | null) => {
+    (
+      index: number,
+      field: keyof QuotePartInput,
+      value: string | number | boolean | null,
+      options: { commit?: boolean } = {}
+    ) => {
+      // Any edit ends the wait on the previous one.
+      cancelCommit()
       setPartItems((prev) => {
         const updated = [...prev]
         const part = { ...updated[index], [field]: value }
-        if (field === 'unitCost' || field === 'markupPercent') {
-          part.unitPrice = priceFromCostAndMarkup(part.unitCost, part.markupPercent)
-        } else if (field === 'unitPrice') {
-          part.markupPercent = markupFromCostAndPrice(part.unitCost, part.unitPrice)
+        if (field === 'unitCost' || field === 'markupPercent' || field === 'unitPrice') {
+          const priced = repricePartRow(part, field, options)
+          part.unitPrice = priced.unitPrice
+          part.markupPercent = priced.markupPercent
+          part.priceOverridden = priced.priceOverridden
         }
         if (
           field === 'quantity' ||
@@ -209,9 +221,24 @@ export function useQuoteFormState({
         updated[index] = part
         return updated
       })
+
+      // A cost typed under a hand-set price restates the margin once typing
+      // stops, so it lands on its own without needing the field to be left.
+      if (field === 'unitCost' && !options.commit) {
+        scheduleCommit(() =>
+          setPartItems((prev) => {
+            const row = prev[index]
+            if (!row?.priceOverridden) return prev
+            const updated = [...prev]
+            updated[index] = { ...row, ...repricePartRow(row, 'unitCost', { commit: true }) }
+            return updated
+          })
+        )
+      }
+
       markDirty()
     },
-    [markDirty]
+    [markDirty, cancelCommit, scheduleCommit]
   )
 
   const updateLabor = useCallback(

+ 17 - 2
src/features/quotes/Schema/quoteSchema.ts

@@ -7,7 +7,13 @@ export const quotePartSchema = z.object({
   /** Unit of measure snapshotted from the picked inventory part. */
   unit: z.string().nullish(),
   unitCost: z.coerce.number().min(0).default(0),
-  markupPercent: z.coerce.number().min(0).default(0),
+  /**
+   * Selling below cost is a real decision (a goodwill line, matching a price),
+   * and the markup has to be able to say so. Floored at -100, which is giving
+   * the part away: anything lower would imply a negative price, which
+   * unitPrice already refuses.
+   */
+  markupPercent: z.coerce.number().min(-100).default(0),
   unitPrice: z.coerce.number().min(0).default(0),
   total: z.coerce.number().min(0).default(0),
   excluded: z.boolean().optional().default(false),
@@ -65,7 +71,16 @@ export const updateQuoteSchema = createQuoteSchema.partial().extend({
 })
 
 export type QuoteAttachmentInput = z.infer<typeof quoteAttachmentSchema>
-export type QuotePartInput = z.infer<typeof quotePartSchema>
+/**
+ * The editor also tracks whether the price was typed over the cost-and-markup
+ * formula, so a later edit to the cost restates the margin instead of
+ * overwriting what was entered. It describes how the row was edited rather
+ * than anything about the part, so it is client-only: the schema above has no
+ * such field and strips it on save.
+ */
+export type QuotePartInput = z.infer<typeof quotePartSchema> & {
+  priceOverridden?: boolean
+}
 export type QuoteLaborInput = z.infer<typeof quoteLaborSchema>
 export type CreateQuoteInput = z.infer<typeof createQuoteSchema>
 export type UpdateQuoteInput = z.infer<typeof updateQuoteSchema>

+ 15 - 2
src/features/vehicles/Components/service-edit/PartsEditor.tsx

@@ -43,7 +43,12 @@ import {
 interface PartsEditorProps {
   partItems: ServicePartInput[]
   setPartItems: React.Dispatch<React.SetStateAction<ServicePartInput[]>>
-  updatePart: (index: number, field: keyof ServicePartInput, value: string | number) => void
+  updatePart: (
+    index: number,
+    field: keyof ServicePartInput,
+    value: string | number,
+    options?: { commit?: boolean }
+  ) => void
   partsSubtotal: number
   currencyCode: string
   hasInventory: boolean
@@ -74,7 +79,12 @@ function SortablePartRow({
   id: string
   part: ServicePartInput
   index: number
-  updatePart: (index: number, field: keyof ServicePartInput, value: string | number) => void
+  updatePart: (
+    index: number,
+    field: keyof ServicePartInput,
+    value: string | number,
+    options?: { commit?: boolean }
+  ) => void
   onDelete: () => void
   currencyCode: string
   inventoryParts: PartSuggestion[]
@@ -181,6 +191,9 @@ function SortablePartRow({
             title={t('unitCostHint')}
             value={part.unitCost ?? 0}
             onChange={(e) => updatePart(index, 'unitCost', e.target.value)}
+            // The margin is restated here rather than on each keystroke: a
+            // cost still being typed is not a cost anyone meant.
+            onBlur={(e) => updatePart(index, 'unitCost', e.target.value, { commit: true })}
           />
         </FieldRow>
         <FieldRow label={t('markupPercent')} hint={t('markupPercentHint')}>

+ 34 - 16
src/features/vehicles/Components/service-page/useServiceFormState.ts

@@ -1,10 +1,7 @@
 import { useState, useCallback, useRef, useEffect } from 'react'
 import { calculateTotals } from '@/lib/tax'
-import {
-  lineTotal,
-  markupFromCostAndPrice,
-  priceFromCostAndMarkup,
-} from '@/features/inventory/Lib/partPricing'
+import { useDeferredCommit } from '@/hooks/use-deferred-commit'
+import { lineTotal, repricePartRow } from '@/features/inventory/Lib/partPricing'
 import type { ServiceConcernInput } from '@/features/vehicles/Schema/serviceSchema'
 import type { ServicePartInput, ServiceLaborInput, InitialData } from './service-page-types'
 import type { ServiceDetail } from '../service-detail/types'
@@ -31,6 +28,7 @@ export function useServiceFormState({
   const [status, setStatus] = useState(initialData.status || 'completed')
   const [concerns, setConcerns] = useState<ServiceConcernInput[]>(initialData.concerns || [])
   const [partItems, setPartItems] = useState<ServicePartInput[]>(initialData.partItems || [])
+  const { schedule: scheduleCommit, cancel: cancelCommit } = useDeferredCommit()
   const [laborItems, setLaborItems] = useState<ServiceLaborInput[]>(initialData.laborItems || [])
   const [taxRate, setTaxRate] = useState(initialData.taxRate ?? defaultTaxRate)
   const [taxInclusive] = useState<boolean>(initialData.taxInclusive ?? false)
@@ -161,21 +159,26 @@ export function useServiceFormState({
   //
   // The three pricing fields (unitCost, markupPercent, unitPrice) are linked by:
   //   unitPrice = unitCost × (1 + markupPercent / 100)
-  // The user can edit any one and the others auto-sync so the row stays
-  // mathematically consistent — Markup % never lies about real margin.
+  // Cost, markup and price stay consistent with each other; repricePartRow
+  // owns which of them moves, and quote parts use the same rules.
   const updatePart = useCallback(
-    (index: number, field: keyof ServicePartInput, value: string | number) => {
+    (
+      index: number,
+      field: keyof ServicePartInput,
+      value: string | number,
+      options: { commit?: boolean } = {}
+    ) => {
+      // Any edit ends the wait on the previous one.
+      cancelCommit()
       setPartItems((prev) => {
         const updated = [...prev]
         const part = { ...updated[index], [field]: value }
 
-        if (field === 'unitCost' || field === 'markupPercent') {
-          // Cost or markup changed → recompute the customer-facing price.
-          part.unitPrice = priceFromCostAndMarkup(part.unitCost, part.markupPercent)
-        } else if (field === 'unitPrice') {
-          // Price was edited directly → derive markup back from cost so the
-          // displayed margin matches reality.
-          part.markupPercent = markupFromCostAndPrice(part.unitCost, part.unitPrice)
+        if (field === 'unitCost' || field === 'markupPercent' || field === 'unitPrice') {
+          const priced = repricePartRow(part, field, options)
+          part.unitPrice = priced.unitPrice
+          part.markupPercent = priced.markupPercent
+          part.priceOverridden = priced.priceOverridden
         }
 
         if (
@@ -189,9 +192,24 @@ export function useServiceFormState({
         updated[index] = part
         return updated
       })
+
+      // A cost typed under a hand-set price restates the margin once typing
+      // stops, so it lands on its own without needing the field to be left.
+      if (field === 'unitCost' && !options.commit) {
+        scheduleCommit(() =>
+          setPartItems((prev) => {
+            const row = prev[index]
+            if (!row?.priceOverridden) return prev
+            const updated = [...prev]
+            updated[index] = { ...row, ...repricePartRow(row, 'unitCost', { commit: true }) }
+            return updated
+          })
+        )
+      }
+
       markDirty()
     },
-    [markDirty]
+    [markDirty, cancelCommit, scheduleCommit]
   )
 
   const updateLabor = useCallback(

+ 17 - 2
src/features/vehicles/Schema/serviceSchema.ts

@@ -9,7 +9,13 @@ export const servicePartSchema = z.object({
   unitPrice: z.coerce.number().min(0).default(0),
   total: z.coerce.number().min(0).default(0),
   unitCost: z.coerce.number().min(0).default(0),
-  markupPercent: z.coerce.number().min(0).default(0),
+  /**
+   * Selling below cost is a real decision (a goodwill line, matching a price),
+   * and the markup has to be able to say so. Floored at -100, which is giving
+   * the part away: anything lower would imply a negative price, which
+   * unitPrice already refuses.
+   */
+  markupPercent: z.coerce.number().min(-100).default(0),
   inventoryPartId: z.string().optional(),
 })
 
@@ -88,7 +94,16 @@ export const updateServiceSchema = createServiceSchema.partial().extend({
 
 export type ServiceAttachmentInput = z.infer<typeof serviceAttachmentSchema>
 export type ServiceConcernInput = z.infer<typeof serviceConcernSchema>
-export type ServicePartInput = z.infer<typeof servicePartSchema>
+/**
+ * The editor also tracks whether the price was typed over the cost-and-markup
+ * formula, so a later edit to the cost restates the margin instead of
+ * overwriting what was entered. It describes how the row was edited rather
+ * than anything about the part, so it is client-only: the schema above has no
+ * such field and strips it on save.
+ */
+export type ServicePartInput = z.infer<typeof servicePartSchema> & {
+  priceOverridden?: boolean
+}
 export type ServiceLaborInput = z.infer<typeof serviceLaborSchema>
 export type CreateServiceInput = z.infer<typeof createServiceSchema>
 export type UpdateServiceInput = z.infer<typeof updateServiceSchema>

+ 57 - 0
src/hooks/use-deferred-commit.ts

@@ -0,0 +1,57 @@
+'use client'
+
+import { useCallback, useEffect, useRef } from 'react'
+
+/** Delay before a pause in typing counts as having finished the value. */
+export const DEFAULT_COMMIT_DELAY_MS = 400
+
+/**
+ * Runs work once a burst of typing stops.
+ *
+ * For fields derived from another field that updates on every keystroke. The
+ * parts editor derives a margin from cost and price: entering a cost of 1500
+ * against a price of 1000 passes through 1, and a margin honestly reported
+ * from a cost of 1 reads 99900%, so the field flashes 99900, 6566.7 and 566.7
+ * before settling. Each is arithmetically right for the digits on screen at
+ * the time, and all three look broken.
+ *
+ * Waiting for a pause rather than for the field to be left keeps the update
+ * automatic; `flush` covers leaving early, when there is nothing left to wait
+ * for.
+ */
+export function useDeferredCommit(delayMs: number = DEFAULT_COMMIT_DELAY_MS) {
+  const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
+  const pending = useRef<(() => void) | null>(null)
+
+  const cancel = useCallback(() => {
+    if (timer.current) clearTimeout(timer.current)
+    timer.current = null
+    pending.current = null
+  }, [])
+
+  const schedule = useCallback(
+    (work: () => void) => {
+      if (timer.current) clearTimeout(timer.current)
+      pending.current = work
+      timer.current = setTimeout(() => {
+        timer.current = null
+        const queued = pending.current
+        pending.current = null
+        queued?.()
+      }, delayMs)
+    },
+    [delayMs]
+  )
+
+  /** Run the pending work now, if any. */
+  const flush = useCallback(() => {
+    const queued = pending.current
+    cancel()
+    queued?.()
+  }, [cancel])
+
+  // A row unmounted mid-edit must not fire a state update afterwards.
+  useEffect(() => cancel, [cancel])
+
+  return { schedule, cancel, flush }
+}