fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View File

@@ -0,0 +1,934 @@
import { useState } from 'react';
import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
import { JsComponentRenderer } from './JsComponentRenderer';
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiRequest } from "@/lib/queryClient";
import { Info, AlertCircle, CheckCircle, Clock, FileText, Link as LinkIcon, ExternalLink, Image, BarChart3, Calendar, User, Puzzle, Code2, Loader2, AlertTriangle, Save, Edit2 } from "lucide-react";
const ALLOWED_EMBED_DOMAINS = [
'docs.google.com',
'sheets.google.com',
'drive.google.com',
'www.google.com',
'notion.so',
'www.notion.so',
'airtable.com',
'www.airtable.com',
'figma.com',
'www.figma.com',
'miro.com',
'www.miro.com',
'trello.com',
'www.trello.com',
'youtube.com',
'www.youtube.com',
'youtu.be',
'vimeo.com',
'www.vimeo.com',
'loom.com',
'www.loom.com',
'calendly.com',
'www.calendly.com',
];
const isAllowedEmbedUrl = (url: string): boolean => {
try {
const parsed = new URL(url);
return ALLOWED_EMBED_DOMAINS.some(domain =>
parsed.hostname === domain || parsed.hostname.endsWith('.' + domain)
);
} catch {
return false;
}
};
interface InputSchemaField {
name: string;
label: string;
type: 'text' | 'url' | 'number' | 'textarea';
required?: boolean;
default?: string | number;
placeholder?: string;
description?: string;
}
interface SafeStyle {
margin?: string;
padding?: string;
border?: string;
borderRadius?: string;
width?: string;
height?: string;
maxWidth?: string;
maxHeight?: string;
minWidth?: string;
minHeight?: string;
overflow?: string;
background?: string;
backgroundColor?: string;
}
interface TabPane {
name: string;
embedUrl?: string;
height?: number;
content?: string;
componentType?: string;
config?: DeclarativeConfig;
}
interface FormulaConfig {
formId: number;
fieldCode: string;
operation: 'sum' | 'avg' | 'count' | 'min' | 'max';
statusId?: number;
label?: string;
format?: 'number' | 'currency' | 'percent';
decimals?: number;
}
interface DeclarativeConfig {
componentType: string;
title?: string;
content?: string;
items?: Array<{ label: string; value?: string; icon?: string; fieldCode?: string }>;
links?: Array<{ text: string; url: string }>;
badges?: Array<{ text: string; variant?: string }>;
icon?: string;
variant?: 'default' | 'info' | 'warning' | 'success';
imageUrls?: string[];
chartType?: 'bar' | 'line' | 'pie';
chartData?: Array<{ label: string; value: number }>;
dateField?: string;
contacts?: Array<{ name: string; role?: string; email?: string; phone?: string }>;
embedUrl?: string;
embedHeight?: number;
embedAllowFullscreen?: boolean;
embedFullHeight?: boolean;
hideTitle?: boolean;
embedStyle?: SafeStyle;
containerStyle?: SafeStyle;
tabs?: TabPane[];
children?: DeclarativeConfig[];
layout?: 'rows' | 'columns';
gap?: number;
formulas?: FormulaConfig[];
code?: string;
}
interface DeclarativeTabRendererProps {
config: DeclarativeConfig;
inputSchema?: InputSchemaField[];
taskId?: number;
tabId?: number;
task?: any;
formData?: Record<string, unknown>;
fieldValues?: Record<string, unknown>;
}
const ALLOWED_STYLE_PROPS = [
'margin', 'padding', 'border', 'borderRadius',
'width', 'height', 'maxWidth', 'maxHeight', 'minWidth', 'minHeight',
'overflow', 'background', 'backgroundColor'
];
const sanitizeStyle = (style?: SafeStyle): React.CSSProperties => {
if (!style) return {};
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(style)) {
if (ALLOWED_STYLE_PROPS.includes(key) && typeof value === 'string') {
const sanitized = value.replace(/[;{}]/g, '').trim();
const lowered = sanitized.toLowerCase();
if (sanitized &&
!lowered.includes('url(') &&
!lowered.includes('expression(') &&
!lowered.includes('javascript:') &&
!lowered.includes('data:')) {
result[key] = sanitized;
}
}
}
return result as React.CSSProperties;
};
const getIconComponent = (iconName?: string) => {
switch (iconName) {
case 'info': return <Info className="w-5 h-5" />;
case 'warning': return <AlertCircle className="w-5 h-5 text-yellow-500" />;
case 'success': return <CheckCircle className="w-5 h-5 text-green-500" />;
case 'clock': return <Clock className="w-5 h-5" />;
case 'file': return <FileText className="w-5 h-5" />;
case 'link': return <LinkIcon className="w-5 h-5" />;
default: return <Info className="w-5 h-5" />;
}
};
const getVariantClass = (variant?: string) => {
switch (variant) {
case 'info': return 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950';
case 'warning': return 'border-yellow-200 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-950';
case 'success': return 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950';
default: return '';
}
};
const InfoPanelComponent = ({ config }: { config: DeclarativeConfig }) => {
return (
<Card className={getVariantClass(config.variant)}>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
{getIconComponent(config.icon)}
<CardTitle className="text-lg">{config.title || 'Информация'}</CardTitle>
</div>
{config.content && (
<CardDescription className="mt-2">{config.content}</CardDescription>
)}
</CardHeader>
{(config.items?.length || config.badges?.length || config.links?.length) && (
<CardContent className="pt-0 space-y-4">
{config.items && config.items.length > 0 && (
<div className="space-y-2">
{config.items.map((item, idx) => (
<div key={idx} className="flex items-center justify-between py-1 border-b last:border-0">
<span className="text-sm text-muted-foreground">{item.label}</span>
<span className="text-sm font-medium">{item.value || '-'}</span>
</div>
))}
</div>
)}
{config.badges && config.badges.length > 0 && (
<div className="flex flex-wrap gap-2">
{config.badges.map((badge, idx) => (
<Badge
key={idx}
variant={badge.variant === 'secondary' ? 'secondary' :
badge.variant === 'destructive' ? 'destructive' :
badge.variant === 'outline' ? 'outline' : 'default'}
>
{badge.text}
</Badge>
))}
</div>
)}
{config.links && config.links.length > 0 && (
<div className="space-y-1">
{config.links.map((link, idx) => (
<a
key={idx}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
>
<ExternalLink className="w-3 h-3" />
{link.text}
</a>
))}
</div>
)}
</CardContent>
)}
</Card>
);
};
const ImageGalleryComponent = ({ config }: { config: DeclarativeConfig }) => {
const images = config.imageUrls || [];
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Image className="w-5 h-5" />
<CardTitle className="text-lg">{config.title || 'Галерея'}</CardTitle>
</div>
</CardHeader>
<CardContent>
{images.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Image className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Нет изображений</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{images.map((url, idx) => (
<div key={idx} className="aspect-square rounded-lg overflow-hidden border">
<img src={url} alt={`Image ${idx + 1}`} className="w-full h-full object-cover" />
</div>
))}
</div>
)}
</CardContent>
</Card>
);
};
const ContactsComponent = ({ config }: { config: DeclarativeConfig }) => {
const contacts = config.contacts || [];
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<User className="w-5 h-5" />
<CardTitle className="text-lg">{config.title || 'Контакты'}</CardTitle>
</div>
</CardHeader>
<CardContent>
{contacts.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<User className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Нет контактов</p>
</div>
) : (
<div className="space-y-4">
{contacts.map((contact, idx) => (
<div key={idx} className="flex items-start gap-4 p-4 border rounded-lg">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center text-white font-medium">
{contact.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
<div className="font-medium">{contact.name}</div>
{contact.role && (
<div className="text-sm text-muted-foreground">{contact.role}</div>
)}
{contact.email && (
<a href={`mailto:${contact.email}`} className="text-sm text-blue-600 hover:underline block">
{contact.email}
</a>
)}
{contact.phone && (
<a href={`tel:${contact.phone}`} className="text-sm text-blue-600 hover:underline block">
{contact.phone}
</a>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
};
const ChartPlaceholderComponent = ({ config }: { config: DeclarativeConfig }) => {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5" />
<CardTitle className="text-lg">{config.title || 'Диаграмма'}</CardTitle>
</div>
</CardHeader>
<CardContent className="py-12">
<div className="text-center text-muted-foreground">
<BarChart3 className="w-16 h-16 mx-auto mb-4 opacity-30" />
<p>Диаграмма будет отображаться здесь</p>
<p className="text-sm mt-1">Тип: {config.chartType || 'bar'}</p>
</div>
</CardContent>
</Card>
);
};
const CalendarPlaceholderComponent = ({ config }: { config: DeclarativeConfig }) => {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5" />
<CardTitle className="text-lg">{config.title || 'Календарь'}</CardTitle>
</div>
</CardHeader>
<CardContent className="py-12">
<div className="text-center text-muted-foreground">
<Calendar className="w-16 h-16 mx-auto mb-4 opacity-30" />
<p>Календарь событий будет отображаться здесь</p>
</div>
</CardContent>
</Card>
);
};
const EmbedComponent = ({ config }: { config: DeclarativeConfig }) => {
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const embedUrl = config.embedUrl || '';
const height = config.embedFullHeight ? 'calc(100vh - 180px)' : (config.embedHeight || 500);
const allowFullscreen = config.embedAllowFullscreen !== false;
const hideTitle = config.hideTitle === true;
const containerStyle = sanitizeStyle(config.containerStyle);
const embedStyle = sanitizeStyle(config.embedStyle);
const isAllowed = isAllowedEmbedUrl(embedUrl);
if (!embedUrl) {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Code2 className="w-5 h-5" />
<CardTitle className="text-lg">{config.title || 'Встроенный контент'}</CardTitle>
</div>
</CardHeader>
<CardContent className="py-12">
<div className="text-center text-muted-foreground">
<Code2 className="w-16 h-16 mx-auto mb-4 opacity-30" />
<p>URL не указан</p>
<p className="text-sm mt-1">Настройте URL в редакторе модуля</p>
</div>
</CardContent>
</Card>
);
}
if (!isAllowed) {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-yellow-500" />
<CardTitle className="text-lg">{config.title || 'Встроенный контент'}</CardTitle>
</div>
</CardHeader>
<CardContent className="py-8">
<div className="text-center">
<AlertTriangle className="w-12 h-12 mx-auto mb-4 text-yellow-500" />
<p className="font-medium">Домен не разрешён</p>
<p className="text-sm text-muted-foreground mt-2">
Для безопасности поддерживаются только следующие сервисы:
</p>
<div className="flex flex-wrap justify-center gap-2 mt-4 text-xs">
<Badge variant="outline">Google Docs/Sheets</Badge>
<Badge variant="outline">Notion</Badge>
<Badge variant="outline">Airtable</Badge>
<Badge variant="outline">Figma</Badge>
<Badge variant="outline">Miro</Badge>
<Badge variant="outline">YouTube</Badge>
<Badge variant="outline">Vimeo</Badge>
<Badge variant="outline">Loom</Badge>
<Badge variant="outline">Calendly</Badge>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<div style={containerStyle}>
<Card className={hideTitle ? 'border-0 shadow-none' : ''}>
{!hideTitle && (
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Code2 className="w-5 h-5" />
<CardTitle className="text-lg">{config.title || 'Встроенный контент'}</CardTitle>
</div>
{config.content && (
<CardDescription>{config.content}</CardDescription>
)}
</CardHeader>
)}
<CardContent className={hideTitle ? 'p-0' : 'p-0 overflow-hidden rounded-b-lg'}>
{isLoading && !hasError && (
<div className="flex items-center justify-center py-12" style={{ height }}>
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
)}
{hasError && (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground" style={{ height }}>
<AlertCircle className="w-12 h-12 mb-4" />
<p>Не удалось загрузить контент</p>
<a
href={embedUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline mt-2 flex items-center gap-1"
>
Открыть в новой вкладке <ExternalLink className="w-3 h-3" />
</a>
</div>
)}
<iframe
src={embedUrl}
style={{
width: '100%',
height,
border: 'none',
display: isLoading || hasError ? 'none' : 'block',
...embedStyle
}}
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
referrerPolicy="no-referrer"
allowFullScreen={allowFullscreen}
onLoad={() => setIsLoading(false)}
onError={() => {
setIsLoading(false);
setHasError(true);
}}
/>
</CardContent>
</Card>
</div>
);
};
const PlaceholderComponent = ({ config }: { config: DeclarativeConfig }) => {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Puzzle className="w-12 h-12 mb-4" />
<p className="text-lg font-medium">Пользовательская вкладка</p>
<p className="text-sm mt-1">{config.title || 'Тип: ' + config.componentType}</p>
</CardContent>
</Card>
);
};
const FormulaComponent = ({ config }: { config: DeclarativeConfig }) => {
const formulas = config.formulas || [];
const formatValue = (value: number | null, formula: FormulaConfig): string => {
if (value === null) return '—';
const decimals = formula.decimals ?? 2;
const formatted = value.toFixed(decimals);
switch (formula.format) {
case 'currency':
return `${formatted} ₽`;
case 'percent':
return `${formatted}%`;
default:
return formatted;
}
};
const getOperationLabel = (op: string): string => {
switch (op) {
case 'sum': return 'Сумма';
case 'avg': return 'Среднее';
case 'count': return 'Количество';
case 'min': return 'Минимум';
case 'max': return 'Максимум';
default: return op;
}
};
const formulaQueries = useQueries({
queries: formulas.map((formula) => ({
queryKey: ['/api/forms', formula.formId, 'tasks/aggregate', formula.fieldCode, formula.operation, formula.statusId],
queryFn: async () => {
const res = await fetch(`/api/forms/${formula.formId}/tasks/aggregate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
fieldCode: formula.fieldCode,
operation: formula.operation,
statusId: formula.statusId
})
});
if (!res.ok) {
throw new Error(`HTTP error ${res.status}`);
}
const data = await res.json();
if (!data.success) {
throw new Error(data.error || 'Ошибка при получении данных');
}
return data;
},
enabled: !!formula.formId && !!formula.fieldCode && !!formula.operation,
staleTime: 30000,
})),
});
if (formulas.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<BarChart3 className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p>Формулы не настроены</p>
</CardContent>
</Card>
);
}
return (
<Card className={getVariantClass(config.variant)}>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5" />
<CardTitle className="text-lg">{config.title || 'Расчёты'}</CardTitle>
</div>
{config.content && (
<CardDescription>{config.content}</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-4">
{formulas.map((formula, idx) => {
const query = formulaQueries[idx];
const isLoading = query?.isLoading;
const isError = query?.isError;
const value = query?.data?.result?.value ?? null;
const taskCount = query?.data?.result?.taskCount ?? 0;
return (
<div key={idx} className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium">{formula.label || `${getOperationLabel(formula.operation)} поля ${formula.fieldCode}`}</p>
<p className="text-xs text-muted-foreground">
Задач: {taskCount}
</p>
</div>
<div className="text-right">
{isLoading ? (
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
) : isError ? (
<p className="text-sm text-destructive">Ошибка</p>
) : (
<p className="text-2xl font-bold">{formatValue(value, formula)}</p>
)}
</div>
</div>
);
})}
</CardContent>
</Card>
);
};
const renderComponent = (config: DeclarativeConfig, savedValues?: Record<string, any>, task?: any, fieldValues?: Record<string, unknown>): JSX.Element => {
switch (config.componentType) {
case 'info_panel':
return <InfoPanelComponent config={config} />;
case 'image_gallery':
return <ImageGalleryComponent config={config} />;
case 'contacts':
return <ContactsComponent config={config} />;
case 'chart':
return <ChartPlaceholderComponent config={config} />;
case 'calendar':
return <CalendarPlaceholderComponent config={config} />;
case 'embed':
return <EmbedComponent config={config} />;
case 'tabs':
return <TabsContainerComponentInner config={config} savedValues={savedValues} />;
case 'layout':
return <LayoutComponentInner config={config} />;
case 'formula':
return <FormulaComponent config={config} />;
case 'js_component':
return <JsComponentRenderer code={config.code || ''} task={task} fieldValues={fieldValues} />;
default:
return <PlaceholderComponent config={config} />;
}
};
const TabsContainerComponentInner = ({ config, savedValues }: { config: DeclarativeConfig; savedValues?: Record<string, any> }) => {
const [activeTab, setActiveTab] = useState(0);
const tabs = savedValues?.tabs || config.tabs || [];
const containerStyle = sanitizeStyle(config.containerStyle);
if (tabs.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<Puzzle className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p>Вкладки не настроены</p>
</CardContent>
</Card>
);
}
return (
<div style={containerStyle} className="flex flex-col h-full">
<div className="flex border-b bg-muted/30 overflow-x-auto">
{tabs.map((tab: TabPane, idx: number) => (
<button
key={idx}
onClick={() => setActiveTab(idx)}
className={`px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors ${
activeTab === idx
? 'bg-background border-b-2 border-primary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
>
{tab.name || `Вкладка ${idx + 1}`}
</button>
))}
</div>
<div className="flex-1 overflow-hidden">
{tabs.map((tab: TabPane, idx: number) => (
<div
key={idx}
className={activeTab === idx ? 'h-full' : 'hidden'}
>
{tab.embedUrl ? (
<EmbedComponent config={{
componentType: 'embed',
embedUrl: tab.embedUrl,
embedHeight: tab.height || 500,
embedFullHeight: !tab.height,
hideTitle: true,
}} />
) : tab.config ? (
renderComponent(tab.config)
) : (
<div className="p-4 text-muted-foreground text-center">
{tab.content || 'Содержимое не настроено'}
</div>
)}
</div>
))}
</div>
</div>
);
};
const LayoutComponentInner = ({ config }: { config: DeclarativeConfig }) => {
const children = config.children || [];
const layout = config.layout || 'rows';
const gap = config.gap || 4;
const containerStyle = sanitizeStyle(config.containerStyle);
if (children.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<Puzzle className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p>Компоненты не добавлены</p>
</CardContent>
</Card>
);
}
return (
<div
className={`${layout === 'columns' ? 'flex flex-row' : 'flex flex-col'}`}
style={{ ...containerStyle, gap: `${gap * 4}px` }}
>
{children.map((child, idx) => (
<div key={idx} className={layout === 'columns' ? 'flex-1' : ''}>
{renderComponent(child)}
</div>
))}
</div>
);
};
const InputForm = ({
schema,
initialValues,
onSave,
isSaving,
title
}: {
schema: InputSchemaField[];
initialValues: Record<string, any>;
onSave: (values: Record<string, any>) => void;
isSaving: boolean;
title?: string;
}) => {
const [values, setValues] = useState<Record<string, any>>(() => {
const defaults: Record<string, any> = {};
schema.forEach(field => {
defaults[field.name] = initialValues[field.name] ?? field.default ?? '';
});
return defaults;
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(values);
};
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Edit2 className="w-5 h-5" />
<CardTitle className="text-lg">{title || 'Настройка вкладки'}</CardTitle>
</div>
<CardDescription>Заполните данные для отображения содержимого</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{schema.map(field => (
<div key={field.name} className="space-y-2">
<Label htmlFor={field.name}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id={field.name}
type={field.type === 'number' ? 'number' : 'text'}
value={values[field.name] || ''}
onChange={(e) => setValues(prev => ({
...prev,
[field.name]: field.type === 'number' ? Number(e.target.value) : e.target.value
}))}
placeholder={field.placeholder}
required={field.required}
/>
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>
)}
</div>
))}
<Button type="submit" disabled={isSaving} className="w-full">
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Сохранение...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Сохранить
</>
)}
</Button>
</form>
</CardContent>
</Card>
);
};
export const DeclarativeTabRenderer = ({ config, inputSchema, taskId, tabId, task, fieldValues }: DeclarativeTabRendererProps) => {
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const hasTaskContext = taskId !== undefined && tabId !== undefined;
const hasInputSchema = inputSchema && inputSchema.length > 0;
const { data: savedValuesData, isLoading: isLoadingValues } = useQuery<{ success: boolean; values: Record<string, any> }>({
queryKey: ['/api/tasks', taskId, 'tabs', tabId, 'values'],
enabled: hasTaskContext,
staleTime: 30000,
});
const savedValues = savedValuesData?.values || {};
const hasData = Object.keys(savedValues).length > 0;
const saveMutation = useMutation({
mutationFn: async (values: Record<string, any>) => {
const response = await apiRequest('PUT', `/api/tasks/${taskId}/tabs/${tabId}/values`, { values });
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'tabs', tabId, 'values'] });
setIsEditing(false);
},
});
if (!config || !config.componentType) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Конфигурация модуля не найдена
</CardContent>
</Card>
);
}
if (hasTaskContext && isLoadingValues) {
return (
<Card>
<CardContent className="py-12 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</CardContent>
</Card>
);
}
if (!hasTaskContext && hasInputSchema) {
return (
<Card className="border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Info className="w-5 h-5" />
{config.title || 'Шаблон модуля'}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Данные будут введены при работе с конкретной задачей.
</p>
<div className="space-y-2 text-sm">
<p className="font-medium">Поля для ввода:</p>
{inputSchema.map((field, idx) => (
<div key={idx} className="flex items-center gap-2 pl-2">
<span className="text-muted-foreground">•</span>
<span>{field.label}</span>
{field.required && <Badge variant="secondary" className="text-xs">обязательное</Badge>}
</div>
))}
</div>
</CardContent>
</Card>
);
}
if (hasTaskContext && hasInputSchema && (!hasData || isEditing)) {
return (
<div className="space-y-4">
<InputForm
schema={inputSchema}
initialValues={savedValues}
onSave={(values) => saveMutation.mutate(values)}
isSaving={saveMutation.isPending}
title={config.title}
/>
{hasData && isEditing && (
<Button variant="outline" onClick={() => setIsEditing(false)} className="w-full">
Отмена
</Button>
)}
</div>
);
}
const resolvedConfig = { ...config };
if (hasTaskContext && savedValues) {
if (savedValues.embedUrl) resolvedConfig.embedUrl = savedValues.embedUrl;
if (savedValues.embedHeight) resolvedConfig.embedHeight = savedValues.embedHeight;
if (savedValues.embedFullHeight !== undefined) resolvedConfig.embedFullHeight = savedValues.embedFullHeight;
if (savedValues.hideTitle !== undefined) resolvedConfig.hideTitle = savedValues.hideTitle;
if (savedValues.title) resolvedConfig.title = savedValues.title;
if (savedValues.content) resolvedConfig.content = savedValues.content;
}
if (fieldValues && config.items) {
resolvedConfig.items = config.items.map(item => ({
...item,
value: item.fieldCode && fieldValues[item.fieldCode] !== undefined
? String(fieldValues[item.fieldCode])
: item.value
}));
}
const renderContent = () => {
return renderComponent(resolvedConfig, savedValues, task, fieldValues);
};
return (
<div className="space-y-4">
{renderContent()}
{hasTaskContext && hasInputSchema && hasData && (
<Button variant="outline" onClick={() => setIsEditing(true)} className="w-full">
<Edit2 className="w-4 h-4 mr-2" />
Изменить настройки
</Button>
)}
</div>
);
};
export default DeclarativeTabRenderer;

View File

@@ -0,0 +1,140 @@
import { LayoutList, GripVertical } from 'lucide-react';
import type { TabModule, TabModuleProps, FormTab, FormField } from './types';
import { registerTabModule } from './registry';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface SortableFieldItemProps {
field: FormField;
fieldIndex: number;
renderFieldCard: (field: FormField, fieldIndex: number, dragHandleProps?: any) => React.ReactNode;
}
function SortableFieldItem({ field, fieldIndex, renderFieldCard }: SortableFieldItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: `field-${fieldIndex}` });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const dragHandleProps = { ...attributes, ...listeners };
return (
<div ref={setNodeRef} style={style}>
{renderFieldCard(field, fieldIndex, dragHandleProps)}
</div>
);
}
function FieldsTabContent({
tab,
fields,
allFields,
renderFieldCard,
onReorderFields,
}: TabModuleProps) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const fieldIds = fields.map((f) => {
const idx = allFields.findIndex((af) => af === f);
return `field-${idx}`;
});
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = fieldIds.indexOf(active.id as string);
const newIndex = fieldIds.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
const newTabFields = arrayMove(fields, oldIndex, newIndex);
// Rebuild allFields preserving order of other tabs
const otherFields = allFields.filter((f) => f.tabId !== tab.id);
const updatedAllFields = [...otherFields, ...newTabFields];
// Recalculate displayOrder for all fields in this tab (immutable)
const reorderedTabFields = newTabFields.map((f, i) => ({ ...f, displayOrder: i }));
const finalAllFields = [...otherFields, ...reorderedTabFields];
onReorderFields?.(finalAllFields);
};
return (
<div className="space-y-3">
{fields.length === 0 ? (
<div className="text-center py-4 text-muted-foreground text-sm border-dashed border rounded">
Нет полей на этой вкладке.
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={fieldIds}
strategy={verticalListSortingStrategy}
>
{fields.map((field) => {
const fieldIndex = allFields.findIndex((f) => f === field);
return (
<SortableFieldItem
key={`field-${fieldIndex}`}
field={field}
fieldIndex={fieldIndex}
renderFieldCard={renderFieldCard}
/>
);
})}
</SortableContext>
</DndContext>
)}
</div>
);
}
const fieldsTabModule: TabModule = {
type: 'fields',
label: 'Вкладка с полями',
icon: LayoutList,
defaultConfig: (): Partial<FormTab> => ({
type: 'fields',
tableColumns: undefined,
}),
TabContent: FieldsTabContent,
};
registerTabModule(fieldsTabModule);
export default fieldsTabModule;

View File

@@ -0,0 +1,423 @@
import { Component, useState, useEffect, ComponentType, ReactNode } from 'react';
import * as ReactLib from 'react';
import type { TransformResult } from '@babel/standalone';
import { useLocation } from 'wouter';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogTrigger } from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Loader2, AlertTriangle, Code2, RefreshCw } from 'lucide-react';
import { apiRequest, queryClient } from '@/lib/queryClient';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/use-toast';
import { SafeHtmlRenderer } from '@/components/SafeHtmlRenderer';
import { stripHtml } from '@/lib/htmlUtils';
import { useEvents } from '@/hooks/useEvents';
export interface JsComponentRendererProps {
code: string;
task?: Record<string, unknown>;
fieldValues?: Record<string, unknown>;
extraCtx?: Record<string, unknown>;
}
interface BabelInstance {
transform(code: string, options?: Record<string, unknown>): TransformResult;
}
type CtxUiLib = {
// Card family
Card: typeof Card;
CardContent: typeof CardContent;
CardHeader: typeof CardHeader;
CardTitle: typeof CardTitle;
CardDescription: typeof CardDescription;
CardFooter: typeof CardFooter;
// Misc
Button: typeof Button;
Badge: typeof Badge;
Input: typeof Input;
Label: typeof Label;
Progress: typeof Progress;
Separator: typeof Separator;
ScrollArea: typeof ScrollArea;
Switch: typeof Switch;
Textarea: typeof Textarea;
Alert: typeof Alert;
AlertDescription: typeof AlertDescription;
AlertTitle: typeof AlertTitle;
// Table family
Table: typeof Table;
TableBody: typeof TableBody;
TableCell: typeof TableCell;
TableHead: typeof TableHead;
TableHeader: typeof TableHeader;
TableRow: typeof TableRow;
// Select family
Select: typeof Select;
SelectContent: typeof SelectContent;
SelectItem: typeof SelectItem;
SelectTrigger: typeof SelectTrigger;
SelectValue: typeof SelectValue;
// Tabs family
Tabs: typeof Tabs;
TabsContent: typeof TabsContent;
TabsList: typeof TabsList;
TabsTrigger: typeof TabsTrigger;
// Dialog family
Dialog: typeof Dialog;
DialogContent: typeof DialogContent;
DialogHeader: typeof DialogHeader;
DialogTitle: typeof DialogTitle;
DialogDescription: typeof DialogDescription;
DialogFooter: typeof DialogFooter;
DialogTrigger: typeof DialogTrigger;
// Checkbox
Checkbox: typeof Checkbox;
// Popover family
Popover: typeof Popover;
PopoverContent: typeof PopoverContent;
PopoverTrigger: typeof PopoverTrigger;
};
interface CtxRequestOptions {
url: string;
method?: string;
data?: unknown;
}
interface CtxObject {
libs: {
React: typeof ReactLib;
ui: CtxUiLib;
SafeHtml: typeof SafeHtmlRenderer;
useEvents: typeof useEvents;
};
request(opts: CtxRequestOptions): Promise<unknown>;
navigate(path: string): void;
auth: { user: unknown };
task: Record<string, unknown> | null;
fieldValues: Record<string, unknown>;
fieldValuesHtml: Record<string, unknown>;
setFieldValue(fieldCode: string, value: unknown): Promise<void>;
setFieldValues(record: Record<string, unknown>): Promise<void>;
refreshTask(): void;
message: {
success(msg: string): void;
error(msg: string): void;
info(msg: string): void;
};
}
interface ErrorBoundaryProps {
children: ReactNode;
onError?: (err: Error) => void;
}
interface ErrorBoundaryState {
error: Error | null;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error): void {
this.props.onError?.(error);
}
render(): ReactNode {
if (this.state.error) {
return (
<Card className="border-destructive">
<CardHeader className="pb-2">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-4 h-4" />
<CardTitle className="text-sm">Ошибка в JS компоненте</CardTitle>
</div>
</CardHeader>
<CardContent>
<pre className="text-xs text-destructive bg-destructive/10 p-3 rounded-md overflow-auto whitespace-pre-wrap">
{this.state.error.message}
</pre>
</CardContent>
</Card>
);
}
return this.props.children;
}
}
// Module-level Babel singleton — loaded once per page, bundled only (no CDN fallback)
let babelPromise: Promise<BabelInstance> | null = null;
function loadBabel(): Promise<BabelInstance> {
if (babelPromise) return babelPromise;
babelPromise = import('@babel/standalone').then((mod) => {
const candidate =
typeof (mod as { default?: BabelInstance }).default?.transform === 'function'
? (mod as { default: BabelInstance }).default
: typeof (mod as unknown as BabelInstance).transform === 'function'
? (mod as unknown as BabelInstance)
: null;
if (!candidate) {
babelPromise = null;
throw new Error('Не удалось найти Babel.transform в загруженном модуле');
}
return candidate;
}).catch((err: unknown) => {
babelPromise = null;
throw err instanceof Error ? err : new Error(String(err));
});
return babelPromise;
}
function buildCtx(
user: unknown,
task: Record<string, unknown> | null | undefined,
fieldValues: Record<string, unknown> | undefined,
toast: ReturnType<typeof useToast>['toast'],
navigate: (path: string) => void,
extraCtx?: Record<string, unknown>,
): CtxObject {
const ui: CtxUiLib = {
Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter,
Button, Badge, Input, Label,
Progress, Separator, ScrollArea, Switch, Textarea,
Alert, AlertDescription, AlertTitle,
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
Tabs, TabsContent, TabsList, TabsTrigger,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogTrigger,
Checkbox,
Popover, PopoverContent, PopoverTrigger,
};
const rawFieldValues = fieldValues ?? {};
const strippedFieldValues: Record<string, unknown> = {};
for (const [k, v] of Object.entries(rawFieldValues)) {
strippedFieldValues[k] = typeof v === 'string' ? stripHtml(v) : v;
}
const taskId = task?.id as number | undefined;
const setFieldValue = async (fieldCode: string, value: unknown): Promise<void> => {
if (!taskId) {
toast({ title: 'Невозможно сохранить: ID задачи не найден', variant: 'destructive' });
return;
}
const fieldsArr = (extraCtx?.fields as Array<{ id: number; code: string }> | undefined) ?? [];
const field = fieldsArr.find(f => f.code === fieldCode);
if (!field) {
toast({ title: `Поле «${fieldCode}» не найдено`, variant: 'destructive' });
return;
}
await apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${field.id}`, { value });
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] });
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'audit-log'] });
};
const setFieldValues = async (record: Record<string, unknown>): Promise<void> => {
if (!taskId) {
toast({ title: 'Невозможно сохранить: ID задачи не найден', variant: 'destructive' });
return;
}
const fieldsArr = (extraCtx?.fields as Array<{ id: number; code: string }> | undefined) ?? [];
const entries = Object.entries(record);
const missing = entries.filter(([code]) => !fieldsArr.find(f => f.code === code)).map(([code]) => code);
if (missing.length > 0) {
toast({ title: `Поля не найдены: ${missing.join(', ')}`, variant: 'destructive' });
return;
}
const body: Record<string, unknown> = {};
for (const [code, value] of entries) {
const field = fieldsArr.find(f => f.code === code)!;
body[String(field.id)] = value;
}
await apiRequest('PATCH', `/api/tasks/${taskId}/field-values`, body);
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] });
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'audit-log'] });
};
const refreshTask = (): void => {
if (!taskId) return;
queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] });
};
return {
libs: { React: ReactLib, ui, SafeHtml: SafeHtmlRenderer, useEvents },
request: async ({ url, method = 'GET', data }: CtxRequestOptions): Promise<unknown> => {
const res = await apiRequest(method.toUpperCase(), url, data);
return res.json();
},
navigate,
auth: { user },
task: task ?? null,
fieldValues: strippedFieldValues,
fieldValuesHtml: rawFieldValues,
setFieldValue,
setFieldValues,
refreshTask,
message: {
success: (msg: string) => toast({ title: msg }),
error: (msg: string) => toast({ title: msg, variant: 'destructive' }),
info: (msg: string) => toast({ title: msg }),
},
...(extraCtx ?? {}),
} as CtxObject;
}
type WidgetComponent = ComponentType<{ ctx: CtxObject }>;
const EmptyCodePlaceholder: WidgetComponent = () => (
<Card>
<CardContent className="py-8 flex flex-col items-center justify-center text-muted-foreground">
<Code2 className="w-8 h-8 mb-2 opacity-30" />
<p className="text-sm">Код компонента не задан</p>
<p className="text-xs mt-1">Добавьте JS-код в редакторе модуля</p>
</CardContent>
</Card>
);
function evalJsModule(babel: BabelInstance, code: string, ctx: CtxObject): WidgetComponent {
if (!code.trim()) return EmptyCodePlaceholder;
const result = babel.transform(code, {
presets: [['react', { runtime: 'classic' }]],
filename: 'widget.jsx',
parserOpts: { allowReturnOutsideFunction: true },
});
const transformed = result.code ?? code;
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const factory = new Function('ctx', `"use strict";\n${transformed}`);
const component: unknown = factory(ctx);
if (typeof component === 'function') return component as WidgetComponent;
const Empty: WidgetComponent = () => null;
return Empty;
}
export function JsComponentRenderer({ code, task, fieldValues, extraCtx }: JsComponentRendererProps) {
const { user } = useAuth();
const { toast } = useToast();
const [, setLocation] = useLocation();
const [loadError, setLoadError] = useState<string | null>(null);
const [runtimeError, setRuntimeError] = useState<string | null>(null);
const [RenderedComponent, setRenderedComponent] = useState<WidgetComponent | null>(null);
const [babel, setBabel] = useState<BabelInstance | null>(null);
const [key, setKey] = useState(0);
const prevCodeRef = ReactLib.useRef(code);
useEffect(() => {
let cancelled = false;
loadBabel()
.then((b) => { if (!cancelled) setBabel(b); })
.catch((e: unknown) => { if (!cancelled) setLoadError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!babel || !code?.trim()) return;
if (prevCodeRef.current !== code) {
prevCodeRef.current = code;
setKey((k) => k + 1);
}
const ctx = buildCtx(user, task ?? null, fieldValues, toast, setLocation, extraCtx);
try {
const comp = evalJsModule(babel, code, ctx);
setRenderedComponent(() => comp);
setRuntimeError(null);
} catch (e: unknown) {
setRuntimeError(e instanceof Error ? e.message : String(e));
setRenderedComponent(null);
}
}, [code, babel, user, task, fieldValues, toast]);
if (!babel && !loadError) {
return (
<Card>
<CardContent className="py-8 flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">Загрузка рантайма...</span>
</CardContent>
</Card>
);
}
const errorMsg = loadError ?? runtimeError;
if (errorMsg) {
const retry = () => {
setLoadError(null);
setRuntimeError(null);
babelPromise = null;
setBabel(null);
loadBabel()
.then((b) => setBabel(b))
.catch((e: unknown) => setLoadError(e instanceof Error ? e.message : String(e)));
setKey((k) => k + 1);
};
return (
<Card className="border-destructive/50">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-4 h-4" />
<CardTitle className="text-sm">Ошибка JS компонента</CardTitle>
</div>
<Button variant="outline" size="sm" onClick={retry}>
<RefreshCw className="w-3 h-3 mr-1" />
Повторить
</Button>
</div>
</CardHeader>
<CardContent>
<pre className="text-xs text-destructive bg-destructive/10 p-3 rounded-md overflow-auto whitespace-pre-wrap max-h-40">
{errorMsg}
</pre>
</CardContent>
</Card>
);
}
if (!RenderedComponent) {
return (
<Card>
<CardContent className="py-8 flex flex-col items-center justify-center text-muted-foreground">
<Code2 className="w-10 h-10 mb-3 opacity-30" />
<p className="text-sm">JS компонент не возвращает функцию</p>
<p className="text-xs mt-1 text-center max-w-xs">
Убедитесь, что код заканчивается на{' '}
<code className="bg-muted px-1 rounded">return function Widget() {'{ ... }'}</code>
</p>
</CardContent>
</Card>
);
}
const ctx = buildCtx(user, task ?? null, fieldValues, toast, setLocation, extraCtx);
return (
<ErrorBoundary key={key} onError={(e) => setRuntimeError(e.message)}>
<RenderedComponent ctx={ctx} />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,240 @@
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;

View File

@@ -0,0 +1,122 @@
import { useQuery } from '@tanstack/react-query';
import { getTabModule, getAllTabModules } from './registry';
import { DeclarativeTabRenderer } from './DeclarativeTabRenderer';
import type { FormTab, FormField, TableColumnDef } from './types';
interface TabRendererProps {
tab: FormTab;
tabIndex: number;
fields: FormField[];
allFields: FormField[];
formId: string;
onUpdateTab: (index: number, updates: Partial<FormTab>) => void;
onAddField: (tabId?: number) => void;
onUpdateField: (index: number, updates: Partial<FormField>) => void;
onRemoveField: (index: number) => void;
renderFieldCard: (field: FormField, fieldIndex: number, dragHandleProps?: any) => React.ReactNode;
onReorderFields?: (fields: FormField[]) => void;
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;
}
interface TabModuleInfo {
id?: number;
type: string;
label: string;
icon: string;
isBuiltIn: boolean;
hasFile: boolean;
config?: any;
isActive?: boolean;
}
export function TabRenderer({
tab,
tabIndex,
fields,
allFields,
formId,
onUpdateTab,
onAddField,
onUpdateField,
onRemoveField,
renderFieldCard,
onReorderFields,
dataTables,
onAddColumn,
onUpdateColumn,
onRemoveColumn
}: TabRendererProps) {
const { data: modulesData } = useQuery<{ success: boolean; modules: TabModuleInfo[] }>({
queryKey: ['/api/tab-modules'],
staleTime: 60000,
});
const module = getTabModule(tab.type);
if (module) {
const Content = module.TabContent;
return (
<Content
tab={tab}
tabIndex={tabIndex}
fields={fields}
allFields={allFields}
formId={formId}
onUpdateTab={onUpdateTab}
onAddField={onAddField}
onUpdateField={onUpdateField}
onRemoveField={onRemoveField}
renderFieldCard={renderFieldCard}
onReorderFields={onReorderFields}
{...(tab.type === 'table' ? { dataTables, onAddColumn, onUpdateColumn, onRemoveColumn } : {})}
/>
);
}
const customModule = modulesData?.modules?.find(m => m.type === tab.type && !m.isBuiltIn);
if (customModule && customModule.config) {
return <DeclarativeTabRenderer config={customModule.config} />;
}
return (
<div className="text-center py-4 text-muted-foreground text-sm">
Неизвестный тип вкладки: {tab.type}
</div>
);
}
export function getTabTypeOptions() {
return getAllTabModules().map(m => ({
value: m.type,
label: m.label,
icon: m.icon,
}));
}
export function useTabTypeOptions() {
const { data: modulesData } = useQuery<{ success: boolean; modules: TabModuleInfo[] }>({
queryKey: ['/api/tab-modules'],
staleTime: 60000,
});
const builtInOptions = getAllTabModules().map(m => ({
value: m.type,
label: m.label,
icon: m.icon,
isBuiltIn: true,
}));
const customOptions = (modulesData?.modules || [])
.filter(m => !m.isBuiltIn && m.isActive !== false)
.map(m => ({
value: m.type,
label: m.label,
icon: m.icon,
isBuiltIn: false,
}));
return [...builtInOptions, ...customOptions];
}

View File

@@ -0,0 +1,7 @@
import './FieldsTab';
import './RegularTableTab';
export * from './types';
export * from './registry';
export { TabRenderer, getTabTypeOptions, useTabTypeOptions } from './TabRenderer';
export { DeclarativeTabRenderer } from './DeclarativeTabRenderer';

View File

@@ -0,0 +1,23 @@
import type { TabModule } from './types';
const tabModules: Map<string, TabModule> = new Map();
export function registerTabModule(module: TabModule): void {
tabModules.set(module.type, module);
}
export function getTabModule(type: string): TabModule | undefined {
return tabModules.get(type);
}
export function getAllTabModules(): TabModule[] {
return Array.from(tabModules.values());
}
export function getTabTypes(): Array<{ value: string; label: string; icon: React.ComponentType<{ className?: string }> }> {
return getAllTabModules().map(m => ({
value: m.type,
label: m.label,
icon: m.icon,
}));
}

View File

@@ -0,0 +1,75 @@
export interface TableColumnDef {
id: string;
name: string;
type: string;
width: number;
options?: Array<{ label: string; value: string } | string>;
dataTableId?: number;
displayColumn?: string;
linkedFormId?: number;
formula?: string;
aggregateFormula?: string;
}
export interface FormTab {
id?: number;
formId?: number;
name: string;
code: string;
type: string;
position: number;
displayOrder?: number;
tableColumns?: TableColumnDef[];
}
export interface FormField {
id?: number;
name: string;
code: string;
type: string;
isRequired: boolean;
placeholder?: string;
defaultValue?: string;
validationRules?: any;
options?: any;
position: number;
displayOrder?: number;
tabId?: number | null;
linkedFormId?: number | null;
buttonActionType?: 'open_url' | 'create_task' | null;
buttonUrl?: string | null;
buttonFormId?: number | null;
companyAutofill?: Record<string, string>;
autoProlongation?: boolean;
autoProlongationLabel?: string;
}
export interface TabModuleProps {
tab: FormTab;
tabIndex: number;
fields: FormField[];
allFields: FormField[];
formId: string;
onUpdateTab: (index: number, updates: Partial<FormTab>) => void;
onAddField: (tabId?: number) => void;
onUpdateField: (index: number, updates: Partial<FormField>) => void;
onRemoveField: (index: number) => void;
renderFieldCard: (field: FormField, fieldIndex: number, dragHandleProps?: any) => React.ReactNode;
onReorderFields?: (fields: FormField[]) => void;
}
export interface TabConfigProps {
tab: FormTab;
tabIndex: number;
onUpdateTab: (index: number, updates: Partial<FormTab>) => void;
dataTables?: Array<{ id: number; name: string; columns?: Array<{ name: string }> }>;
}
export interface TabModule {
type: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
defaultConfig: () => Partial<FormTab>;
TabContent: React.ComponentType<TabModuleProps>;
TabConfig?: React.ComponentType<TabConfigProps>;
}