lead-to-policy/src/components/form/ActivityForm.tsx
Bhanu Prakash Sai Potteri 714f8ab8ff initial commit
2026-06-22 10:36:16 +05:30

260 lines
8.3 KiB
TypeScript

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<string, unknown>)[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<Record<string, unknown>>(() => {
const init: Record<string, unknown> = {};
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<string, unknown> = {};
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 (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4">
{def.fields.map((f) => (
<Field
key={f.id}
field={f}
value={values[f.id]}
onChange={(v) => set(f.id, v)}
activityId={def.uid}
instanceId={instanceId}
/>
))}
</div>
{result && (
<div
className={
result.ok
? 'flex items-center gap-2 text-sm text-emerald-700 bg-emerald-100 border border-emerald-300 rounded-md px-4 py-3'
: 'text-sm text-amber-700 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3'
}
>
{result.ok && <CheckCircle2 size={16} />}
{result.msg}
</div>
)}
<div>
<Button variant="primary" disabled={submitting || result?.ok} onClick={submit}>
{submitting ? 'Submitting…' : result?.ok ? 'Done' : def.submitLabel}
</Button>
</div>
</div>
);
}
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 (
<label className={`${wrap} flex flex-col gap-1.5`}>
<span className="text-sm font-medium text-muted">
{field.label}
{field.required && <span className="text-ruby-600"> *</span>}
</span>
<textarea
rows={3}
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="font-sans text-base text-strong border border-border-default rounded-md px-3 py-2.5 resize-y outline-none focus:border-sunrise-500"
/>
</label>
);
case 'select':
return (
<Select
label={field.label}
required={field.required}
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
options={[{ value: '', label: field.required ? 'Select…' : '— None —' }, ...(field.options ?? [])]}
/>
);
case 'multiselect':
return (
<div className={wrap}>
<MultiSelect
label={field.label}
value={Array.isArray(value) ? (value as string[]) : []}
onChange={onChange}
options={field.options ?? []}
/>
</div>
);
case 'boolean':
return (
<label className="flex items-center gap-2.5 h-[42px] mt-6 cursor-pointer select-none">
<input
type="checkbox"
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
className="w-4 h-4 accent-sunrise-500"
/>
<span className="text-sm font-medium text-body">{field.label}</span>
</label>
);
case 'lookup':
return (
<div className={wrap}>
<LookupField
label={field.label}
required={field.required}
templateUid={field.lookupTemplate!}
displayCols={field.lookupDisplay ?? []}
storageCols={field.lookupStorage ?? []}
activityId={activityId}
fieldId={field.id}
instanceId={instanceId}
value={(value as LookupValue) ?? null}
onChange={onChange}
/>
</div>
);
default: {
const htmlType =
field.type === 'number'
? 'number'
: field.type === 'date'
? 'date'
: field.type === 'datetime'
? 'datetime-local'
: field.type === 'email'
? 'email'
: field.type === 'phone'
? 'tel'
: 'text';
return (
<Input
label={field.label}
required={field.required}
type={htmlType}
prefix={field.prefix}
placeholder={field.placeholder}
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
/>
);
}
}
}