form grid implemented
This commit is contained in:
parent
f7efd4d9cc
commit
77c596e3a1
@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { ZinoClient } from '../../api/client';
|
||||
import type { FormScreenResponse } from '../../api/types';
|
||||
import type { FormScreenResponse, FormScreenField } from '../../api/types';
|
||||
import { Button } from '../buttons/Button';
|
||||
import { Spinner } from '../reusable/Spinner';
|
||||
import {
|
||||
@ -121,10 +121,14 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
|
||||
const clickedActionRef = useRef<string | null>(null);
|
||||
|
||||
const handleFieldChange = (fieldId: string, newVal: unknown, fullRow?: any) => {
|
||||
if (submitError) {
|
||||
setSubmitError(null);
|
||||
}
|
||||
setValues(prev => {
|
||||
const next = { ...prev, [fieldId]: newVal };
|
||||
if (fullRow) {
|
||||
@ -151,8 +155,11 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
)?.id || 'row_kgs';
|
||||
|
||||
newVal.forEach(row => {
|
||||
totalBags += Number(row[bagsColId]) || 0;
|
||||
totalKgs += Number(row[rowKgsColId]) || 0;
|
||||
const bags = Number(row[bagsColId] ?? row.bags ?? row.quantity) || 0;
|
||||
const sku = Number(row.sku) || 0;
|
||||
const rowKgs = Number(row[rowKgsColId] ?? row.row_kgs) || (bags * sku);
|
||||
totalBags += bags;
|
||||
totalKgs += rowKgs;
|
||||
});
|
||||
|
||||
const tbField = schema?.fields.find(f => f.id === 'total_bags' || getBaseId(f.id) === 'total_bags' || f.mapped_workflow_field === 'total_bags');
|
||||
@ -184,14 +191,191 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setShowErrors(true);
|
||||
setSubmitError(null);
|
||||
|
||||
const finalValues = { ...values };
|
||||
if (actionField && clickedActionRef.current) {
|
||||
finalValues[actionField.id] = clickedActionRef.current;
|
||||
}
|
||||
|
||||
// 1. Pre-submit consolidation for grid fields & total fields
|
||||
normalFields.forEach((f) => {
|
||||
if (f.data_type.startsWith('grid')) {
|
||||
const rawGrid = (finalValues[f.id] as Record<string, unknown>[]) || [];
|
||||
if (rawGrid.length > 0) {
|
||||
const merged: Record<string, unknown>[] = [];
|
||||
const indexMap = new Map<string, number>();
|
||||
|
||||
for (const r of rawGrid) {
|
||||
const cat = String(r.category || r.product_category || '').trim().toLowerCase();
|
||||
const prod = String(r.productName || r.product_name || r.product || '').trim().toLowerCase();
|
||||
const currentBags = Number(r.bags || r.quantity || 0);
|
||||
|
||||
if (cat && prod) {
|
||||
const key = `${cat}::${prod}`;
|
||||
if (indexMap.has(key)) {
|
||||
const targetIdx = indexMap.get(key)!;
|
||||
const existingRow = merged[targetIdx];
|
||||
const existingBags = Number(existingRow.bags || existingRow.quantity || 0);
|
||||
const addedBags = currentBags > 0 ? currentBags : 1;
|
||||
const newBags = existingBags + addedBags;
|
||||
const skuVal = Number(existingRow.sku || r.sku || 0);
|
||||
|
||||
merged[targetIdx] = {
|
||||
...existingRow,
|
||||
bags: newBags,
|
||||
quantity: newBags,
|
||||
row_kgs: skuVal * newBags,
|
||||
};
|
||||
continue;
|
||||
} else {
|
||||
indexMap.set(key, merged.length);
|
||||
}
|
||||
}
|
||||
merged.push(r);
|
||||
}
|
||||
|
||||
// Clean virtual keys
|
||||
const cleanGrid = merged.map((r) => {
|
||||
const clean: Record<string, unknown> = {};
|
||||
Object.keys(r).forEach((k) => {
|
||||
if (k !== '_row_total_kgs' && k !== 'row_kgs' && k !== 'row_total') {
|
||||
clean[k] = r[k];
|
||||
}
|
||||
});
|
||||
return clean;
|
||||
});
|
||||
|
||||
finalValues[f.id] = cleanGrid;
|
||||
|
||||
// Recalculate total_bags and total_kgs
|
||||
let totalB = 0;
|
||||
let totalK = 0;
|
||||
cleanGrid.forEach((r) => {
|
||||
const b = Number(r.bags || r.quantity) || 0;
|
||||
const sku = Number(r.sku) || 0;
|
||||
totalB += b;
|
||||
totalK += b * sku;
|
||||
});
|
||||
|
||||
const getBaseId = (id: string) => id.replace(/_\d+$/, '');
|
||||
normalFields.forEach((tf) => {
|
||||
const baseId = getBaseId(tf.id);
|
||||
const mapped = (tf.mapped_workflow_field || '').toLowerCase();
|
||||
if (baseId === 'total_bags' || mapped === 'total_bags' || tf.id === 'total_bags') {
|
||||
finalValues[tf.id] = Math.round(totalB);
|
||||
}
|
||||
if (baseId === 'total_kgs' || mapped === 'total_kgs' || tf.id === 'total_kgs') {
|
||||
finalValues[tf.id] = Math.round(totalK);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Validate grid fields (Order Details)
|
||||
const extractGridRowDetails = (r: Record<string, unknown>, columns?: FormScreenField[]) => {
|
||||
let cat = String(r.category || r.product_category || r.cat || '').trim();
|
||||
let prod = String(r.productName || r.product_name || r.product || r.name || '').trim();
|
||||
|
||||
let bagsNum = 0;
|
||||
if (r.bags !== undefined && r.bags !== null && r.bags !== '') bagsNum = Number(r.bags);
|
||||
else if (r.quantity !== undefined && r.quantity !== null && r.quantity !== '') bagsNum = Number(r.quantity);
|
||||
else if (r.actual_potential !== undefined && r.actual_potential !== null && r.actual_potential !== '') bagsNum = Number(r.actual_potential);
|
||||
else if (r.store_potential !== undefined && r.store_potential !== null && r.store_potential !== '') bagsNum = Number(r.store_potential);
|
||||
|
||||
if (columns && columns.length > 0) {
|
||||
for (const c of columns) {
|
||||
const idL = c.id.toLowerCase();
|
||||
const nL = (c.name || '').toLowerCase();
|
||||
const mL = (c.mapped_workflow_field || '').toLowerCase();
|
||||
|
||||
if (!cat && (idL.includes('category') || nL.includes('category') || mL.includes('category'))) {
|
||||
cat = String(r[c.id] || '').trim();
|
||||
}
|
||||
if (!prod && (idL.includes('product') || nL.includes('product') || mL.includes('product') || idL.includes('name') || nL.includes('name'))) {
|
||||
prod = String(r[c.id] || '').trim();
|
||||
}
|
||||
if (bagsNum <= 0 && (idL.includes('bags') || nL.includes('bags') || mL.includes('bags') || idL.includes('quantity') || nL.includes('quantity') || idL.includes('potential') || nL.includes('potential'))) {
|
||||
const val = Number(r[c.id]);
|
||||
if (!isNaN(val) && val > 0) bagsNum = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bagsNum <= 0) {
|
||||
for (const k of Object.keys(r)) {
|
||||
const kL = k.toLowerCase();
|
||||
if ((kL.includes('bags') || kL.includes('quantity') || kL.includes('potential')) && !kL.includes('total') && !kL.includes('kgs')) {
|
||||
const val = Number(r[k]);
|
||||
if (!isNaN(val) && val > 0) {
|
||||
bagsNum = val;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { cat, prod, bags: isNaN(bagsNum) ? 0 : bagsNum };
|
||||
};
|
||||
|
||||
for (const f of normalFields) {
|
||||
if (f.data_type.startsWith('grid')) {
|
||||
const gridRows = (finalValues[f.id] as Record<string, unknown>[]) || [];
|
||||
|
||||
// Check if grid has at least one valid row
|
||||
const validRows = gridRows.filter((r) => {
|
||||
const { cat, prod, bags } = extractGridRowDetails(r, f.columns);
|
||||
return cat && prod && bags > 0;
|
||||
});
|
||||
|
||||
if (validRows.length === 0) {
|
||||
setSubmitError('Please add at least one complete product with Category, Product Name, and Bags.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any row is partially filled
|
||||
const hasIncompleteRow = gridRows.some((r) => {
|
||||
const { cat, prod, bags } = extractGridRowDetails(r, f.columns);
|
||||
|
||||
if (!cat && !prod && bags <= 0) return false;
|
||||
if (cat && (!prod || bags <= 0)) return true;
|
||||
if (prod && (!cat || bags <= 0)) return true;
|
||||
if (bags > 0 && (!cat || !prod)) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (hasIncompleteRow) {
|
||||
setSubmitError('Please complete all required product details (Product Name and Bags) before submitting.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Validate mandatory normal fields using finalValues
|
||||
const getBaseId = (id: string) => id.replace(/_\d+$/, '');
|
||||
for (const f of normalFields) {
|
||||
if (f.mandatory) {
|
||||
if (f.data_type.startsWith('grid')) continue;
|
||||
const baseId = getBaseId(f.id);
|
||||
const mapped = (f.mapped_workflow_field || '').toLowerCase();
|
||||
if (baseId === 'total_bags' || mapped === 'total_bags' || f.id === 'total_bags' ||
|
||||
baseId === 'total_kgs' || mapped === 'total_kgs' || f.id === 'total_kgs') {
|
||||
continue; // Total fields are calculated automatically from grid
|
||||
}
|
||||
|
||||
const v = finalValues[f.id];
|
||||
if (v === undefined || v === null || String(v).trim() === '') {
|
||||
setSubmitError(`Please fill in required field: ${f.name}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {};
|
||||
const finalValues = { ...values };
|
||||
if (actionField && clickedActionRef.current) {
|
||||
finalValues[actionField.id] = clickedActionRef.current;
|
||||
}
|
||||
|
||||
for (const f of fields) {
|
||||
const val = finalValues[f.id];
|
||||
@ -341,9 +525,31 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
}
|
||||
};
|
||||
|
||||
const getBaseIdForField = (id: string) => id.replace(/_\d+$/, '');
|
||||
const hasGridField = normalFields.some(f => f.data_type.startsWith('grid'));
|
||||
|
||||
const tbField = normalFields.find(f => f.id === 'total_bags' || getBaseIdForField(f.id) === 'total_bags' || f.mapped_workflow_field === 'total_bags');
|
||||
const tkField = normalFields.find(f => f.id === 'total_kgs' || getBaseIdForField(f.id) === 'total_kgs' || f.mapped_workflow_field === 'total_kgs');
|
||||
|
||||
const displayFields = normalFields.filter(f => {
|
||||
if (hasGridField) {
|
||||
const baseId = getBaseIdForField(f.id);
|
||||
const mapped = (f.mapped_workflow_field || '').toLowerCase();
|
||||
const isTotalField =
|
||||
baseId === 'total_bags' ||
|
||||
baseId === 'total_kgs' ||
|
||||
mapped === 'total_bags' ||
|
||||
mapped === 'total_kgs' ||
|
||||
f.id === 'total_bags' ||
|
||||
f.id === 'total_kgs';
|
||||
if (isTotalField) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{normalFields.map(f => {
|
||||
{displayFields.map(f => {
|
||||
const type = f.data_type;
|
||||
const val = values[f.id];
|
||||
const isDisabled = schema.field_defaults?.[f.id]?.disabled;
|
||||
@ -409,6 +615,9 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
columns={f.columns || []}
|
||||
value={(val as Record<string, unknown>[]) || []}
|
||||
onChange={(newVal) => handleFieldChange(f.id, newVal)}
|
||||
totalBagsValue={tbField ? Number(values[tbField.id]) || 0 : undefined}
|
||||
totalKgsValue={tkField ? Number(values[tkField.id]) || 0 : undefined}
|
||||
showErrors={showErrors}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -480,6 +689,9 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
);
|
||||
}
|
||||
|
||||
const isValEmpty = val === undefined || val === null || String(val).trim() === '';
|
||||
const currentFieldError = showErrors && f.mandatory && isValEmpty ? 'Required' : undefined;
|
||||
|
||||
return (
|
||||
<TextField
|
||||
label={f.name}
|
||||
@ -487,6 +699,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
|
||||
type={type}
|
||||
value={(val as string) ?? ''}
|
||||
onChange={(newVal) => handleFieldChange(f.id, newVal)}
|
||||
error={currentFieldError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,85 +1,5 @@
|
||||
import { Button } from '../../buttons/Button';
|
||||
import { Select } from '../../reusable/Select';
|
||||
import { Input } from '../../reusable/Input';
|
||||
import type { FormScreenField } from '../../../api/types';
|
||||
import { SmartGridField, type SmartGridFieldProps } from './SmartGridField';
|
||||
|
||||
export function GridInput({
|
||||
label,
|
||||
columns,
|
||||
value = [],
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
columns: FormScreenField[];
|
||||
value: Record<string, unknown>[];
|
||||
onChange: (val: Record<string, unknown>[]) => void;
|
||||
}) {
|
||||
const addRow = () => {
|
||||
onChange([...value, {}]);
|
||||
};
|
||||
|
||||
const removeRow = (idx: number) => {
|
||||
const next = [...value];
|
||||
next.splice(idx, 1);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const updateRow = (idx: number, fieldId: string, val: unknown) => {
|
||||
const next = [...value];
|
||||
next[idx] = { ...next[idx], [fieldId]: val };
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 font-sans border border-border-default rounded-md p-4 bg-slate-50">
|
||||
<span className="text-sm font-semibold text-strong mb-2">{label}</span>
|
||||
{value.length === 0 ? (
|
||||
<span className="text-sm text-faint italic">No rows added.</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{value.map((row, i) => (
|
||||
<div key={i} className="flex flex-col gap-3 p-3 bg-white border border-border-subtle rounded relative shadow-sm">
|
||||
<div className="absolute top-2 right-2">
|
||||
<button type="button" onClick={() => removeRow(i)} className="text-xs text-ruby-600 font-medium hover:underline">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-muted uppercase tracking-wider">Row {i + 1}</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{columns.map(col => {
|
||||
const val = row[col.id];
|
||||
if (col.data_type === 'select' || col.data_type === 'multiselect') {
|
||||
const opts = col.properties?.options || [];
|
||||
return (
|
||||
<Select
|
||||
key={col.id}
|
||||
label={col.name}
|
||||
required={col.mandatory}
|
||||
value={(val as string) ?? ''}
|
||||
onChange={(e) => updateRow(i, col.id, e.target.value)}
|
||||
options={[{ value: '', label: 'Select...' }, ...opts.map((o: any) => ({ value: String(o.value), label: o.label }))]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
key={col.id}
|
||||
label={col.name}
|
||||
required={col.mandatory}
|
||||
type={col.data_type === 'number' ? 'number' : col.data_type === 'email' ? 'email' : 'text'}
|
||||
value={(val as string) ?? ''}
|
||||
onChange={(e) => updateRow(i, col.id, col.data_type === 'number' ? Number(e.target.value) : e.target.value)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button type="button" variant="secondary" size="sm" onClick={addRow} className="mt-2 self-start">
|
||||
+ Add Row
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
export function GridInput(props: SmartGridFieldProps) {
|
||||
return <SmartGridField {...props} />;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,24 +2,30 @@ import { Input } from '../../reusable/Input';
|
||||
|
||||
export function TextField({
|
||||
label,
|
||||
type,
|
||||
type = 'text',
|
||||
required,
|
||||
disabled,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
}: {
|
||||
label: string;
|
||||
type: string;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
error?: string;
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
label={label}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
type={type === 'number' ? 'number' : type === 'email' ? 'email' : 'text'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user