fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
934
client/src/components/tabs/DeclarativeTabRenderer.tsx
Normal file
934
client/src/components/tabs/DeclarativeTabRenderer.tsx
Normal 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;
|
||||
140
client/src/components/tabs/FieldsTab.tsx
Normal file
140
client/src/components/tabs/FieldsTab.tsx
Normal 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;
|
||||
423
client/src/components/tabs/JsComponentRenderer.tsx
Normal file
423
client/src/components/tabs/JsComponentRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
240
client/src/components/tabs/RegularTableTab.tsx
Normal file
240
client/src/components/tabs/RegularTableTab.tsx
Normal 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 Вариант 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;
|
||||
122
client/src/components/tabs/TabRenderer.tsx
Normal file
122
client/src/components/tabs/TabRenderer.tsx
Normal 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];
|
||||
}
|
||||
7
client/src/components/tabs/index.ts
Normal file
7
client/src/components/tabs/index.ts
Normal 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';
|
||||
23
client/src/components/tabs/registry.ts
Normal file
23
client/src/components/tabs/registry.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
75
client/src/components/tabs/types.ts
Normal file
75
client/src/components/tabs/types.ts
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user