krishna_sales/src/components/forms/fields/OrderGrid.tsx
2026-07-29 17:04:00 +05:30

512 lines
20 KiB
TypeScript

import { useMemo, useCallback } from 'react';
import type { FormScreenField } from '../../../api/types';
import { Select } from '../../reusable/Select';
import { Input } from '../../reusable/Input';
import { isCategoryColumn, isProductColumn, findColumn } from './gridUtils';
export interface OrderGridProps {
label: string;
columns: FormScreenField[];
value: Record<string, unknown>[];
onChange: (val: Record<string, unknown>[]) => void;
totalBagsValue?: number;
totalKgsValue?: number;
showErrors?: boolean;
}
export function OrderGrid({
label,
columns,
value = [],
onChange,
totalBagsValue,
totalKgsValue,
showErrors = false,
}: OrderGridProps) {
const rows = value.length > 0 ? value : [{}];
const catCol = useMemo(() => columns.find(c => isCategoryColumn(c.id, c.name)) || findColumn(columns, 'product_category', 'category'), [columns]);
const prodNameCol = useMemo(() => columns.find(c => isProductColumn(c.id, c.name)), [columns]);
const bagsCol = useMemo(() => findColumn(columns, 'bags', 'quantity', 'actual_potential', 'store_potential'), [columns]);
const visibleColumns = useMemo(() => {
const baseCols = (() => {
if (columns.length === 0) {
return [
{ id: 'product_category', name: 'Category', data_type: 'select' } as FormScreenField,
{ id: 'product_name', name: 'Product name', data_type: 'select' } as FormScreenField,
{ id: 'bags', name: 'Bags', data_type: 'number' } as FormScreenField,
];
}
const filtered = columns.filter((c) => {
return isCategoryColumn(c.id, c.name) || isProductColumn(c.id, c.name) || c.id.includes('bags') || (c.name || '').toLowerCase().includes('bags');
});
if (filtered.length > 0) return filtered;
return columns.filter((c) => {
const idL = c.id.toLowerCase();
const nL = (c.name || '').toLowerCase();
return !idL.includes('sku') && !nL.includes('sku') && !idL.includes('br_code') && !nL.includes('br code') && !idL.includes('row_kgs');
});
})();
return [
...baseCols,
{ id: '_row_total_kgs', name: 'Total (kgs)', data_type: 'readonly' } as FormScreenField,
];
}, [columns]);
const gridTemplateColumns = useMemo(() => {
const numCols = visibleColumns.length;
const actionCol = ' auto';
if (numCols === 4) return `1.3fr 1.5fr 0.7fr 0.8fr${actionCol}`;
if (numCols === 3) return `1.4fr 1.6fr 0.7fr${actionCol}`;
if (numCols === 2) return `1.5fr 1.5fr${actionCol}`;
return `repeat(${numCols}, minmax(0, 1fr))${actionCol}`;
}, [visibleColumns]);
const consolidateRows = useCallback((rowsList: Record<string, unknown>[]) => {
const merged: Record<string, unknown>[] = [];
const indexMap = new Map<string, number>();
for (const r of rowsList) {
const cat = String(r.category || r.product_category || (catCol ? r[catCol.id] : '') || '').trim().toLowerCase();
const prod = String(r.productName || r.product_name || (prodNameCol ? r[prodNameCol.id] : '') || '').trim().toLowerCase();
const bagsColId = bagsCol ? bagsCol.id : 'bags';
const currentBags = Number(r[bagsColId] ?? r.bags ?? r.quantity ?? 0);
if (cat && prod) {
const key = `${cat}::${prod}`;
if (indexMap.has(key) && currentBags > 0) {
const targetIdx = indexMap.get(key)!;
const existingRow = merged[targetIdx];
const existingBags = Number(existingRow[bagsColId] ?? existingRow.bags ?? existingRow.quantity ?? 0);
const addedBags = currentBags;
const newBags = existingBags + addedBags;
const skuVal = Number(existingRow.sku || r.sku || 0);
const updatedRow: Record<string, unknown> = {
...existingRow,
bags: newBags,
quantity: newBags,
row_kgs: skuVal * newBags,
};
if (bagsCol) updatedRow[bagsCol.id] = newBags;
merged[targetIdx] = updatedRow;
continue;
} else if (!indexMap.has(key)) {
indexMap.set(key, merged.length);
}
}
merged.push(r);
}
return merged.length > 0 ? merged : [{}];
}, [catCol, prodNameCol, bagsCol]);
const isAnyRowIncomplete = useMemo(() => {
return rows.some((r) => {
const cat = String(r.category || r.product_category || (catCol ? r[catCol.id] : '') || '').trim();
if (!cat) return true;
if (prodNameCol) {
const prod = String(r.productName || r.product_name || r[prodNameCol.id] || '').trim();
if (!prod) return true;
}
if (bagsCol) {
const bags = Number(r.bags || r.quantity || r[bagsCol.id] || 0);
if (bags <= 0) return true;
}
return false;
});
}, [rows, catCol, prodNameCol, bagsCol]);
const addRow = useCallback(() => {
if (isAnyRowIncomplete) return;
const consolidated = consolidateRows(rows);
const cleanRows = consolidated.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;
});
onChange([...cleanRows, {}]);
}, [rows, isAnyRowIncomplete, consolidateRows, onChange]);
const removeRow = useCallback((idx: number) => {
if (rows.length > 1) {
const next = [...rows];
next.splice(idx, 1);
const cleanRows = next.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;
});
onChange(cleanRows);
}
}, [rows, onChange]);
const categoriesList = useMemo(() => {
if (catCol?.properties?.options && catCol.properties.options.length > 0) {
return catCol.properties.options.map((o: any) => ({
value: String(o.value ?? o.label),
label: String(o.label ?? o.value),
}));
}
return [];
}, [catCol]);
const getProductsForCategory = useCallback((selectedCat?: string) => {
let allOpts: { value: string; label: string; rawCat?: string; raw?: any }[] = [];
if (prodNameCol?.properties?.options && prodNameCol.properties.options.length > 0) {
allOpts = prodNameCol.properties.options.map((o: any) => {
const val = String(o.value ?? o.label ?? '');
const label = String(o.label ?? o.value ?? '');
const rawCat = o._raw?.product_category || o._raw?.cat || o._raw?.category;
return { value: val, label, rawCat, raw: o._raw };
});
if (selectedCat) {
allOpts = allOpts.filter((o) => {
if (o.rawCat) {
return String(o.rawCat).trim().toLowerCase() === String(selectedCat).trim().toLowerCase();
}
return o.label.toLowerCase().startsWith(String(selectedCat).toLowerCase());
});
}
}
return allOpts;
}, [prodNameCol]);
const updateRowField = useCallback((rowIdx: number, fieldId: string, val: unknown) => {
const next = rows.map((r) => ({ ...r }));
let row = { ...next[rowIdx] };
const colDef = columns.find((c) => c.id === fieldId) || visibleColumns.find((c) => c.id === fieldId);
const colName = colDef?.name || '';
const isCatField = isCategoryColumn(fieldId, colName);
const isProdField = isProductColumn(fieldId, colName);
const isBagsField = colDef?.data_type === 'number' || fieldId.includes('bags') || fieldId.includes('quantity') || colName.toLowerCase().includes('bags');
if (isCatField) {
const catVal = String(val ?? '');
row[fieldId] = catVal;
row['category'] = catVal;
row['product_category'] = catVal;
const pColId = prodNameCol?.id || 'product_name';
row[pColId] = '';
row['product_name'] = '';
row['productName'] = '';
row['sku'] = '';
row['br_code'] = '';
row['br'] = '';
row['sku_code'] = '';
row['skucode'] = '';
row['bags'] = '';
row['quantity'] = '';
if (bagsCol) row[bagsCol.id] = '';
row['row_kgs'] = 0;
} else if (isProdField) {
const currentCat = String(row['category'] || row['product_category'] || (catCol ? row[catCol.id] : '') || '');
const prodOptions = getProductsForCategory(currentCat);
const matchedOpt = prodOptions.find(
(o) => o.value === String(val) || o.label === String(val) || o.label.toLowerCase() === String(val).toLowerCase()
);
const selectedName = matchedOpt?.label || String(val);
const sku = matchedOpt?.raw?.sku || '';
const br = matchedOpt?.raw?.br_code || matchedOpt?.raw?.br || matchedOpt?.raw?.brcode || matchedOpt?.raw?.brand_code || '';
const skucode = matchedOpt?.raw?.sku_code || matchedOpt?.raw?.skucode || '';
const cat = matchedOpt?.rawCat || matchedOpt?.raw?.product_category || currentCat;
const setVal = matchedOpt?.value || String(val);
row[fieldId] = setVal;
row['product_name'] = selectedName;
row['productName'] = selectedName;
if (prodNameCol) row[prodNameCol.id] = setVal;
row['sku'] = sku;
const skuCol = findColumn(columns, 'sku');
if (skuCol) row[skuCol.id] = sku;
row['br'] = br;
row['br_code'] = br;
const brCol = findColumn(columns, 'br_code', 'br');
if (brCol) row[brCol.id] = br;
row['skucode'] = skucode;
row['sku_code'] = skucode;
const skuCodeCol = findColumn(columns, 'sku_code', 'skucode');
if (skuCodeCol) row[skuCodeCol.id] = skucode;
if (cat) {
row['category'] = cat;
row['product_category'] = cat;
if (catCol) row[catCol.id] = cat;
}
const bagsVal = Number(row.bags ?? row.quantity ?? (bagsCol ? row[bagsCol.id] : 0)) || 0;
row['row_kgs'] = Number(sku || 0) * bagsVal;
} else if (isBagsField) {
const parsedVal = val !== '' && val !== null && val !== undefined ? val : '';
const numericVal = Number(val) || 0;
row[fieldId] = parsedVal;
row['bags'] = parsedVal;
row['quantity'] = parsedVal;
if (bagsCol) row[bagsCol.id] = parsedVal;
const skuVal = Number(row.sku || 0);
row['row_kgs'] = skuVal * numericVal;
} else {
row[fieldId] = val;
}
next[rowIdx] = row;
const cleanRows = next.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;
});
onChange(cleanRows);
}, [rows, columns, visibleColumns, prodNameCol, catCol, bagsCol, getProductsForCategory, onChange]);
const { calculatedBags, calculatedKgs } = useMemo(() => {
let bagsAcc = 0;
let kgsAcc = 0;
for (const r of rows) {
const b = Number(r.bags ?? r.quantity ?? (bagsCol ? r[bagsCol.id] : 0)) || 0;
const sku = Number(r.sku) || 0;
bagsAcc += b;
kgsAcc += b * sku;
}
return { calculatedBags: Math.round(bagsAcc), calculatedKgs: Math.round(kgsAcc) };
}, [rows, bagsCol]);
const displayTotalBags = totalBagsValue !== undefined ? totalBagsValue : calculatedBags;
const displayTotalKgs = totalKgsValue !== undefined ? totalKgsValue : calculatedKgs;
return (
<div className="max-w-full bg-[var(--tiles-card-bg)] rounded-xl border border-border-default p-6 space-y-6 shadow-sm font-sans">
<div className="flex items-center justify-between">
<p className="text-sm font-bold text-slate-700 uppercase tracking-wide">
{label || 'Order details'}
</p>
</div>
<div className="border-t border-border-subtle pt-4">
<div className="grid gap-3 px-1 pb-2 text-xs font-bold text-muted uppercase tracking-wider" style={{ gridTemplateColumns }}>
{visibleColumns.map((col) => (
<span key={col.id}>{col.name}</span>
))}
<span />
</div>
<div className="space-y-3">
{rows.map((row, rowIdx) => {
const currentCatVal = String(row.category ?? row.product_category ?? (catCol ? row[catCol.id] : '') ?? '');
const currentProdVal = String(row[prodNameCol?.id || 'product_name'] ?? row.product_name ?? row.productName ?? '');
const currentBagsVal = row.bags ?? row.quantity ?? (bagsCol ? row[bagsCol.id] : '') ?? '';
const prodOptions = getProductsForCategory(currentCatVal);
const isCategorySelected = Boolean(currentCatVal.trim());
const skuNum = Number(row.sku) || 0;
const bagsNum = Number(currentBagsVal) || 0;
const computedRowKgs = skuNum * bagsNum;
const isMissingProd = isCategorySelected && !currentProdVal.trim();
const prodError = showErrors && isMissingProd ? 'Required' : undefined;
const isMissingBags = isCategorySelected && (!currentBagsVal || Number(currentBagsVal) <= 0);
const bagsError = showErrors && isMissingBags ? 'Required' : undefined;
return (
<div key={rowIdx}>
<div className="grid gap-3 items-center" style={{ gridTemplateColumns }}>
{visibleColumns.map((col) => {
const fieldId = col.id;
if (fieldId === '_row_total_kgs') {
return (
<Input
key={fieldId}
disabled
value={computedRowKgs > 0 ? `${computedRowKgs.toLocaleString()} kgs` : '0 kgs'}
className="w-full font-semibold text-slate-700 bg-slate-50 border-slate-200"
/>
);
}
const isCat = isCategoryColumn(fieldId, col.name);
const isProd = isProductColumn(fieldId, col.name);
const isBagsOrNum = col.data_type === 'number' || fieldId.includes('bags') || fieldId.includes('quantity') || col.name?.toLowerCase().includes('bags');
if (isCat) {
return (
<Select
key={fieldId}
value={currentCatVal}
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
options={[{ value: '', label: 'Category' }, ...categoriesList]}
className="w-full"
/>
);
}
if (isProd) {
return (
<Select
key={fieldId}
value={currentProdVal}
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
disabled={!isCategorySelected && categoriesList.length > 0}
options={[
{ value: '', label: isCategorySelected || categoriesList.length === 0 ? 'Select product' : 'Select category first' },
...prodOptions
]}
error={prodError}
className="w-full"
/>
);
}
if (isBagsOrNum) {
const isMainBagsCol = bagsCol?.id === fieldId || fieldId === 'bags' || fieldId === 'quantity';
const numVal = isMainBagsCol ? currentBagsVal : row[fieldId];
return (
<Input
key={fieldId}
type="number"
min={isMainBagsCol ? "1" : undefined}
value={numVal !== undefined && numVal !== null ? String(numVal) : ''}
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
placeholder={isMainBagsCol && isCategorySelected ? 'Bags' : '0'}
error={isMainBagsCol ? bagsError : undefined}
className="w-full"
/>
);
}
if (col.data_type === 'select' || col.data_type === 'multiselect') {
const opts = col.properties?.options || [];
return (
<Select
key={fieldId}
value={String(row[fieldId] ?? '')}
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
options={[{ value: '', label: 'Select...' }, ...opts.map((o: any) => ({ value: String(o.value), label: o.label }))]}
className="w-full"
/>
);
}
return (
<Input
key={fieldId}
type="text"
value={String(row[fieldId] ?? '')}
onChange={(e) => updateRowField(rowIdx, fieldId, e.target.value)}
className="w-full"
/>
);
})}
<button
type="button"
onClick={() => removeRow(rowIdx)}
aria-label="Remove product"
disabled={rows.length === 1}
className="h-[42px] w-[42px] flex items-center justify-center rounded-md border border-border-default text-slate-500 hover:bg-slate-50 hover:border-ruby-300 hover:text-ruby-600 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-slate-500 transition-colors shrink-0"
>
</button>
</div>
</div>
);
})}
</div>
<div className="pt-2 flex justify-end">
<button
type="button"
onClick={addRow}
disabled={isAnyRowIncomplete}
className="text-xs px-3.5 py-2 rounded-md bg-slate-900 text-white hover:bg-slate-800 font-semibold transition-all shadow-sm flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-slate-900"
>
+ Add product
</button>
</div>
{bagsCol && (
<div className="border-t border-border-subtle pt-3 mt-4">
<div className="grid gap-3 items-center" style={{ gridTemplateColumns }}>
{visibleColumns.map((col, idx) => {
const fieldId = col.id;
const isBagsOrNum = col.data_type === 'number' || fieldId.includes('bags') || fieldId.includes('quantity') || col.name?.toLowerCase().includes('bags');
if (isBagsOrNum) {
return (
<Input
key={fieldId}
disabled
value={displayTotalBags.toLocaleString()}
className="w-full font-bold text-center text-slate-800 bg-slate-100 border-slate-200"
/>
);
}
if (fieldId === '_row_total_kgs') {
return (
<Input
key={fieldId}
disabled
value={`${displayTotalKgs.toLocaleString()} kgs`}
className="w-full font-bold text-center text-slate-800 bg-slate-100 border-slate-200"
/>
);
}
if (idx === 0) {
return <div key={col.id} className="flex items-center text-xs font-bold text-slate-500 uppercase tracking-wider px-1">Total</div>;
}
return <div key={col.id} />;
})}
<div className="w-[42px]" />
</div>
</div>
)}
</div>
</div>
);
}