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())}`;
+}