Files
iistwin/client/src/components/tabs/RegularTableTab.tsx
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

241 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Table, Plus, GripVertical, Trash2, X, Calculator, Sigma } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { selectOptionsToText, textToSelectOptions } from '@/lib/fieldOptions';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { TabModule, TabModuleProps, TabConfigProps, FormTab, TableColumnDef } from './types';
import { registerTabModule } from './registry';
import { FormulaInput } from '@/components/FormulaInput';
import { FIELD_TYPE_DEFINITIONS } from '@shared/field-types';
import * as LucideIcons from 'lucide-react';
interface RegularTableTabContentProps extends TabModuleProps {
dataTables?: Array<{ id: number; name: string; columns?: Array<{ name: string }> }>;
onAddColumn: (tabIndex: number) => void;
onUpdateColumn: (tabIndex: number, colIndex: number, updates: Partial<TableColumnDef>) => void;
onRemoveColumn: (tabIndex: number, colIndex: number) => void;
}
function RegularTableTabContent({
tab,
tabIndex,
allFields,
dataTables,
onAddColumn,
onUpdateColumn,
onRemoveColumn
}: RegularTableTabContentProps) {
const columns = tab.tableColumns || [];
const linkedForms = allFields
.filter(f => f.type === 'task' && f.linkedFormId)
.map(f => ({ id: f.linkedFormId!, name: f.code }));
if (columns.length === 0) {
return (
<div className="text-center py-4 text-muted-foreground text-sm border-dashed border rounded">
Нет колонок. Нажмите "Колонка" чтобы добавить.
</div>
);
}
return (
<div className="space-y-2">
{columns.map((col, colIndex) => (
<div key={col.id} className="p-2 border rounded bg-muted/20 space-y-2">
<div className="flex items-center gap-2">
<GripVertical className="w-4 h-4 text-muted-foreground cursor-grab" />
<Input
value={col.name}
onChange={(e) => onUpdateColumn(tabIndex, colIndex, { name: e.target.value })}
placeholder="Название колонки"
className="h-8 flex-1"
data-testid={`input-column-name-${tabIndex}-${colIndex}`}
/>
<Select
value={col.type}
onValueChange={(value) => {
const updates: Partial<TableColumnDef> = { type: value as TableColumnDef['type'] };
if (value !== 'select') updates.options = undefined;
if (value !== 'table') {
updates.dataTableId = undefined;
updates.displayColumn = undefined;
}
if (value !== 'task') updates.linkedFormId = undefined;
onUpdateColumn(tabIndex, colIndex, updates);
}}
>
<SelectTrigger className="w-44 h-8" data-testid={`select-column-type-${tabIndex}-${colIndex}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPE_DEFINITIONS.filter(t => t.id !== 'button').map((type) => {
const Icon = (LucideIcons as any)[type.iconName] || LucideIcons.Circle;
return (
<SelectItem key={type.id} value={type.id}>
<div className="flex items-center gap-2">
<Icon className="w-4 h-4" />
{type.label}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
<Input
type="number"
value={col.width}
onChange={(e) => onUpdateColumn(tabIndex, colIndex, { width: parseInt(e.target.value) || 100 })}
className="h-8 w-20"
placeholder="Ширина"
data-testid={`input-column-width-${tabIndex}-${colIndex}`}
/>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => onRemoveColumn(tabIndex, colIndex)}
data-testid={`button-remove-column-${tabIndex}-${colIndex}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
{col.type === 'select' && (
<div className="ml-6 space-y-1">
<label className="text-xs font-medium text-muted-foreground">Варианты списка</label>
<Textarea
value={selectOptionsToText(col.options)}
onChange={(e) => onUpdateColumn(tabIndex, colIndex, { options: textToSelectOptions(e.target.value) })}
placeholder="Вариант 1&#10;Вариант 2 | value2&#10;Вариант 3"
className="text-xs min-h-[60px]"
rows={3}
data-testid={`textarea-column-options-${tabIndex}-${colIndex}`}
/>
<p className="text-[10px] text-muted-foreground">
Каждый вариант на новой строке. Формат: Название или Название | Значение
</p>
</div>
)}
{col.type === 'formula' && (
<div className="ml-6 space-y-1">
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1">
<Calculator className="w-3 h-3" />
Выражение формулы
</label>
<FormulaInput
value={col.formula || ''}
onChange={(val) => onUpdateColumn(tabIndex, colIndex, { formula: val })}
availableNames={columns.filter(c => c.id !== col.id).map(c => c.name)}
formulaFieldCodes={columns.filter(c => c.id !== col.id && c.type === 'formula').map(c => c.name)}
placeholder="{Столбец1} + {Столбец2} * 2"
useInput
data-testid={`input-column-formula-${tabIndex}-${colIndex}`}
/>
<p className="text-xs text-muted-foreground">
Введите <code className="bg-muted px-0.5 rounded">{'{'}</code> для выбора столбца
</p>
</div>
)}
<div className="ml-6 space-y-1">
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1">
<Sigma className="w-3 h-3" />
Агрегация (итоговая строка)
</label>
<FormulaInput
value={col.aggregateFormula || ''}
onChange={(val) => onUpdateColumn(tabIndex, colIndex, { aggregateFormula: val || undefined })}
availableNames={columns.map(c => c.name)}
placeholder="SUM({Цена}), COUNT({Статус}), AVG({Кол-во})"
useInput
data-testid={`input-column-aggregate-${tabIndex}-${colIndex}`}
/>
<p className="text-xs text-muted-foreground">
Функции: <code className="bg-muted px-0.5 rounded">SUM</code>, <code className="bg-muted px-0.5 rounded">COUNT</code>, <code className="bg-muted px-0.5 rounded">AVG</code>, <code className="bg-muted px-0.5 rounded">MIN</code>, <code className="bg-muted px-0.5 rounded">MAX</code>
</p>
</div>
{col.type === 'table' && dataTables && (
<div className="ml-6 grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-muted-foreground">Таблица данных</label>
<Select
value={col.dataTableId?.toString() || 'none'}
onValueChange={(value) => {
if (value === 'none') {
onUpdateColumn(tabIndex, colIndex, { dataTableId: undefined, displayColumn: undefined });
return;
}
const selectedTable = dataTables.find(t => t.id === parseInt(value));
onUpdateColumn(tabIndex, colIndex, {
dataTableId: parseInt(value),
displayColumn: selectedTable?.columns?.[0]?.name || undefined
});
}}
>
<SelectTrigger className="h-7 text-xs" data-testid={`select-column-table-${tabIndex}-${colIndex}`}>
<SelectValue placeholder="Выберите таблицу" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Не выбрано</SelectItem>
{dataTables.map((table) => (
<SelectItem key={table.id} value={table.id.toString()}>
<div className="flex items-center gap-1">
<Table className="w-3 h-3" />
{table.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{col.dataTableId && (
<div>
<label className="text-xs font-medium text-muted-foreground">Отображаемый столбец</label>
<Select
value={col.displayColumn || 'none'}
onValueChange={(value) => onUpdateColumn(tabIndex, colIndex, { displayColumn: value === 'none' ? undefined : value })}
>
<SelectTrigger className="h-7 text-xs" data-testid={`select-column-display-${tabIndex}-${colIndex}`}>
<SelectValue placeholder="Выберите столбец" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Не выбрано</SelectItem>
{dataTables
.find(t => t.id === col.dataTableId)
?.columns?.map((c, idx) => (
<SelectItem key={idx} value={c.name || `col-${idx}`}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
</div>
))}
</div>
);
}
const regularTableTabModule: TabModule = {
type: 'table',
label: 'Обычная таблица',
icon: Table,
defaultConfig: (): Partial<FormTab> => ({
type: 'table',
tableColumns: [],
}),
TabContent: RegularTableTabContent as React.ComponentType<TabModuleProps>,
};
registerTabModule(regularTableTabModule);
export default regularTableTabModule;