fix: MedSchedule ESM import for Router and data table links resolution

This commit is contained in:
2026-07-15 09:58:07 +03:00
parent f519ce95db
commit f560facabd
8 changed files with 791 additions and 76 deletions

View File

@@ -0,0 +1,210 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Table, Link2 } from 'lucide-react';
import { apiRequest } from '@/lib/queryClient';
import type { DataTableSubdirectory } from '@shared/schema';
interface FormFieldTable {
id?: number;
type: string;
allowMultiple?: boolean;
options?: Record<string, any>;
}
interface SubdirectoryFieldConfigProps {
field: FormFieldTable;
index: number;
tablesData?: { tables: Array<{ id: number; name: string; columns: any[] }> };
updateField: (index: number, updates: Partial<FormFieldTable>) => void;
}
export default function SubdirectoryFieldConfig({
field,
index,
tablesData,
updateField,
}: SubdirectoryFieldConfigProps) {
const tableId = field.options?.tableId as number | undefined;
const { data: subdirectoriesData } = useQuery<{ subdirectories: DataTableSubdirectory[] }>({
queryKey: ['/api/directories', tableId, 'subdirectories'],
queryFn: async () => {
const res = await apiRequest('GET', `/api/directories/${tableId}/subdirectories`);
return res.json();
},
enabled: !!tableId,
staleTime: 60 * 1000,
});
const subdirectories = subdirectoriesData?.subdirectories || [];
const showSubdirectory = !!field.options?.showSubdirectory;
const selectedSubdirectoryId = field.options?.subdirectoryId as number | undefined;
const selectedSubdirectory = useMemo(() => {
return subdirectories.find((s) => s.id === selectedSubdirectoryId || s.childTableId === selectedSubdirectoryId);
}, [subdirectories, selectedSubdirectoryId]);
const selectedTable = useMemo(() => {
return tablesData?.tables?.find((t) => t.id === tableId);
}, [tablesData, tableId]);
const handleTableChange = (value: string) => {
if (value === 'none') {
updateField(index, {
options: {
...field.options,
tableId: undefined,
displayColumn: undefined,
showSubdirectory: undefined,
subdirectoryId: undefined,
},
});
return;
}
const newTableId = parseInt(value);
const newSelectedTable = tablesData?.tables?.find((t) => t.id === newTableId);
updateField(index, {
options: {
...field.options,
tableId: newTableId,
displayColumn: newSelectedTable?.columns?.[0]?.name || undefined,
showSubdirectory: undefined,
subdirectoryId: undefined,
},
});
};
const handleShowSubdirectoryChange = (checked: boolean) => {
updateField(index, {
options: {
...field.options,
showSubdirectory: checked,
subdirectoryId: checked ? field.options?.subdirectoryId : undefined,
},
});
};
const handleSubdirectoryChange = (value: string) => {
updateField(index, {
options: {
...field.options,
subdirectoryId: value === 'none' ? undefined : parseInt(value),
},
});
};
return (
<div className="mt-3 space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
checked={field.allowMultiple || false}
onCheckedChange={(checked) => updateField(index, { allowMultiple: !!checked })}
data-testid={`checkbox-field-table-allow-multiple-${index}`}
/>
<label className="text-sm font-medium">Разрешить множественный выбор</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Таблица</label>
<Select value={tableId?.toString() || 'none'} onValueChange={handleTableChange}>
<SelectTrigger data-testid={`select-field-table-${index}`}>
<SelectValue placeholder="Выберите таблицу" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Не выбрано</SelectItem>
{tablesData?.tables?.map((table) => (
<SelectItem key={table.id} value={table.id.toString()}>
<div className="flex items-center gap-2">
<Table className="w-4 h-4" />
{table.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{tableId && (
<div>
<label className="text-sm font-medium">Отображаемый столбец</label>
<Select
value={field.options?.displayColumn || 'none'}
onValueChange={(value) =>
updateField(index, {
options: { ...field.options, displayColumn: value === 'none' ? undefined : value },
})
}
>
<SelectTrigger data-testid={`select-field-column-${index}`}>
<SelectValue placeholder="Выберите столбец" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Не выбрано</SelectItem>
{selectedTable?.columns?.map((col: any, idx: number) => (
<SelectItem key={idx} value={col.name || `col-${idx}`}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{tableId && (
<div className="border rounded-md p-3 space-y-3 bg-muted/20">
<div className="flex items-center space-x-2">
<Checkbox
checked={showSubdirectory}
onCheckedChange={handleShowSubdirectoryChange}
data-testid={`checkbox-field-show-subdirectory-${index}`}
/>
<label className="text-sm font-medium flex items-center gap-1.5">
<Link2 className="w-3.5 h-3.5" />
Отображать подсправочник
</label>
</div>
{showSubdirectory && (
<div>
<Label className="text-xs">Подсправочник</Label>
<Select value={selectedSubdirectoryId?.toString() || 'none'} onValueChange={handleSubdirectoryChange}>
<SelectTrigger>
<SelectValue placeholder="Выберите подсправочник" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Не выбрано</SelectItem>
{subdirectories.length === 0 && (
<SelectItem value="no-subdirectories" disabled>
Нет привязанных подсправочников
</SelectItem>
)}
{subdirectories.map((sub) => (
<SelectItem key={sub.id} value={sub.id?.toString() || sub.childTableId?.toString() || ''}>
{sub.childTable?.name || `Справочник ${sub.childTableId}`}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedSubdirectory && (
<p className="text-xs text-muted-foreground mt-1">
Фильтр: «{selectedTable?.columns?.[selectedSubdirectory.parentColumnIndex ?? 0]?.name || '?'}» = «{selectedSubdirectory.childTable?.columns?.[selectedSubdirectory.childColumnIndex ?? 0]?.name || '?'}»
</p>
)}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -115,6 +115,7 @@ import { JsComponentRenderer } from '@/components/tabs/JsComponentRenderer';
import FormAccessTab from '@/components/FormAccessTab';
import { TitleTemplateBuilder } from '@/components/TitleTemplateBuilder';
import { FormulaInput } from '@/components/FormulaInput';
import SubdirectoryFieldConfig from '@/components/SubdirectoryFieldConfig';
import { detectCircularDependency } from '@/lib/formulaEngine';
import type { FormTab, TableColumnDef } from '@/components/tabs/types';
@@ -1812,78 +1813,12 @@ const FormEditor = () => {
</div>
)}
{field.type === 'table' && (
<div className="mt-3 space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
checked={field.allowMultiple || false}
onCheckedChange={(checked) => updateField(index, { allowMultiple: !!checked })}
data-testid={`checkbox-field-table-allow-multiple-${index}`}
/>
<label className="text-sm font-medium">Разрешить множественный выбор</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Таблица</label>
<Select
value={field.options?.tableId?.toString() || 'none'}
onValueChange={(value) => {
if (value === 'none') {
updateField(index, { options: { ...field.options, tableId: undefined, displayColumn: undefined } });
return;
}
const selectedTable = tablesData?.tables?.find(t => t.id === parseInt(value));
updateField(index, {
options: {
...field.options,
tableId: parseInt(value),
displayColumn: selectedTable?.columns?.[0]?.name || undefined
}
});
}}
>
<SelectTrigger data-testid={`select-field-table-${index}`}>
<SelectValue placeholder="Выберите таблицу" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Не выбрано</SelectItem>
{tablesData?.tables?.map((table) => (
<SelectItem key={table.id} value={table.id.toString()}>
<div className="flex items-center gap-2">
<Table className="w-4 h-4" />
{table.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{field.options?.tableId && (
<div>
<label className="text-sm font-medium">Отображаемый столбец</label>
<Select
value={field.options?.displayColumn || 'none'}
onValueChange={(value) => updateField(index, {
options: { ...field.options, displayColumn: value === 'none' ? undefined : value }
})}
>
<SelectTrigger data-testid={`select-field-column-${index}`}>
<SelectValue placeholder="Выберите столбец" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Не выбрано</SelectItem>
{tablesData?.tables
?.find(t => t.id === field.options?.tableId)
?.columns?.map((col: any, idx: number) => (
<SelectItem key={idx} value={col.name || `col-${idx}`}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
<SubdirectoryFieldConfig
field={field}
index={index}
tablesData={tablesData}
updateField={updateField}
/>
)}
{/* Настройки для связи с задачей */}

View File

@@ -25,16 +25,17 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { normalizeSelectOptions } from '@/lib/fieldOptions';
import {
ArrowLeft, Plus, Trash2, Save, Settings, Users, Download, Upload,
Check, X, Pencil, FileSpreadsheet, Globe, ListTree, GripVertical, Search, FileText
Check, X, Pencil, FileSpreadsheet, Globe, ListTree, GripVertical, Search, FileText, Link2
} from 'lucide-react';
import { FIELD_TYPE_DEFINITIONS } from '@shared/field-types';
import { apiRequest, queryClient } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import type { DataTableFull, DataTableRow, User } from '@shared/schema';
import type { DataTableFull, DataTableRow, DataTableSubdirectory, User } from '@shared/schema';
import { useColumnFilters } from '@/hooks/useColumnFilters';
import { ColumnFilter } from '@/components/ui/ColumnFilter';
import { ActiveFilterBadges } from '@/components/ui/ActiveFilterBadges';
@@ -110,6 +111,12 @@ export default function TableEditor() {
const [rowEditorGroupValue, setRowEditorGroupValue] = useState<string | null>(null);
const [editorValues, setEditorValues] = useState<string[]>([]);
const [treeSearchQuery, setTreeSearchQuery] = useState('');
const [subdirectoriesOpen, setSubdirectoriesOpen] = useState(false);
const [editingSubdirectory, setEditingSubdirectory] = useState<DataTableSubdirectory | null>(null);
const [subdirectoryChildTableId, setSubdirectoryChildTableId] = useState<string>('none');
const [subdirectoryParentColumnIndex, setSubdirectoryParentColumnIndex] = useState<number>(0);
const [subdirectoryChildColumnIndex, setSubdirectoryChildColumnIndex] = useState<number>(0);
const [subdirectoryParentValues, setSubdirectoryParentValues] = useState<Set<string>>(new Set());
const { filters, sort, setFilter, clearFilter, clearAll, setSort, clearSort, hasActiveFilters } = useColumnFilters();
@@ -171,6 +178,19 @@ export default function TableEditor() {
});
const fieldTemplatesList = fieldTemplatesData ?? [];
const { data: allTablesData } = useQuery<{ tables: DataTableFull[] }>({
queryKey: ['/api/directories'],
enabled: subdirectoriesOpen,
staleTime: 60 * 1000,
});
const allTables = allTablesData?.tables || [];
const { data: subdirectoriesData, refetch: refetchSubdirectories } = useQuery<{ subdirectories: DataTableSubdirectory[] }>({
queryKey: ['/api/directories', tableId, 'subdirectories'],
enabled: subdirectoriesOpen,
});
const subdirectories = subdirectoriesData?.subdirectories || [];
useEffect(() => {
if (data?.table) {
setTableName(data.table.name);
@@ -188,6 +208,104 @@ export default function TableEditor() {
}
}, [rowEditorOpen, rowEditorInitialValues]);
const createSubdirectoryMutation = useMutation({
mutationFn: async (payload: { childTableId: number; parentColumnIndex: number; childColumnIndex: number; parentValues: string[] }) => {
return apiRequest('POST', `/api/directories/${tableId}/subdirectories`, payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/directories', tableId, 'subdirectories'] });
queryClient.invalidateQueries({ queryKey: ['/api/directories', tableId] });
refetchSubdirectories();
resetSubdirectoryForm();
setSubdirectoriesOpen(false);
toast({ title: 'Подсправочник добавлен' });
},
onError: (error: any) => {
toast({ title: 'Ошибка добавления подсправочника', description: error.message, variant: 'destructive' });
},
});
const updateSubdirectoryMutation = useMutation({
mutationFn: async ({ linkId, payload }: { linkId: number; payload: { childTableId?: number; parentColumnIndex?: number; childColumnIndex?: number; parentValues?: string[] } }) => {
return apiRequest('PATCH', `/api/directories/${tableId}/subdirectories/${linkId}`, payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/directories', tableId, 'subdirectories'] });
queryClient.invalidateQueries({ queryKey: ['/api/directories', tableId] });
refetchSubdirectories();
resetSubdirectoryForm();
setEditingSubdirectory(null);
toast({ title: 'Подсправочник обновлён' });
},
onError: (error: any) => {
toast({ title: 'Ошибка обновления подсправочника', description: error.message, variant: 'destructive' });
},
});
const deleteSubdirectoryMutation = useMutation({
mutationFn: async (linkId: number) => {
return apiRequest('DELETE', `/api/directories/${tableId}/subdirectories/${linkId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/directories', tableId, 'subdirectories'] });
queryClient.invalidateQueries({ queryKey: ['/api/directories', tableId] });
refetchSubdirectories();
toast({ title: 'Подсправочник отвязан' });
},
onError: (error: any) => {
toast({ title: 'Ошибка удаления подсправочника', description: error.message, variant: 'destructive' });
},
});
const resetSubdirectoryForm = () => {
setSubdirectoryChildTableId('none');
setSubdirectoryParentColumnIndex(0);
setSubdirectoryChildColumnIndex(0);
setSubdirectoryParentValues(new Set());
setEditingSubdirectory(null);
};
const openSubdirectoryEditor = (link?: DataTableSubdirectory) => {
if (link) {
setEditingSubdirectory(link);
setSubdirectoryChildTableId(link.childTableId?.toString() || 'none');
setSubdirectoryParentColumnIndex(link.parentColumnIndex ?? 0);
setSubdirectoryChildColumnIndex(link.childColumnIndex ?? 0);
setSubdirectoryParentValues(new Set(link.parentValues || []));
} else {
resetSubdirectoryForm();
}
setSubdirectoriesOpen(true);
};
const handleSaveSubdirectory = () => {
const childId = parseInt(subdirectoryChildTableId);
if (isNaN(childId)) {
toast({ title: 'Выберите подсправочник', variant: 'destructive' });
return;
}
const payload = {
childTableId: childId,
parentColumnIndex: subdirectoryParentColumnIndex,
childColumnIndex: subdirectoryChildColumnIndex,
parentValues: Array.from(subdirectoryParentValues),
};
if (editingSubdirectory?.id) {
updateSubdirectoryMutation.mutate({ linkId: editingSubdirectory.id, payload });
} else {
createSubdirectoryMutation.mutate(payload);
}
};
const toggleParentValue = (value: string) => {
setSubdirectoryParentValues((prev) => {
const next = new Set(prev);
if (next.has(value)) next.delete(value);
else next.add(value);
return next;
});
};
const updateTableMutation = useMutation({
mutationFn: async (updates: { name?: string; description?: string; columns?: TableColumn[]; treeColumnIndex?: number | null; treeDisplayColumnIndex?: number | null }) => {
return apiRequest('PATCH', `/api/directories/${tableId}`, updates);
@@ -724,6 +842,10 @@ export default function TableEditor() {
)}
{canManage && (
<>
<Button variant="outline" onClick={() => openSubdirectoryEditor()} data-testid="button-subdirectories">
<Link2 className="w-4 h-4 mr-2" />
Подсправочники
</Button>
<Button variant="outline" onClick={() => setPermissionsOpen(true)} data-testid="button-permissions">
<Users className="w-4 h-4 mr-2" />
Права
@@ -1616,6 +1738,152 @@ export default function TableEditor() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={subdirectoriesOpen} onOpenChange={(open) => {
setSubdirectoriesOpen(open);
if (!open) resetSubdirectoryForm();
}}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingSubdirectory ? 'Редактировать подсправочник' : 'Подсправочники'}</DialogTitle>
<DialogDescription>
{editingSubdirectory
? 'Измените привязку подсправочника к родительскому справочнику'
: 'Свяжите отдельные справочники с этим справочником. В форме они будут появляться как дополнительный выбор после выбора значения из основного справочника.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{!editingSubdirectory && subdirectories.length > 0 && (
<div className="border rounded p-3 space-y-2">
<Label className="text-xs">Привязанные подсправочники</Label>
<div className="space-y-2">
{subdirectories.map((link) => (
<div key={link.id} className="flex items-center justify-between p-2 bg-muted/50 rounded">
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{link.childTable?.name || `ID ${link.childTableId}`}</div>
<div className="text-xs text-muted-foreground">
по столбцу «{columns[link.parentColumnIndex ?? 0]?.name || '?'}» =&gt; «{link.childTable?.columns?.[link.childColumnIndex ?? 0]?.name || '?'}»
{link.parentValues && link.parentValues.length > 0 && (
<span className="ml-1">({link.parentValues.length} значений)</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openSubdirectoryEditor(link)}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => link.id && deleteSubdirectoryMutation.mutate(link.id)}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
<div className="space-y-3">
<div>
<Label>Подсправочник</Label>
<Select value={subdirectoryChildTableId} onValueChange={setSubdirectoryChildTableId} disabled={!!editingSubdirectory}>
<SelectTrigger>
<SelectValue placeholder="Выберите справочник" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Не выбрано</SelectItem>
{allTables
.filter((t) => t.id !== tableId)
.map((t) => (
<SelectItem key={t.id} value={t.id.toString()}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Столбец родителя</Label>
<Select value={String(subdirectoryParentColumnIndex)} onValueChange={(v) => setSubdirectoryParentColumnIndex(parseInt(v))}>
<SelectTrigger>
<SelectValue placeholder="Столбец" />
</SelectTrigger>
<SelectContent>
{columns.map((col, idx) => (
<SelectItem key={idx} value={String(idx)}>{col.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Столбец подсправочника</Label>
<Select
value={String(subdirectoryChildColumnIndex)}
onValueChange={(v) => setSubdirectoryChildColumnIndex(parseInt(v))}
disabled={subdirectoryChildTableId === 'none'}
>
<SelectTrigger>
<SelectValue placeholder="Столбец" />
</SelectTrigger>
<SelectContent>
{allTables
.find((t) => t.id.toString() === subdirectoryChildTableId)
?.columns?.map((col: any, idx: number) => (
<SelectItem key={idx} value={String(idx)}>{col.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="mb-2 block">Значения родительского справочника</Label>
<div className="border rounded p-2 max-h-48 overflow-y-auto space-y-1">
{displayRows.length === 0 ? (
<div className="text-sm text-muted-foreground">Нет строк в родительском справочнике</div>
) : (
displayRows.map((row) => {
const value = row.values[subdirectoryParentColumnIndex] ?? '';
if (!value) return null;
return (
<label key={row.id} className="flex items-center gap-2 p-1.5 hover:bg-muted rounded cursor-pointer">
<Checkbox
checked={subdirectoryParentValues.has(String(value))}
onCheckedChange={() => toggleParentValue(String(value))}
/>
<span className="text-sm">{value}</span>
</label>
);
})
)}
</div>
<p className="text-xs text-muted-foreground mt-1">
Подсправочник будет доступен в форме только при выборе этих значений из основного справочника.
</p>
</div>
</div>
</div>
<DialogFooter>
{editingSubdirectory && (
<Button variant="outline" onClick={resetSubdirectoryForm}>
Назад к списку
</Button>
)}
<Button variant="outline" onClick={() => { setSubdirectoriesOpen(false); resetSubdirectoryForm(); }}>
Отмена
</Button>
<Button
onClick={handleSaveSubdirectory}
disabled={subdirectoryChildTableId === 'none' || createSubdirectoryMutation.isPending || updateSubdirectoryMutation.isPending}
>
{createSubdirectoryMutation.isPending || updateSubdirectoryMutation.isPending ? 'Сохранение...' : 'Сохранить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Layout>
);
}