- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
241 lines
11 KiB
TypeScript
241 lines
11 KiB
TypeScript
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 Вариант 2 | value2 Вариант 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;
|