import type { Express } from "express"; import { storage } from "../storage"; import { authenticateToken, requireFormAdminOrPermission, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import type { FormField, FormTab } from "@shared/schema"; type ColDef = { id: string; name: string; type: string; width?: number }; // DaData sub-field definitions for company autofill const DADATA_SUBFIELDS: Array<{ key: string; label: string; suffix: string }> = [ { key: "name.full_with_opf", label: "Полное наименование", suffix: "full_name" }, { key: "name.short_with_opf", label: "Краткое наименование", suffix: "short_name" }, { key: "inn", label: "ИНН", suffix: "inn" }, { key: "kpp", label: "КПП", suffix: "kpp" }, { key: "ogrn", label: "ОГРН", suffix: "ogrn" }, { key: "management.name", label: "ФИО руководителя", suffix: "director_name" }, { key: "management.post", label: "Должность руководителя", suffix: "director_post" }, ]; /** * Shift positions of existing fields that are >= targetPosition upward by 1, * so the new field can be inserted at targetPosition without collisions. */ async function shiftFieldPositions( existingFields: FormField[], formId: number, orgId: number, targetPosition: number ): Promise { const toShift = existingFields .filter((f) => f.position >= targetPosition) .sort((a, b) => b.position - a.position); for (const f of toShift) { await storage.updateFormField(f.id, formId, orgId, { position: f.position + 1 }); } } export function registerFieldTemplateInsertRoutes(app: Express): void { /** * POST /api/forms/:formId/fields/from-template * Вставляет шаблон поля в форму как обычное поле. * Body: { fieldTemplateId: number, position?: number, tabId?: number } * Код поля = ft_{fieldTemplateId}. Если уже есть — возвращает существующее. */ app.post( "/api/forms/:formId/fields/from-template", authenticateToken, requireFormAdminOrPermission('forms.manage', 'formId'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const formId = parseInt(req.params.formId); if (isNaN(formId)) { return res.status(400).json({ success: false, error: "Неверный ID формы" }); } const { fieldTemplateId, position, tabId } = req.body as { fieldTemplateId?: number; position?: number; tabId?: number; }; if (!fieldTemplateId || typeof fieldTemplateId !== "number") { return res.status(400).json({ success: false, error: "fieldTemplateId обязателен" }); } const orgId = req.organizationId!; const [form, fieldTemplate] = await Promise.all([ storage.getForm(formId, orgId), storage.getFieldTemplate(fieldTemplateId, orgId), ]); if (!form) { return res.status(404).json({ success: false, error: "Форма не найдена" }); } if (!fieldTemplate) { return res.status(404).json({ success: false, error: "Шаблон поля не найден" }); } const fieldCode = `ft_${fieldTemplate.code}`; const existingFields = await storage.getFormFields(formId, orgId); const existing = existingFields.find((f) => f.code === fieldCode); if (existing) { return res.json({ success: true, field: existing, alreadyExists: true }); } const maxPos = existingFields.length > 0 ? Math.max(...existingFields.map((f) => f.position)) : -1; const targetPosition = typeof position === "number" ? Math.max(0, position) : maxPos + 1; if (typeof position === "number") { await shiftFieldPositions(existingFields, formId, orgId, targetPosition); } let resolvedTabId: number | null = null; // Track requested tabId for client-side patching (negative IDs = unsaved tabs) const requestedTabId = typeof tabId === "number" ? tabId : null; if (typeof tabId === "number" && tabId >= 0) { const tab = await storage.getFormTab(tabId, formId, orgId); if (!tab) { return res.status(400).json({ success: false, error: "Указанная вкладка не принадлежит этой форме" }); } resolvedTabId = tabId; } // Build companyAutofill for form field (if company type with autofill configured) let formCompanyAutofill: Record | null = null; const createdSubFieldCodes: string[] = []; if (fieldTemplate.type === "company" && fieldTemplate.companyAutofill) { const templateAutofill = fieldTemplate.companyAutofill as Record; formCompanyAutofill = {}; for (const [dadataKey, suffix] of Object.entries(templateAutofill)) { const meta = DADATA_SUBFIELDS.find((s) => s.key === dadataKey); if (!meta) continue; const subCode = `ft_${fieldTemplate.code}_${suffix}`; // Create sub-field if it doesn't exist yet const subExists = existingFields.find((f) => f.code === subCode); if (!subExists) { await storage.createFormField({ formId, tabId: resolvedTabId, name: meta.label, code: subCode, type: "text", isRequired: false, placeholder: null, defaultValue: null, validationRules: null, options: null, position: targetPosition + 1 + createdSubFieldCodes.length, linkedFormId: null, buttonActionType: null, buttonUrl: null, buttonFormId: null, companyAutofill: null, taskRelationType: null, maxFileCount: null, maxFileSizeMB: null, } as Parameters[0]); createdSubFieldCodes.push(subCode); } formCompanyAutofill[dadataKey] = subCode; } } const newField = await storage.createFormField({ formId, tabId: resolvedTabId, name: fieldTemplate.name, code: fieldCode, type: fieldTemplate.type, isRequired: fieldTemplate.isRequired ?? false, placeholder: fieldTemplate.placeholder ?? null, defaultValue: fieldTemplate.defaultValue ?? null, validationRules: fieldTemplate.validationRules ?? null, options: fieldTemplate.options ?? null, position: targetPosition, linkedFormId: null, buttonActionType: null, buttonUrl: null, buttonFormId: null, companyAutofill: formCompanyAutofill, taskRelationType: null, maxFileCount: null, maxFileSizeMB: null, } as Parameters[0]); // Fetch all newly created sub-fields from DB to return full objects const updatedFields = await storage.getFormFields(formId, orgId); const createdSubFields = updatedFields.filter((f) => createdSubFieldCodes.includes(f.code)); return res.status(201).json({ success: true, field: newField, alreadyExists: false, createdSubFields, requestedTabId }); } catch (error) { console.error("add field template to form error:", error); return res.status(500).json({ success: false, error: "Ошибка при вставке шаблона поля в форму" }); } } ); /** * POST /api/data-tables/:tableId/fields/from-template * Вставляет шаблон поля в справочник (data table) как колонку. * Body: { fieldTemplateId: number } * Код колонки = ft_{fieldTemplate.code}. */ app.post( "/api/data-tables/:tableId/fields/from-template", authenticateToken, requirePermission('forms.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const tableId = parseInt(req.params.tableId); if (isNaN(tableId)) { return res.status(400).json({ success: false, error: "Неверный ID справочника" }); } const { fieldTemplateId } = req.body as { fieldTemplateId?: number }; if (!fieldTemplateId || typeof fieldTemplateId !== "number") { return res.status(400).json({ success: false, error: "fieldTemplateId обязателен" }); } const orgId = req.organizationId!; const [table, fieldTemplate] = await Promise.all([ storage.getDataTable(tableId, orgId), storage.getFieldTemplate(fieldTemplateId, orgId), ]); if (!table) { return res.status(404).json({ success: false, error: "Справочник не найден" }); } if (!fieldTemplate) { return res.status(404).json({ success: false, error: "Шаблон поля не найден" }); } const columnCode = `ft_${fieldTemplate.code}`; const existingColumns: Array<{ name: string; type: string; options?: string[]; isRequired?: boolean }> = Array.isArray(table.columns) ? table.columns : []; const existing = existingColumns.find((c) => c.name === columnCode || c.name === fieldTemplate.name); if (existing) { return res.json({ success: true, column: existing, alreadyExists: true }); } const newColumn = { name: fieldTemplate.name, type: fieldTemplate.type, options: Array.isArray(fieldTemplate.options) ? fieldTemplate.options : undefined, isRequired: fieldTemplate.isRequired ?? false, }; const updatedColumns = [...existingColumns, newColumn]; await storage.updateDataTable(tableId, orgId, { columns: updatedColumns }); return res.status(201).json({ success: true, column: newColumn, alreadyExists: false }); } catch (error) { console.error("add field template to data table error:", error); return res.status(500).json({ success: false, error: "Ошибка при вставке шаблона поля в справочник" }); } } ); /** * POST /api/forms/:formId/tabs/table-from-templates * Создаёт вкладку «Простая таблица» с колонками из шаблонов полей, * или дополняет существующую вкладку недостающими колонками. * Body: { tabName: string, fieldTemplateIds: number[] } */ app.post( "/api/forms/:formId/tabs/table-from-templates", authenticateToken, requireFormAdminOrPermission('forms.manage', 'formId'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const formId = parseInt(req.params.formId); if (isNaN(formId)) { return res.status(400).json({ success: false, error: "Неверный ID формы" }); } const { tabName, fieldTemplateIds } = req.body as { tabName?: string; fieldTemplateIds?: number[]; }; if (!tabName || typeof tabName !== "string" || !tabName.trim()) { return res.status(400).json({ success: false, error: "tabName обязателен" }); } if (!Array.isArray(fieldTemplateIds) || fieldTemplateIds.length === 0) { return res.status(400).json({ success: false, error: "fieldTemplateIds должен быть непустым массивом" }); } const orgId = req.organizationId!; const [form, existingTabs] = await Promise.all([ storage.getForm(formId, orgId), storage.getFormTabs(formId, orgId), ]); if (!form) { return res.status(404).json({ success: false, error: "Форма не найдена" }); } const uniqueFieldTemplateIds = [...new Set(fieldTemplateIds.map(Number).filter(Number.isFinite))]; const fieldTemplatesList = await Promise.all( uniqueFieldTemplateIds.map((id) => storage.getFieldTemplate(id, orgId)) ); const validFieldTemplates = fieldTemplatesList.filter(Boolean) as NonNullable>>[]; if (validFieldTemplates.length === 0) { return res.status(404).json({ success: false, error: "Ни один из указанных шаблонов полей не найден" }); } const trimmedName = tabName.trim(); let tab = existingTabs.find( (t) => t.name.toLowerCase() === trimmedName.toLowerCase() && t.type === "table" ); let isNew = false; if (!tab) { const tabCode = `gft_${trimmedName .toLowerCase() .replace(/[^a-z0-9а-яё]/gi, "_") .replace(/_+/g, "_") .slice(0, 80)}_${Date.now()}`; const maxPosition = existingTabs.length > 0 ? Math.max(...existingTabs.map((t) => t.position)) + 1 : 0; tab = await storage.createFormTab({ formId, name: trimmedName, code: tabCode, type: "table", position: maxPosition, tableColumns: [] as ColDef[], } as Parameters[0]); isNew = true; } const currentColumns: ColDef[] = Array.isArray(tab.tableColumns) ? (tab.tableColumns as ColDef[]) : []; const existingColIds = new Set(currentColumns.map((c) => c.id)); const newColumns: ColDef[] = validFieldTemplates .filter((ft) => !existingColIds.has(`ft_${ft.id}`)) .map((ft) => ({ id: `ft_${ft.id}`, name: ft.name, type: ft.type === "number" ? "number" : ft.type === "date" ? "date" : "text", })); let updatedTab: FormTab = tab; if (newColumns.length > 0) { const mergedColumns = [...currentColumns, ...newColumns]; updatedTab = await storage.updateFormTab(tab.id, formId, orgId, { tableColumns: mergedColumns, }); } return res.status(isNew ? 201 : 200).json({ success: true, tab: updatedTab, isNew, addedColumns: newColumns.length, skippedColumns: validFieldTemplates.length - newColumns.length, }); } catch (error) { console.error("table-from-templates error:", error); return res.status(500).json({ success: false, error: "Ошибка при создании/обновлении вкладки-таблицы" }); } } ); }