krishna_sales/src/components/forms/DynamicForm.tsx

390 lines
13 KiB
TypeScript

import { useState, useEffect } from 'react';
import type { ZinoClient } from '../../api/client';
import type { FormScreenResponse } from '../../api/types';
import { Button } from '../buttons/Button';
import { Spinner } from '../reusable/Spinner';
import {
FileInput,
SmartGridField,
GeolocationInput,
PhoneInput,
SelectField,
TextField,
EmailField,
TextAreaField,
DateField,
TimeField,
WfLookupField,
RadioField,
} from './fields';
import { ORDER_BOOKING } from '../../api/config';
export interface DynamicFormProps {
client: ZinoClient;
activityId: string;
instanceId?: number | string;
onSuccess?: () => void;
onCancel?: () => void;
ignorePrefill?: boolean;
}
export function DynamicForm({ client, activityId: initialActivityId, instanceId: initialInstanceId, onSuccess, onCancel, ignorePrefill }: DynamicFormProps) {
const [currentActivityId, setCurrentActivityId] = useState(initialActivityId);
const [currentInstanceId, setCurrentInstanceId] = useState<number | string | undefined>(initialInstanceId);
const [chainQueue, setChainQueue] = useState<Array<{ activity_uid: string; activity_name: string }>>([]);
useEffect(() => {
setCurrentActivityId(initialActivityId);
setCurrentInstanceId(initialInstanceId);
setChainQueue([]);
}, [initialActivityId, initialInstanceId]);
const [schema, setSchema] = useState<FormScreenResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
setLoading(true);
client.formSchema(currentActivityId, currentInstanceId)
.then(res => {
if (mounted) {
setSchema(res);
const defaultValues: Record<string, unknown> = {};
if (res.field_defaults) {
Object.entries(res.field_defaults).forEach(([fieldId, def]) => {
if (def.value != null) {
defaultValues[fieldId] = def.value;
} else if (def.prefill) {
if (def.prefill.value === 'current_date') {
defaultValues[fieldId] = new Date().toISOString().split('T')[0];
} else if (def.prefill.value === 'current_time') {
defaultValues[fieldId] = new Date().toTimeString().split(' ')[0].substring(0, 5);
} else if (def.prefill.value === 'current_user_id') {
const user = client.currentUser();
defaultValues[fieldId] = user ? Number(user.id) : '';
} else {
defaultValues[fieldId] = def.prefill.value;
}
}
});
}
const imageFieldIds = new Set(
res.fields.filter(f => f.data_type === 'image' || f.data_type === 'file').map(f => f.id)
);
if (!ignorePrefill) {
if (res.prefill_data) {
Object.entries(res.prefill_data).forEach(([k, v]) => {
if (!imageFieldIds.has(k)) defaultValues[k] = v;
});
} else if (res.data) {
Object.entries(res.data).forEach(([k, v]) => {
if (!imageFieldIds.has(k)) defaultValues[k] = v;
});
}
}
setValues(defaultValues);
setLoading(false);
}
})
.catch((err: any) => {
if (mounted) {
setError(err?.message || 'Failed to load schema');
setLoading(false);
}
});
return () => { mounted = false; };
}, [client, currentActivityId, currentInstanceId]);
const [values, setValues] = useState<Record<string, unknown>>({});
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const handleFieldChange = (fieldId: string, newVal: unknown) => {
setValues(prev => {
const next = { ...prev, [fieldId]: newVal };
// Auto-calculate order_details totals
if (fieldId === 'order_details' && Array.isArray(newVal)) {
let totalBags = 0;
let totalKgs = 0;
newVal.forEach(row => {
totalBags += Number(row.bags) || 0;
totalKgs += Number(row.row_kgs) || 0;
});
next['total_bags'] = totalBags;
next['total_kgs'] = totalKgs;
}
return next;
});
};
if (loading) {
return <div className="p-8 flex justify-center"><Spinner label="Loading form..." /></div>;
}
if (error || !schema) {
return <div className="p-4 text-ruby-600">Failed to load form: {error}</div>;
}
// Filter out disabled fields (usually server-generated IDs)
const fields = schema.fields.filter(f => !f.properties?.disabled);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setSubmitError(null);
try {
const payload: Record<string, unknown> = {};
for (const f of fields) {
const val = values[f.id];
if (val == null) continue;
if (f.data_type === 'phone' && typeof val === 'string') {
const phoneNum = val.replace(/^\+91\s*/, '').trim();
payload[f.id] = {
dial_code: '+91',
phone: phoneNum,
phone_with_dial_code: `+91${phoneNum}`
};
} else if ((f.data_type === 'image' || f.data_type === 'file') && Array.isArray(val) && val.length > 0 && val[0] instanceof File) {
const uploadedFiles = [];
for (const file of val) {
const fileMeta = await client.uploadFile(file, { activityId: currentActivityId, fieldId: f.id });
uploadedFiles.push(fileMeta);
}
payload[f.id] = uploadedFiles;
} else if ((f.data_type === 'image' || f.data_type === 'file') && val instanceof File) {
const fileMeta = await client.uploadFile(val, { activityId: currentActivityId, fieldId: f.id });
payload[f.id] = [fileMeta];
} else {
payload[f.id] = val;
}
}
let res;
if (currentInstanceId != null) {
res = await client.performActivity(currentInstanceId, currentActivityId, payload);
} else {
res = await client.startInstance(currentActivityId, payload);
}
let pending = [...chainQueue];
let chainSource = (res as any).activity_chain || schema.activity_chain || [];
// Centralized activity chaining logic
if (currentActivityId === ORDER_BOOKING.activities.PRODUCTIVITY_OF_VISIT.uid) {
const actionValue = String(payload[ORDER_BOOKING.activities.PRODUCTIVITY_OF_VISIT.fields.action] || '').toLowerCase().trim();
const normalized = actionValue.replace(/[\s_]+/g, '');
if (normalized === 'order') {
chainSource = [{ activity_uid: ORDER_BOOKING.activities.PLACE_ORDER.uid, activity_name: 'Place Order' }];
} else if (normalized === 'noorder') {
chainSource = [{ activity_uid: ORDER_BOOKING.activities.POTENTIAL_MINING.uid, activity_name: 'Potential Mining' }];
}
}
const newActivities = chainSource.filter(
(a: any) => a.activity_uid !== currentActivityId
);
// Remove any existing occurrences from pending to avoid duplicates
pending = pending.filter(p => !newActivities.some(n => n.activity_uid === p.activity_uid));
// Prepend the new activities for depth-first execution (nested chaining)
pending = [...newActivities, ...pending];
const nextActivity = pending.shift();
if (nextActivity) {
setChainQueue(pending);
setCurrentActivityId(nextActivity.activity_uid);
setCurrentInstanceId(res.instance_id ?? currentInstanceId);
} else {
onSuccess?.();
}
} catch (err: any) {
setSubmitError(err.message || 'Failed to submit form');
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{fields.map(f => {
const type = f.data_type;
const val = values[f.id];
const isDisabled = schema.field_defaults?.[f.id]?.disabled;
const renderField = () => {
if (type === 'wf_lookup') {
return (
<WfLookupField
label={f.name}
required={f.mandatory}
value={(val as string | number) ?? ''}
client={client}
config={f.properties?.wf_lookup_config || f.properties?.lookup_config}
properties={f.properties}
activityId={currentActivityId}
fieldId={f.id}
formData={values}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('select') || type.startsWith('multiselect')) {
return (
<SelectField
label={f.name}
required={f.mandatory}
value={(val as string) ?? ''}
options={f.properties?.options || []}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type === 'radio') {
return (
<RadioField
label={f.name}
required={f.mandatory}
value={(val as string) ?? ''}
options={f.properties?.options || []}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('image') || type.startsWith('file')) {
return (
<FileInput
label={f.name}
type={type}
required={f.mandatory}
value={val}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('grid')) {
return (
<SmartGridField
label={f.name}
columns={f.columns || []}
value={(val as Record<string, unknown>[]) || []}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('geolocation')) {
return (
<GeolocationInput
label={f.name}
required={f.mandatory}
value={(val as { latitude: number; longitude: number }) || null}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('phone')) {
const phoneStr = typeof val === 'object' && val !== null ? (val as any).phone || '' : (val as string) ?? '';
return (
<PhoneInput
label={f.name}
required={f.mandatory}
value={phoneStr}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('email')) {
return (
<EmailField
label={f.name}
required={f.mandatory}
value={(val as string) ?? ''}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('textarea') || type.startsWith('text_area') || type.startsWith('longtext')) {
return (
<TextAreaField
label={f.name}
required={f.mandatory}
value={(val as string) ?? ''}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('date')) {
return (
<DateField
label={f.name}
required={f.mandatory}
value={(val as string) ?? ''}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
if (type.startsWith('time')) {
return (
<TimeField
label={f.name}
required={f.mandatory}
value={(val as string) ?? ''}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
}
return (
<TextField
label={f.name}
required={f.mandatory}
type={type}
value={(val as string) ?? ''}
onChange={(newVal) => handleFieldChange(f.id, newVal)}
/>
);
};
const content = renderField();
return isDisabled ? (
<fieldset key={f.id} disabled className="opacity-60 pointer-events-none">
{content}
</fieldset>
) : (
<div key={f.id}>{content}</div>
);
})}
{submitError && <div className="text-sm text-ruby-600 mt-2">{submitError}</div>}
<div className="flex items-center justify-end gap-3 mt-4 pt-4 border-t border-border-subtle">
{onCancel && (
<Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}>
Cancel
</Button>
)}
<Button type="submit" variant="primary" disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit'}
</Button>
</div>
</form>
);
}