512 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|