- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
360 lines
15 KiB
TypeScript
360 lines
15 KiB
TypeScript
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<void> {
|
||
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<string, string> | null = null;
|
||
const createdSubFieldCodes: string[] = [];
|
||
|
||
if (fieldTemplate.type === "company" && fieldTemplate.companyAutofill) {
|
||
const templateAutofill = fieldTemplate.companyAutofill as Record<string, string>;
|
||
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<typeof storage.createFormField>[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<typeof storage.createFormField>[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<Awaited<ReturnType<typeof storage.getFieldTemplate>>>[];
|
||
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<typeof storage.createFormTab>[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: "Ошибка при создании/обновлении вкладки-таблицы" });
|
||
}
|
||
}
|
||
);
|
||
}
|