260 lines
8.3 KiB
TypeScript
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)}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
}
|