diff --git a/src/components/ActivityForm.tsx b/src/components/ActivityForm.tsx index a3b838e..58e1969 100644 --- a/src/components/ActivityForm.tsx +++ b/src/components/ActivityForm.tsx @@ -266,13 +266,21 @@ function FieldControl({ onChange={onChange} /> ); + // A `datetime-local` input yields "2026-08-17T18:31" — no seconds, no + // timezone. The server validates this field as a timestamp and rejects + // that shape outright: + // validation failed for activity hdfc-act-disburse: disbursed_at(type) + // So the browser value is converted to RFC3339 UTC on the way out, and + // back to local wall-clock on the way in. Storing the ISO string and + // slicing it for display would show the UTC time in the picker — 13:01 + // for an 18:31 selection — which looks like the control losing input. case 'datetime': return ( onChange(localInputToIso(v))} /> ); case 'longtext': @@ -412,3 +420,28 @@ function OcrUpload({ ); } + +/** + * `datetime-local` <-> RFC3339, the two conversions the datetime control needs. + * + * The input speaks local wall-clock with minute precision and no zone; the + * workflow's timestamp validator wants a full RFC3339 instant. Neither will + * accept the other's format, and getting it wrong fails as an opaque + * "(type)" validation error with no hint about which half is at fault. + */ +function localInputToIso(v: string): string { + if (!v) return ''; + const d = new Date(v); // parsed as LOCAL time, which is what the picker meant + return Number.isNaN(d.getTime()) ? v : d.toISOString(); +} + +function isoToLocalInput(v: unknown): string { + const s = String(v ?? ''); + if (!s) return ''; + const d = new Date(s); + // Not a parseable instant — hand it back untouched rather than blanking the + // field, so a value the server sent stays visible even if we misread it. + if (Number.isNaN(d.getTime())) return s.slice(0, 16); + const p = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; +}