3.0 KiB
F-080 — Architect: PVP (IVA incl.) input locks decimal editing
Root cause
PricingSection.tsx binds the PVP <input> to value={grossEur} where grossEur is recomputed on every render as centsToEur(Math.round(netCents * (1 + ratePercent / 100))). Every keystroke in PVP fires onChange which writes to net; the next render immediately rewrites the PVP value based on the (new) net. The input shows a value that never matches what the user typed — feels "locked".
Secondary issue: <input type="number"> ignores locale decimals. Browsers (Chrome/Safari) reject commas outright, and Firefox sometimes snaps to an integer. Sibling Neto input has the same type="number" but happens to feel usable because the value displayed in Neto is the actual state (net[v.id]), so the user controls the display string directly.
Design
Treat PVP as a first-class controlled input, exactly like Neto:
- Add a
grossstate alongsidenet:Record<string, string>holding the PVP value per variant (euros as a user-typed string). - On price load, populate
gross[v.id]from the persisted net + IVA rate. - The PVP
<input>valuereads fromgross[v.id], not from a derived expression. - PVP
onChange:- stores the raw string in
gross, - re-derives
netso the Neto cell stays in sync.
- stores the raw string in
- Neto
onChange:- stores the raw string in
net, - re-derives
grossso the PVP cell stays in sync.
- stores the raw string in
- IVA
onChange: re-derivesgrossfromnetusing the new rate. savePriceprefersgross(the customer-facing price) and converts it to net using the current IVA rate, so what the operator sees is what gets saved.- Switch both price inputs from
type="number"totype="text" inputMode="decimal"so comma/dot typing and digit deletion work natively across browsers; the existingeurToCentsalready handles both separators. - Margin and gross column still derive their final numbers from
grossstate for display.
No backend changes. No new fields. Same payload to setVariantPrice.
Risk
Low. UI behaviour change only. The round-trip gross → net → gross is now loss-tolerant because:
- gross is the canonical input,
- net is recomputed as
Math.round(gross / (1 + rate)), - gross displayed is the typed string (not re-derived mid-edit).
A rounding mismatch of ±1 cent between saved and displayed is acceptable because we already round to cents on save.
Acceptance mapping
- "PVP input lets the operator type any digit and decimal separator" → free-text input + state-driven value.
- "Operator can delete individual digits and the decimal separator" → value is the raw state, never overridden mid-edit.
- "PVP value is parsed and stored as a real number with 2-decimal rounding" →
eurToCents+Math.roundchain. - "On blur/save, PVP stays consistent with neto + IVA rate" → save uses
gross / (1 + rate). - "Behavior matches Neto" → identical input component, parsing rules, controlled-state pattern.
- "verify.sh is green" → no schema/API changes; typecheck and unit tests unaffected.