372 lines
17 KiB
TypeScript
372 lines
17 KiB
TypeScript
import { useState, useRef } from 'react';
|
|
import { Button } from '../../buttons/Button';
|
|
import { Select } from '../../reusable/Select';
|
|
import { Input } from '../../reusable/Input';
|
|
import { Trash2, Pencil } from 'lucide-react';
|
|
import type { FormScreenField } from '../../../api/types';
|
|
|
|
export function SmartGridField({
|
|
label,
|
|
columns,
|
|
value = [],
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
columns: FormScreenField[];
|
|
value: Record<string, unknown>[];
|
|
onChange: (val: Record<string, unknown>[]) => void;
|
|
}) {
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const formRef = useRef<HTMLDivElement>(null);
|
|
const [newRow, setNewRow] = useState<Record<string, unknown>>({});
|
|
const [editingIdx, setEditingIdx] = useState<number | null>(null);
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
|
|
|
|
const visibleColumns = columns.filter(c => {
|
|
const isOrderDetails = label.toLowerCase().includes('order');
|
|
if (isOrderDetails) {
|
|
const n = (c.name || '').toLowerCase();
|
|
const id = (c.id || '').toLowerCase();
|
|
// Show only Category, Name, and Bags
|
|
return n.includes('category') || n.includes('name') || n.includes('bags') ||
|
|
id.includes('category') || id.includes('name') || id.includes('bags');
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const removeRow = (idx: number) => {
|
|
const next = [...value];
|
|
next.splice(idx, 1);
|
|
onChange(next);
|
|
};
|
|
|
|
const startEdit = (idx: number) => {
|
|
setEditingIdx(idx);
|
|
setNewRow({ ...value[idx] });
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const startAdd = () => {
|
|
setEditingIdx(null);
|
|
setNewRow({});
|
|
setIsModalOpen(true);
|
|
setTimeout(() => {
|
|
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}, 150);
|
|
};
|
|
|
|
const updateNewRowField = (fieldId: string, val: unknown) => {
|
|
let row = { ...newRow, [fieldId]: val };
|
|
setErrors(prev => ({ ...prev, [fieldId]: '' }));
|
|
|
|
|
|
const colDef = columns.find(c => c.id === fieldId);
|
|
|
|
// If product category changes, clear out the rest of the row's data
|
|
if (colDef && (colDef.id === 'product_category' || colDef.name === 'Product Category' || colDef.mapped_workflow_field === 'product_category')) {
|
|
Object.keys(row).forEach(k => {
|
|
if (k !== fieldId) {
|
|
row[k] = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Auto-fill dataset keys from _raw
|
|
if (colDef && (colDef.data_type === 'select' || colDef.data_type === 'multiselect')) {
|
|
const option = colDef.properties?.options?.find(o => String(o.value) === String(val));
|
|
if (option && option._raw) {
|
|
const getBaseId = (id: string) => id.replace(/_\d+$/, '');
|
|
for (const key of Object.keys(option._raw)) {
|
|
const targetCol = columns.find(c => c.id === key || c.mapped_workflow_field === key || getBaseId(c.id) === key || c.name.toLowerCase().replace(/\s+/g, '') === key.toLowerCase().replace(/_/g, ''));
|
|
if (targetCol && targetCol.id !== fieldId) {
|
|
row[targetCol.id] = option._raw[key];
|
|
} else if (!targetCol && key !== fieldId) {
|
|
row[key] = option._raw[key];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-calculate row_kgs if sku and bags are present
|
|
const getBaseId = (id: string) => id.replace(/_\d+$/, '');
|
|
const getVal = (key: string) => {
|
|
const c = columns.find(c => c.id === key || c.mapped_workflow_field === key || getBaseId(c.id) === key || c.name.toLowerCase().replace(/\s+/g, '') === key.toLowerCase().replace(/_/g, ''));
|
|
return c ? row[c.id] : row[key];
|
|
};
|
|
|
|
const skuVal = Number(getVal('sku')) || 0;
|
|
const bagsVal = Number(getVal('bags')) || 0;
|
|
|
|
const rowKgsCol = columns.find(c => c.id === 'row_kgs' || c.mapped_workflow_field === 'row_kgs' || getBaseId(c.id) === 'row_kgs' || getBaseId(c.id) === 'rowkgs');
|
|
if (rowKgsCol) {
|
|
row[rowKgsCol.id] = skuVal * bagsVal;
|
|
} else {
|
|
row['row_kgs'] = skuVal * bagsVal;
|
|
}
|
|
|
|
setNewRow(row);
|
|
};
|
|
|
|
const submitNewRow = () => {
|
|
const newErrors: Record<string, string> = {};
|
|
const getBaseId = (id: string) => id.replace(/_\d+$/, '');
|
|
const catColId = columns.find(c => c.id === 'product_category' || c.name === 'Product Category' || c.mapped_workflow_field === 'product_category' || getBaseId(c.id) === 'product_category')?.id;
|
|
const isCatSelected = catColId ? !!newRow[catColId] : false;
|
|
|
|
for (const col of visibleColumns) {
|
|
const isProductName = col.id === 'product_name' || col.name === 'Product Name' || col.mapped_workflow_field === 'product_name' || getBaseId(col.id) === 'product_name';
|
|
const isBags = col.id === 'bags' || col.name === 'Bags' || col.mapped_workflow_field === 'bags' || getBaseId(col.id) === 'bags' || col.id.toLowerCase().includes('quantity') || col.name.toLowerCase().includes('quantity') || col.mapped_workflow_field?.toLowerCase().includes('quantity');
|
|
const isRequired = col.mandatory || (isCatSelected && (isProductName || isBags));
|
|
|
|
if (isRequired && !newRow[col.id]) {
|
|
newErrors[col.id] = 'This field is required';
|
|
}
|
|
}
|
|
|
|
if (Object.keys(newErrors).length > 0) {
|
|
setErrors(newErrors);
|
|
return;
|
|
}
|
|
|
|
if (editingIdx !== null) {
|
|
const next = [...value];
|
|
next[editingIdx] = newRow;
|
|
onChange(next);
|
|
} else {
|
|
const getBaseId = (id: string) => id.replace(/_\d+$/, '');
|
|
const prodNameCol = columns.find(c => c.id === 'product_name' || getBaseId(c.id) === 'product_name' || c.mapped_workflow_field === 'product_name');
|
|
|
|
let foundIdx = -1;
|
|
if (prodNameCol && newRow[prodNameCol.id]) {
|
|
foundIdx = value.findIndex(r => r[prodNameCol.id] === newRow[prodNameCol.id]);
|
|
}
|
|
|
|
if (foundIdx !== -1) {
|
|
const next = [...value];
|
|
const existingRow = { ...next[foundIdx] };
|
|
|
|
const bagsCol = columns.find(c => c.id === 'bags' || getBaseId(c.id) === 'bags' || c.mapped_workflow_field === 'bags' || c.name.toLowerCase() === 'bags');
|
|
const rowKgsCol = columns.find(c => c.id === 'row_kgs' || getBaseId(c.id) === 'row_kgs' || c.mapped_workflow_field === 'row_kgs' || getBaseId(c.id) === 'rowkgs');
|
|
const skuCol = columns.find(c => c.id === 'sku' || getBaseId(c.id) === 'sku' || c.mapped_workflow_field === 'sku');
|
|
|
|
const bagsId = bagsCol ? bagsCol.id : 'bags';
|
|
const rowKgsId = rowKgsCol ? rowKgsCol.id : 'row_kgs';
|
|
const skuId = skuCol ? skuCol.id : 'sku';
|
|
|
|
const existingBags = Number(existingRow[bagsId]) || 0;
|
|
const addedBags = Number(newRow[bagsId]) || 0;
|
|
existingRow[bagsId] = existingBags + addedBags;
|
|
|
|
const skuVal = Number(existingRow[skuId]) || 0;
|
|
existingRow[rowKgsId] = (existingBags + addedBags) * skuVal;
|
|
|
|
next[foundIdx] = existingRow;
|
|
onChange(next);
|
|
} else {
|
|
onChange([...value, newRow]);
|
|
}
|
|
}
|
|
|
|
// Only close form if we were editing an existing row. For new rows, stay open.
|
|
if (editingIdx !== null) {
|
|
setIsModalOpen(false);
|
|
}
|
|
setNewRow({});
|
|
setEditingIdx(null);
|
|
};
|
|
|
|
const isPotentialMining = label.toLowerCase().includes('potential');
|
|
|
|
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-3">
|
|
{value.map((row, i) => {
|
|
let productName = 'Unknown Product';
|
|
let bags = '0';
|
|
let hasBagsCol = false;
|
|
let diffVal: number | null = null;
|
|
let reasonStr = '';
|
|
|
|
visibleColumns.forEach(col => {
|
|
const isName = col.id.toLowerCase().includes('name') || col.name.toLowerCase().includes('name') || (col.id.toLowerCase().includes('category') && productName === 'Unknown Product');
|
|
const isBags = col.id.toLowerCase().includes('bags') || col.name.toLowerCase().includes('bags') || col.id.toLowerCase().includes('quantity');
|
|
const isDiff = col.id.toLowerCase().includes('diff') || col.name.toLowerCase().includes('diff');
|
|
const isReason = col.id.toLowerCase().includes('reason') || col.name.toLowerCase().includes('reason');
|
|
|
|
const val = row[col.id];
|
|
let displayVal = String(val ?? '-');
|
|
|
|
if (col.data_type === 'select' || col.data_type === 'multiselect') {
|
|
const opt = col.properties?.options?.find(o => String(o.value) === String(val));
|
|
if (opt) displayVal = opt.label;
|
|
}
|
|
|
|
if (isName && (productName === 'Unknown Product' || col.id.toLowerCase().includes('name'))) {
|
|
productName = displayVal;
|
|
}
|
|
if (isBags) {
|
|
bags = displayVal;
|
|
hasBagsCol = true;
|
|
}
|
|
|
|
if (isPotentialMining && isDiff && val != null && val !== '') {
|
|
diffVal = Number(val);
|
|
}
|
|
|
|
if (isPotentialMining && isReason && val && displayVal !== 'Select...' && displayVal !== '-') {
|
|
reasonStr = displayVal;
|
|
}
|
|
});
|
|
|
|
return (
|
|
<div key={i} className="bg-slate-50 border border-slate-200 rounded-xl p-3 shadow-sm flex items-center justify-between">
|
|
<div className="flex flex-col min-w-0 pr-4 gap-1.5">
|
|
<span className="font-bold text-slate-800 text-[14px] truncate">{productName}</span>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{(!isPotentialMining || hasBagsCol) && (
|
|
<span className="font-extrabold text-indigo-600 text-[13px]">{bags} {isPotentialMining ? 'Kgs' : 'Bags'}</span>
|
|
)}
|
|
|
|
{isPotentialMining && diffVal !== null && !isNaN(diffVal) && diffVal !== 0 && (
|
|
<span className={`text-[11px] font-bold px-2 py-0.5 rounded-md ${diffVal > 0 ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'}`}>
|
|
{Math.abs(diffVal)} {diffVal > 0 ? 'Surplus' : 'Less'}
|
|
</span>
|
|
)}
|
|
|
|
{isPotentialMining && reasonStr && (
|
|
<span className="text-[11px] font-medium bg-slate-200 text-slate-700 px-2 py-0.5 rounded-md truncate max-w-[120px]" title={reasonStr}>
|
|
{reasonStr}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<button type="button" onClick={() => startEdit(i)} className="text-indigo-600 bg-white border border-indigo-100 hover:bg-indigo-50 p-1.5 rounded-lg transition-colors flex items-center justify-center shadow-sm" title="Edit">
|
|
<Pencil size={14} />
|
|
</button>
|
|
<button type="button" onClick={() => removeRow(i)} className="text-red-600 bg-white border border-red-100 hover:bg-red-50 p-1.5 rounded-lg transition-colors flex items-center justify-center shadow-sm" title="Remove">
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end mt-2">
|
|
<Button
|
|
type="button"
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={startAdd}
|
|
className={`transition-all duration-300 ease-in-out font-bold text-lg leading-none ${isModalOpen || editingIdx !== null ? 'opacity-0 pointer-events-none scale-95 w-0 h-0 p-0 m-0 overflow-hidden' : 'opacity-100 scale-100 rounded-full w-10 h-10 flex items-center justify-center shadow-md'}`}
|
|
title="Add Row"
|
|
>
|
|
+
|
|
</Button>
|
|
</div>
|
|
|
|
<div
|
|
ref={formRef}
|
|
className={`grid transition-[grid-template-rows,opacity,margin] duration-300 ease-in-out ${
|
|
isModalOpen || editingIdx !== null ? 'grid-rows-[1fr] opacity-100 mt-4' : 'grid-rows-[0fr] opacity-0 mt-0'
|
|
}`}
|
|
>
|
|
<div className="overflow-hidden">
|
|
<div className="p-4 border border-border-subtle rounded-xl bg-white shadow-sm">
|
|
<h4 className="text-sm font-semibold text-strong mb-4 flex items-center gap-2">
|
|
{editingIdx !== null ? (
|
|
<><Pencil size={16} className="text-blue-600" /> Edit Row</>
|
|
) : (
|
|
<>Add New Item</>
|
|
)}
|
|
</h4>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 items-end">
|
|
{visibleColumns.map(col => {
|
|
const val = newRow[col.id];
|
|
|
|
const getBaseId = (id: string) => id.replace(/_\d+$/, '');
|
|
const catColId = columns.find(c => c.id === 'product_category' || c.name === 'Product Category' || c.mapped_workflow_field === 'product_category' || getBaseId(c.id) === 'product_category')?.id;
|
|
const isCatSelected = catColId ? !!newRow[catColId] : false;
|
|
const isProductName = col.id === 'product_name' || col.name === 'Product Name' || col.mapped_workflow_field === 'product_name' || getBaseId(col.id) === 'product_name';
|
|
const isBags = col.id === 'bags' || col.name === 'Bags' || col.mapped_workflow_field === 'bags' || getBaseId(col.id) === 'bags' || col.id.toLowerCase().includes('quantity') || col.name.toLowerCase().includes('quantity') || col.mapped_workflow_field?.toLowerCase().includes('quantity');
|
|
const isRequired = col.mandatory || (isCatSelected && (isProductName || isBags));
|
|
|
|
if (col.data_type === 'select' || col.data_type === 'multiselect') {
|
|
const allOpts = col.properties?.options || [];
|
|
let filteredOpts = allOpts;
|
|
|
|
if (col.id === 'product_name' || col.name === 'Product Name' || col.mapped_workflow_field === 'product_name') {
|
|
const catCol = columns.find(c => c.id === 'product_category' || c.name === 'Product Category' || c.mapped_workflow_field === 'product_category');
|
|
if (catCol) {
|
|
const selectedCategory = newRow[catCol.id] as string;
|
|
if (selectedCategory) {
|
|
filteredOpts = allOpts.filter(opt => {
|
|
const labelStr = String(opt.label || opt.value || '');
|
|
return labelStr.startsWith(selectedCategory);
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
filteredOpts = allOpts.filter(opt => {
|
|
if (!opt._raw) return true;
|
|
for (const [rowKey, rowVal] of Object.entries(newRow)) {
|
|
if (rowKey === col.id || rowVal == null || rowVal === '') continue;
|
|
const rawKey = Object.keys(opt._raw).find(rk => rk === rowKey || rk.replace(/_/g, '') === rowKey.replace(/_/g, ''));
|
|
if (rawKey && String(opt._raw[rawKey]) !== String(rowVal)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
return (
|
|
<Select
|
|
key={col.id}
|
|
label={col.name}
|
|
required={isRequired}
|
|
error={errors[col.id]}
|
|
value={(val as string) ?? ''}
|
|
onChange={(e) => updateNewRowField(col.id, e.target.value)}
|
|
options={[{ value: '', label: 'Select...' }, ...filteredOpts.map((o: any) => ({ value: String(o.value), label: o.label }))]}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<Input
|
|
key={col.id}
|
|
label={col.name}
|
|
required={isRequired}
|
|
error={errors[col.id]}
|
|
type={col.data_type === 'number' ? 'number' : col.data_type === 'email' ? 'email' : 'text'}
|
|
value={(val as string) ?? ''}
|
|
onChange={(e) => updateNewRowField(col.id, col.data_type === 'number' ? Number(e.target.value) : e.target.value)}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
<div className="flex justify-end gap-2 sm:col-span-2 md:col-span-3 mt-2">
|
|
<Button type="button" variant="ghost" onClick={() => { setIsModalOpen(false); setEditingIdx(null); setNewRow({}); }}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="button" variant="primary" onClick={submitNewRow}>
|
|
{editingIdx !== null ? "Save Item" : "+ Add to Order"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|