From f560facabd7ffb17e84e23dc6cb12fe37debaa85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Wed, 15 Jul 2026 09:58:07 +0300 Subject: [PATCH] fix: MedSchedule ESM import for Router and data table links resolution --- .../components/SubdirectoryFieldConfig.tsx | 210 ++++++++++++++ client/src/pages/FormEditor.tsx | 79 +---- client/src/pages/TableEditor.tsx | 272 +++++++++++++++++- migrations/0060_data_table_links.sql | 17 ++ server/medschedule/routes.ts | 3 +- server/routes/data-tables.routes.ts | 145 ++++++++++ server/storage/data-tables-core.storage.ts | 98 ++++++- shared/schema.ts | 43 +++ 8 files changed, 791 insertions(+), 76 deletions(-) create mode 100644 client/src/components/SubdirectoryFieldConfig.tsx create mode 100644 migrations/0060_data_table_links.sql diff --git a/client/src/components/SubdirectoryFieldConfig.tsx b/client/src/components/SubdirectoryFieldConfig.tsx new file mode 100644 index 0000000..9056edb --- /dev/null +++ b/client/src/components/SubdirectoryFieldConfig.tsx @@ -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; +} + +interface SubdirectoryFieldConfigProps { + field: FormFieldTable; + index: number; + tablesData?: { tables: Array<{ id: number; name: string; columns: any[] }> }; + updateField: (index: number, updates: Partial) => 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 ( +
+
+ updateField(index, { allowMultiple: !!checked })} + data-testid={`checkbox-field-table-allow-multiple-${index}`} + /> + +
+ +
+
+ + +
+ + {tableId && ( +
+ + +
+ )} +
+ + {tableId && ( +
+
+ + +
+ + {showSubdirectory && ( +
+ + + {selectedSubdirectory && ( +

+ Фильтр: «{selectedTable?.columns?.[selectedSubdirectory.parentColumnIndex ?? 0]?.name || '?'}» = «{selectedSubdirectory.childTable?.columns?.[selectedSubdirectory.childColumnIndex ?? 0]?.name || '?'}» +

+ )} +
+ )} +
+ )} +
+ ); +} diff --git a/client/src/pages/FormEditor.tsx b/client/src/pages/FormEditor.tsx index b2dbf09..6291545 100644 --- a/client/src/pages/FormEditor.tsx +++ b/client/src/pages/FormEditor.tsx @@ -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 = () => { )} {field.type === 'table' && ( -
-
- updateField(index, { allowMultiple: !!checked })} - data-testid={`checkbox-field-table-allow-multiple-${index}`} - /> - -
-
-
- - -
- {field.options?.tableId && ( -
- - -
- )} -
-
+ )} {/* Настройки для связи с задачей */} diff --git a/client/src/pages/TableEditor.tsx b/client/src/pages/TableEditor.tsx index 8d139b3..130393e 100644 --- a/client/src/pages/TableEditor.tsx +++ b/client/src/pages/TableEditor.tsx @@ -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(null); const [editorValues, setEditorValues] = useState([]); const [treeSearchQuery, setTreeSearchQuery] = useState(''); + const [subdirectoriesOpen, setSubdirectoriesOpen] = useState(false); + const [editingSubdirectory, setEditingSubdirectory] = useState(null); + const [subdirectoryChildTableId, setSubdirectoryChildTableId] = useState('none'); + const [subdirectoryParentColumnIndex, setSubdirectoryParentColumnIndex] = useState(0); + const [subdirectoryChildColumnIndex, setSubdirectoryChildColumnIndex] = useState(0); + const [subdirectoryParentValues, setSubdirectoryParentValues] = useState>(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 && ( <> + + + + + ))} + + + )} + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ {displayRows.length === 0 ? ( +
Нет строк в родительском справочнике
+ ) : ( + displayRows.map((row) => { + const value = row.values[subdirectoryParentColumnIndex] ?? ''; + if (!value) return null; + return ( + + ); + }) + )} +
+

+ Подсправочник будет доступен в форме только при выборе этих значений из основного справочника. +

+
+
+ + + + {editingSubdirectory && ( + + )} + + + + + ); } diff --git a/migrations/0060_data_table_links.sql b/migrations/0060_data_table_links.sql new file mode 100644 index 0000000..0394a91 --- /dev/null +++ b/migrations/0060_data_table_links.sql @@ -0,0 +1,17 @@ +-- data_table_links: связи между справочниками (родитель → подсправочник) +CREATE TABLE IF NOT EXISTS data_table_links ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + parent_table_id INTEGER NOT NULL REFERENCES data_tables(id) ON DELETE CASCADE, + child_table_id INTEGER NOT NULL REFERENCES data_tables(id) ON DELETE CASCADE, + parent_column_index INTEGER NOT NULL DEFAULT 0, + child_column_index INTEGER NOT NULL DEFAULT 0, + parent_values JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS data_table_links_parent_idx ON data_table_links(parent_table_id); +CREATE INDEX IF NOT EXISTS data_table_links_child_idx ON data_table_links(child_table_id); +CREATE UNIQUE INDEX IF NOT EXISTS data_table_links_parent_child_unique + ON data_table_links(parent_table_id, child_table_id); diff --git a/server/medschedule/routes.ts b/server/medschedule/routes.ts index 88722e3..144440e 100644 --- a/server/medschedule/routes.ts +++ b/server/medschedule/routes.ts @@ -1,3 +1,4 @@ +import { Router } from "express"; import type { Express, Request, Response, NextFunction } from "express"; import type { Response as ExpressResponse } from "express"; import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; @@ -52,7 +53,7 @@ function parseDate(value: unknown): Date | null { } export function registerMedScheduleRoutes(app: Express) { - const router = require("express").Router(); + const router = Router(); router.use(authenticateToken); router.use(tenantIsolation); router.use(medOrgResolver()); diff --git a/server/routes/data-tables.routes.ts b/server/routes/data-tables.routes.ts index 170da10..937529f 100644 --- a/server/routes/data-tables.routes.ts +++ b/server/routes/data-tables.routes.ts @@ -707,6 +707,151 @@ export function registerDataTableRoutes(app: import("express").Express): void { } ); + // ===================================================== + // DATA TABLE LINKS API (подсправочники) + // ===================================================== + + // Get subdirectories for a parent table + app.get(['/api/tables/:id/subdirectories', '/api/directories/:id/subdirectories'], + authenticateToken, + tenantIsolation, + async (req: AuthenticatedRequest, res) => { + try { + const tableId = parseInt(req.params.id); + const table = await storage.getDataTable(tableId, req.organizationId!); + if (!table) { + return res.status(404).json({ error: 'Таблица не найдена' }); + } + + const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!); + if (!role) { + return res.status(403).json({ error: 'Нет доступа к таблице' }); + } + + const links = await storage.getDataTableLinks(tableId, req.organizationId!); + res.json({ subdirectories: links }); + } catch (error) { + console.error('Get subdirectories error:', error); + res.status(500).json({ error: 'Ошибка получения подсправочников' }); + } + } + ); + + // Create a subdirectory link + app.post(['/api/tables/:id/subdirectories', '/api/directories/:id/subdirectories'], + authenticateToken, + tenantIsolation, + async (req: AuthenticatedRequest, res) => { + try { + const tableId = parseInt(req.params.id); + const table = await storage.getDataTable(tableId, req.organizationId!); + if (!table) { + return res.status(404).json({ error: 'Таблица не найдена' }); + } + + const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!); + if (role !== 'admin') { + return res.status(403).json({ error: 'Нет прав для управления таблицей' }); + } + + const { childTableId, parentColumnIndex, childColumnIndex, parentValues } = req.body; + if (!childTableId || typeof childTableId !== 'number') { + return res.status(400).json({ error: 'Необходимо указать подсправочник (childTableId)' }); + } + + const childTable = await storage.getDataTable(childTableId, req.organizationId!); + if (!childTable) { + return res.status(404).json({ error: 'Подсправочник не найден' }); + } + + const link = await storage.createDataTableLink({ + organizationId: req.organizationId!, + parentTableId: tableId, + childTableId, + parentColumnIndex: parentColumnIndex ?? 0, + childColumnIndex: childColumnIndex ?? 0, + parentValues: Array.isArray(parentValues) ? parentValues : [], + }); + + res.status(201).json({ link }); + } catch (error: any) { + console.error('Create subdirectory error:', error); + res.status(400).json({ error: error.message || 'Ошибка создания подсправочника' }); + } + } + ); + + // Update a subdirectory link + app.patch(['/api/tables/:id/subdirectories/:linkId', '/api/directories/:id/subdirectories/:linkId'], + authenticateToken, + tenantIsolation, + async (req: AuthenticatedRequest, res) => { + try { + const tableId = parseInt(req.params.id); + const linkId = parseInt(req.params.linkId); + const table = await storage.getDataTable(tableId, req.organizationId!); + if (!table) { + return res.status(404).json({ error: 'Таблица не найдена' }); + } + + const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!); + if (role !== 'admin') { + return res.status(403).json({ error: 'Нет прав для управления таблицей' }); + } + + const existing = await storage.getDataTableLink(linkId, req.organizationId!); + if (!existing || existing.parentTableId !== tableId) { + return res.status(404).json({ error: 'Связь не найдена' }); + } + + const { childTableId, parentColumnIndex, childColumnIndex, parentValues } = req.body; + const updates: Partial = {}; + if (childTableId !== undefined) updates.childTableId = childTableId; + if (parentColumnIndex !== undefined) updates.parentColumnIndex = parentColumnIndex; + if (childColumnIndex !== undefined) updates.childColumnIndex = childColumnIndex; + if (parentValues !== undefined) updates.parentValues = Array.isArray(parentValues) ? parentValues : []; + + const link = await storage.updateDataTableLink(linkId, req.organizationId!, updates); + res.json({ link }); + } catch (error: any) { + console.error('Update subdirectory error:', error); + res.status(400).json({ error: error.message || 'Ошибка обновления подсправочника' }); + } + } + ); + + // Delete a subdirectory link + app.delete(['/api/tables/:id/subdirectories/:linkId', '/api/directories/:id/subdirectories/:linkId'], + authenticateToken, + tenantIsolation, + async (req: AuthenticatedRequest, res) => { + try { + const tableId = parseInt(req.params.id); + const linkId = parseInt(req.params.linkId); + const table = await storage.getDataTable(tableId, req.organizationId!); + if (!table) { + return res.status(404).json({ error: 'Таблица не найдена' }); + } + + const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!); + if (role !== 'admin') { + return res.status(403).json({ error: 'Нет прав для управления таблицей' }); + } + + const existing = await storage.getDataTableLink(linkId, req.organizationId!); + if (!existing || existing.parentTableId !== tableId) { + return res.status(404).json({ error: 'Связь не найдена' }); + } + + await storage.deleteDataTableLink(linkId, req.organizationId!); + res.json({ success: true }); + } catch (error: any) { + console.error('Delete subdirectory error:', error); + res.status(400).json({ error: error.message || 'Ошибка удаления подсправочника' }); + } + } + ); + // Log data table Excel/CSV export app.post(['/api/tables/:tableId/export', '/api/directories/:tableId/export'], authenticateToken, diff --git a/server/storage/data-tables-core.storage.ts b/server/storage/data-tables-core.storage.ts index 0c6f795..faf7878 100644 --- a/server/storage/data-tables-core.storage.ts +++ b/server/storage/data-tables-core.storage.ts @@ -1,4 +1,4 @@ -import { users, dataTables, dataTableRows, dataTablePermissions, dataTableAccessRules, formFields, forms, formAccessRules, roleMembers, type DataTable, type DataTableRow, type DataTablePermission, type InsertDataTable, type InsertDataTableRow, type InsertDataTablePermission, type DataTableFull, type DataTableAccessRule, type InsertDataTableAccessRule } from "@shared/schema"; +import { users, dataTables, dataTableRows, dataTablePermissions, dataTableAccessRules, dataTableLinks, formFields, forms, formAccessRules, roleMembers, type DataTable, type DataTableRow, type DataTablePermission, type InsertDataTable, type InsertDataTableRow, type InsertDataTablePermission, type DataTableFull, type DataTableAccessRule, type InsertDataTableAccessRule, type DataTableLink, type DataTableSubdirectory } from "@shared/schema"; import { db } from "../db"; import { eq, and, desc, sql, inArray } from "drizzle-orm"; import { BotsStorage } from "./bots.storage"; @@ -56,17 +56,53 @@ export class DataTablesCoreStorage extends BotsStorage { return tablesWithAccess; } + // ===================================================== + // DATA TABLE LINKS (подсправочники) + // ===================================================== + + async getDataTableLinks(parentTableId: number, organizationId: number): Promise { + const links = await db + .select({ + id: dataTableLinks.id, + organizationId: dataTableLinks.organizationId, + parentTableId: dataTableLinks.parentTableId, + childTableId: dataTableLinks.childTableId, + parentColumnIndex: dataTableLinks.parentColumnIndex, + childColumnIndex: dataTableLinks.childColumnIndex, + parentValues: dataTableLinks.parentValues, + createdAt: dataTableLinks.createdAt, + updatedAt: dataTableLinks.updatedAt, + childTable: { + id: dataTables.id, + name: dataTables.name, + columns: dataTables.columns, + }, + }) + .from(dataTableLinks) + .innerJoin(dataTables, eq(dataTableLinks.childTableId, dataTables.id)) + .where(and( + eq(dataTableLinks.parentTableId, parentTableId), + eq(dataTableLinks.organizationId, organizationId), + eq(dataTables.isDeleted, false) + )) + .orderBy(dataTableLinks.id); + + return links as DataTableSubdirectory[]; + } + async getDataTableFull(id: number, organizationId: number): Promise { const table = await this.getDataTable(id, organizationId); if (!table) return undefined; const rows = await this.getDataTableRows(id, organizationId); const permissions = await this.getDataTablePermissions(id, organizationId); + const subdirectories = await this.getDataTableLinks(id, organizationId); return { ...table, rows, permissions, + subdirectories, }; } @@ -312,6 +348,66 @@ export class DataTablesCoreStorage extends BotsStorage { .where(eq(dataTableRows.tableId, tableId)); } + // ===================================================== + // DATA TABLE LINKS (подсправочники) + // ===================================================== + + async getDataTableLink(id: number, organizationId: number): Promise { + const [link] = await db + .select() + .from(dataTableLinks) + .where(and( + eq(dataTableLinks.id, id), + eq(dataTableLinks.organizationId, organizationId) + )); + return link || undefined; + } + + async createDataTableLink(data: { organizationId: number; parentTableId: number; childTableId: number; parentColumnIndex?: number; childColumnIndex?: number; parentValues?: string[] }): Promise { + const [link] = await db + .insert(dataTableLinks) + .values({ + organizationId: data.organizationId, + parentTableId: data.parentTableId, + childTableId: data.childTableId, + parentColumnIndex: data.parentColumnIndex ?? 0, + childColumnIndex: data.childColumnIndex ?? 0, + parentValues: data.parentValues ?? [], + }) + .onConflictDoUpdate({ + target: [dataTableLinks.parentTableId, dataTableLinks.childTableId], + set: { + parentColumnIndex: data.parentColumnIndex ?? 0, + childColumnIndex: data.childColumnIndex ?? 0, + parentValues: data.parentValues ?? [], + updatedAt: new Date(), + }, + }) + .returning(); + return link; + } + + async updateDataTableLink(id: number, organizationId: number, updates: Partial): Promise { + const [link] = await db + .update(dataTableLinks) + .set({ ...updates, updatedAt: new Date() }) + .where(and( + eq(dataTableLinks.id, id), + eq(dataTableLinks.organizationId, organizationId) + )) + .returning(); + return link; + } + + async deleteDataTableLink(id: number, organizationId: number): Promise { + await db + .delete(dataTableLinks) + .where(and( + eq(dataTableLinks.id, id), + eq(dataTableLinks.organizationId, organizationId) + )); + } + async applyDataTableSync( tableId: number, deleted: { id: number }[], diff --git a/shared/schema.ts b/shared/schema.ts index a81d8a4..1e87ec3 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -1849,6 +1849,23 @@ export const dataTables = pgTable("data_tables", { folderIdx: index("data_tables_folder_idx").on(table.folderId), })); +// Data table links (подсправочники / связанные справочники) +export const dataTableLinks = pgTable("data_table_links", { + id: serial("id").primaryKey(), + organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + parentTableId: integer("parent_table_id").notNull().references(() => dataTables.id, { onDelete: "cascade" }), + childTableId: integer("child_table_id").notNull().references(() => dataTables.id, { onDelete: "cascade" }), + parentColumnIndex: integer("parent_column_index").notNull().default(0), + childColumnIndex: integer("child_column_index").notNull().default(0), + parentValues: jsonb("parent_values").notNull().$type().default([]), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), +}, (table) => ({ + parentIdx: index("data_table_links_parent_idx").on(table.parentTableId), + childIdx: index("data_table_links_child_idx").on(table.childTableId), + uniqueParentChild: unique("data_table_links_parent_child_unique").on(table.parentTableId, table.childTableId), +})); + // Directory/table folders table export const directoryFolders = pgTable("directory_folders", { id: serial("id").primaryKey(), @@ -1920,6 +1937,25 @@ export const dataTablesRelations = relations(dataTables, ({ one, many }) => ({ }), rows: many(dataTableRows), permissions: many(dataTablePermissions), + parentLinks: many(dataTableLinks, { relationName: "parentLinks" }), + childLinks: many(dataTableLinks, { relationName: "childLinks" }), +})); + +export const dataTableLinksRelations = relations(dataTableLinks, ({ one }) => ({ + organization: one(organizations, { + fields: [dataTableLinks.organizationId], + references: [organizations.id], + }), + parentTable: one(dataTables, { + fields: [dataTableLinks.parentTableId], + references: [dataTables.id], + relationName: "parentLinks", + }), + childTable: one(dataTables, { + fields: [dataTableLinks.childTableId], + references: [dataTables.id], + relationName: "childLinks", + }), })); export const directoryFoldersRelations = relations(directoryFolders, ({ one, many }) => ({ @@ -2040,6 +2076,8 @@ export type DataTable = typeof dataTables.$inferSelect; export type DataTableRow = typeof dataTableRows.$inferSelect; export type DataTablePermission = typeof dataTablePermissions.$inferSelect; export type DataTableAccessRule = typeof dataTableAccessRules.$inferSelect; +export type DataTableLink = typeof dataTableLinks.$inferSelect; +export type InsertDataTableLink = typeof dataTableLinks.$inferInsert; export type InsertDataTable = z.infer; export type InsertDataTableRow = z.infer; export type InsertDataTablePermission = z.infer; @@ -2051,6 +2089,10 @@ export type SetTablePermission = z.infer; export type SyncDataTable = z.infer; export type DiffDataTable = z.infer; +export type DataTableSubdirectory = DataTableLink & { + childTable?: { id: number; name: string; columns: DataTable['columns'] } | null; +}; + export type DataTableWithRows = DataTable & { rows: DataTableRow[]; }; @@ -2062,6 +2104,7 @@ export type DataTableWithPermissions = DataTable & { export type DataTableFull = DataTable & { rows: DataTableRow[]; permissions: (DataTablePermission & { user: { id: number; firstName: string; lastName: string; email: string } })[]; + subdirectories: DataTableSubdirectory[]; }; // ========================================