import { useMemo, useState } from 'react'; import { CheckCircle2 } from 'lucide-react'; import { Button } from '../core/Button'; import { Input } from '../core/Input'; import { Select } from '../core/Select'; import { MultiSelect } from '../core/MultiSelect'; import { LookupField } from './LookupField'; import type { LookupValue } from './LookupField'; import { useZino } from '../../api/provider'; import type { ActivityDef, FieldDef } from '../../api/forms'; import type { LeadRecord } from '../../api/types'; import type { ActivityResult } from '../../api/types'; export interface ActivityFormProps { def: ActivityDef; /** Omit for the INIT activity (Capture Lead) — submits via /start. */ instanceId?: number | string; /** Seed values from an existing record. */ record?: LeadRecord | null; onSuccess?: (res: ActivityResult) => void; /** Override the green confirmation line. */ successMessage?: string; } // datetime-local ("YYYY-MM-DDTHH:MM") → RFC 3339 with local TZ offset, the // format the workflow engine validates datetime fields against. function toRfc3339(local: string): string { const d = new Date(local); if (Number.isNaN(d.getTime())) return local; const pad = (n: number) => String(n).padStart(2, '0'); const off = -d.getTimezoneOffset(); const sign = off >= 0 ? '+' : '-'; const abs = Math.abs(off); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`; } function seedFor(field: FieldDef, record?: LeadRecord | null): unknown { const key = field.seedKey ?? field.id.replace(/_input$/, ''); const fromRecord = record ? (record as Record)[key] : undefined; if (fromRecord !== undefined && fromRecord !== null) return fromRecord; switch (field.type) { case 'multiselect': return []; case 'boolean': return false; default: return ''; } } /** Renders an activity's fields from its declarative spec and submits via * performActivity (or startInstance when no instanceId is given). */ export function ActivityForm({ def, instanceId, record, onSuccess, successMessage }: ActivityFormProps) { const { client } = useZino(); const [values, setValues] = useState>(() => { const init: Record = {}; def.fields.forEach((f) => (init[f.id] = seedFor(f, record))); return init; }); const [submitting, setSubmitting] = useState(false); const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null); const set = (id: string, v: unknown) => setValues((p) => ({ ...p, [id]: v })); const missing = useMemo( () => def.fields.filter((f) => { if (!f.required) return false; const v = values[f.id]; if (Array.isArray(v)) return v.length === 0; return v === '' || v === null || v === undefined; }), [def.fields, values], ); async function submit() { if (missing.length) { setResult({ ok: false, msg: `Fill required: ${missing.map((m) => m.label).join(', ')}` }); return; } setSubmitting(true); setResult(null); // Drop empty optional values so we don't overwrite with blanks. const data: Record = {}; for (const f of def.fields) { const v = values[f.id]; if (v === '' || v === null || v === undefined) continue; if (Array.isArray(v) && v.length === 0) continue; data[f.id] = f.type === 'number' ? Number(v) || 0 : f.type === 'datetime' ? toRfc3339(String(v)) : v; } try { const res = instanceId != null ? await client.performActivity(instanceId, def.uid, data) : await client.startInstance(def.uid, data); setResult({ ok: true, msg: successMessage ?? `${def.name} ${def.done}.` }); onSuccess?.(res); } catch (e) { setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') }); } finally { setSubmitting(false); } } return (
{def.fields.map((f) => ( set(f.id, v)} activityId={def.uid} instanceId={instanceId} /> ))}
{result && (
{result.ok && } {result.msg}
)}
); } function Field({ field, value, onChange, activityId, instanceId, }: { field: FieldDef; value: unknown; onChange: (v: unknown) => void; activityId: string; instanceId?: number | string; }) { const full = field.type === 'longtext' || field.type === 'multiselect' || field.type === 'lookup'; const wrap = full ? 'col-span-full' : ''; switch (field.type) { case 'longtext': return (