diff --git a/client/src/components/form-editor/DeletedFieldsDialog.tsx b/client/src/components/form-editor/DeletedFieldsDialog.tsx new file mode 100644 index 0000000..8ab43aa --- /dev/null +++ b/client/src/components/form-editor/DeletedFieldsDialog.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Archive, RotateCcw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { apiRequest } from '@/lib/queryClient'; +import { useToast } from '@/hooks/use-toast'; +import { getFieldTypeDef } from '@shared/field-types'; + +interface DeletedField { + id: number; + name: string; + code: string; + type: string; + deletedAt?: string | null; +} + +/** + * Архив удалённых (мягко) полей формы. + * Удалённые поля скрыты везде, но их определение и значения в задачах сохранены — + * отсюда их можно восстановить, и значения вернутся во все задачи автоматически. + */ +export function DeletedFieldsDialog({ formId }: { formId: number }) { + const [open, setOpen] = useState(false); + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const { data, isLoading, refetch } = useQuery<{ success: boolean; fields: DeletedField[] }>({ + queryKey: ['/api/forms', formId, 'fields', 'deleted'], + queryFn: async () => { + const res = await apiRequest('GET', `/api/forms/${formId}/fields/deleted`); + return res.json(); + }, + enabled: open, + }); + + const restoreMutation = useMutation({ + mutationFn: async (fieldId: number) => { + const res = await apiRequest('POST', `/api/forms/${formId}/fields/${fieldId}/restore`); + return res.json(); + }, + onSuccess: () => { + toast({ title: 'Поле восстановлено', description: 'Значения в задачах сохранились и снова видны' }); + refetch(); + queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'editor'] }); + }, + onError: (error: any) => { + toast({ title: 'Не удалось восстановить', description: error?.message || 'Ошибка', variant: 'destructive' }); + }, + }); + + const fields = data?.fields || []; + + return ( + + + + + + + Удалённые поля + +

+ Удалённые поля скрыты в форме, но их значения в задачах сохранены. Восстановление вернёт поле и все его значения. +

+
+ {isLoading ? ( +

Загрузка…

+ ) : fields.length === 0 ? ( +

Нет удалённых полей

+ ) : ( + fields.map((field) => ( +
+
+
{field.name}
+
+ {getFieldTypeDef(field.type)?.label || field.type} · {field.code} + {field.deletedAt ? ` · удалено ${new Date(field.deletedAt).toLocaleDateString('ru-RU')}` : ''} +
+
+ удалено + +
+ )) + )} +
+
+
+ ); +} diff --git a/client/src/components/form-editor/FieldsTab.tsx b/client/src/components/form-editor/FieldsTab.tsx index d03cf66..af840a7 100644 --- a/client/src/components/form-editor/FieldsTab.tsx +++ b/client/src/components/form-editor/FieldsTab.tsx @@ -11,6 +11,7 @@ import { } from '@/components/ui/dropdown-menu'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { DndContext, closestCenter } from '@dnd-kit/core'; +import { DeletedFieldsDialog } from './DeletedFieldsDialog'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { Plus, Trash2, ChevronDown, Table, Layers } from 'lucide-react'; import { TabRenderer } from '@/components/tabs'; @@ -101,6 +102,7 @@ export function FieldsTab({
+ {id && }
); }; diff --git a/client/src/components/task-detail/TaskContent.tsx b/client/src/components/task-detail/TaskContent.tsx index e398c0a..be9f106 100644 --- a/client/src/components/task-detail/TaskContent.tsx +++ b/client/src/components/task-detail/TaskContent.tsx @@ -34,6 +34,8 @@ export interface TaskContentProps { isFieldImmutableForTask: (field: FormFieldData) => boolean; fieldErrors: Record; setFieldErrors: React.Dispatch>>; + /** Удалённые (мягко) поля формы — архив со значениями внизу вкладки полей */ + deletedFields?: FormFieldData[]; modulesData?: { modules: TabModuleInfo[] }; subtasks: Array; formNamesMap: Record; @@ -64,6 +66,7 @@ export function TaskContent({ isFieldImmutableForTask, fieldErrors, setFieldErrors, + deletedFields, modulesData, subtasks, formNamesMap, @@ -233,6 +236,7 @@ export function TaskContent({ isFieldImmutableForTask={isFieldImmutableForTask} fieldErrors={fieldErrors} setFieldErrors={setFieldErrors} + deletedFields={deletedFields} /> )} diff --git a/client/src/components/task-detail/TaskMobileAccordion.tsx b/client/src/components/task-detail/TaskMobileAccordion.tsx index 87d21af..b7ee84d 100644 --- a/client/src/components/task-detail/TaskMobileAccordion.tsx +++ b/client/src/components/task-detail/TaskMobileAccordion.tsx @@ -33,6 +33,8 @@ interface TaskMobileAccordionProps { isFieldImmutableForTask: (field: FormFieldData) => boolean; fieldErrors: Record; setFieldErrors: React.Dispatch>>; + /** Удалённые (мягко) поля формы — архив со значениями */ + deletedFields?: FormFieldData[]; orderedSystemTabs: string[]; subtasks: Array; formNamesMap: Record; @@ -61,6 +63,7 @@ export function TaskMobileAccordion({ isFieldImmutableForTask, fieldErrors, setFieldErrors, + deletedFields, orderedSystemTabs, subtasks, formNamesMap, @@ -107,6 +110,7 @@ export function TaskMobileAccordion({ isFieldImmutableForTask={isFieldImmutableForTask} fieldErrors={fieldErrors} setFieldErrors={setFieldErrors} + deletedFields={deletedFields} /> diff --git a/client/src/components/task-detail/types.ts b/client/src/components/task-detail/types.ts index 606223b..6d377a7 100644 --- a/client/src/components/task-detail/types.ts +++ b/client/src/components/task-detail/types.ts @@ -99,6 +99,8 @@ export interface TaskDetailResponse extends CachedTaskDetail { messages: unknown[]; users: UserSummary[]; hasRelations: boolean; + /** Удалённые (мягко) поля формы — архив со значениями в fieldValues */ + deletedFields?: FormFieldData[]; } export interface TaskRoleData { diff --git a/client/src/pages/TaskDetail.tsx b/client/src/pages/TaskDetail.tsx index f61e832..718697a 100644 --- a/client/src/pages/TaskDetail.tsx +++ b/client/src/pages/TaskDetail.tsx @@ -21,7 +21,6 @@ import { useOfflineSyncContext } from '@/contexts/OfflineSyncContext'; import { useTaskWithCache } from '@/hooks/useTaskWithCache'; import { saveTask, patchTaskCache } from '@/lib/tasksCache'; import type { Task } from '@shared/schema'; -import { computeTaskTagColor } from '@shared/tag-color'; import { evaluateAllFormulas } from '@/lib/formulaEngine'; import { TaskContent } from '@/components/task-detail/TaskContent'; import { TaskDetailHeader } from '@/components/task-detail/TaskDetailHeader'; @@ -536,16 +535,6 @@ const TaskDetail = () => { rawFieldValuesMap, ); - // «Главный» цветной тег задачи (первая цветная опция select-поля) — чип в шапке - const tagValuesMap: Record = {}; - for (const field of fields) { - const fv = fieldValues.find((v: TaskFieldValueData) => v.fieldId === field.id); - if (fv?.value !== undefined && fv?.value !== null && fv?.value !== '') { - tagValuesMap[`field_${field.id}`] = fv.value; - } - } - const taskTag = computeTaskTagColor(fields, tagValuesMap); - // Кастомный вид задачи: пресет (@preset:) или JS-код const hasCustomLayout = detailLayoutInfo.kind !== 'standard'; const allTransitionsForLayout: FormTransition[] = effectiveDetailData?.transitions || []; @@ -615,6 +604,7 @@ const TaskDetail = () => { isFieldImmutableForTask={isFieldImmutableForTask} fieldErrors={fieldErrors} setFieldErrors={setFieldErrors} + deletedFields={effectiveDetailData?.deletedFields || []} modulesData={modulesData} subtasks={subtasks} formNamesMap={formNamesMap} @@ -690,6 +680,7 @@ const TaskDetail = () => { isFieldImmutableForTask={isFieldImmutableForTask} fieldErrors={fieldErrors} setFieldErrors={setFieldErrors} + deletedFields={effectiveDetailData?.deletedFields || []} orderedSystemTabs={orderedSystemTabs} subtasks={subtasks} formNamesMap={formNamesMap} @@ -743,7 +734,6 @@ const TaskDetail = () => { toggleHideMutation={toggleHideMutation} onShowReminderDialog={setShowReminderDialog} onShowDeleteDialog={setShowDeleteDialog} - tag={taskTag} /> ) => f.id).map((f: Record) => f.id as number); const fieldsToDelete = existingFields.filter(existing => !incomingIds.includes(existing.id)); - if (fieldsToDelete.length > 0) { - const tasks = await storage.getTasksByForm(formId, req.organizationId!); - const usedFieldIds = new Set(); - for (const task of tasks) { - const fieldValues = await storage.getTaskFieldValues(task.id, req.organizationId!); - for (const value of fieldValues) { - usedFieldIds.add(value.fieldId); - } - } - for (const field of fieldsToDelete) { - if (usedFieldIds.has(field.id)) { - return res.status(400).json({ - success: false, - error: `Невозможно удалить поле "${field.name}" - оно используется в существующих задачах. Сначала удалите значения этого поля во всех задачах.` - }); - } - } - } - + // Мягкое удаление: значения в задачах сохраняются (поле уходит в архив), + // поэтому раньше существовавшая блокировка «поле используется в задачах» снята. const fieldIdsToDelete = fieldsToDelete.map(f => f.id); const normalizedFields = fields.map((f: Record) => ({ ...f, @@ -171,4 +154,42 @@ export function registerFormStructureRoutes(router: ReturnType { + try { + const formId = parseInt(req.params.id); + if (isNaN(formId)) { + return res.status(400).json({ success: false, error: 'Неверный ID формы' }); + } + const fields = await storage.getDeletedFormFields(formId, req.organizationId!); + res.json({ success: true, fields }); + } catch (error) { + console.error('Get deleted fields error:', error); + res.status(500).json({ success: false, error: 'Ошибка при загрузке удалённых полей' }); + } + } + ); + + // Восстановление удалённого поля (значения в задачах сохранились и вернутся сами) + router.post('/api/forms/:id/fields/:fieldId/restore', + requireFormAdminOrPermission('forms.manage'), + async (req: AuthenticatedRequest, res) => { + try { + const formId = parseInt(req.params.id); + const fieldId = parseInt(req.params.fieldId); + if (isNaN(formId) || isNaN(fieldId)) { + return res.status(400).json({ success: false, error: 'Неверный ID формы или поля' }); + } + const restored = await storage.restoreFormField(fieldId, formId, req.organizationId!); + formsCache.invalidatePrefix(`forms:${req.organizationId}`); + res.json({ success: true, field: restored }); + } catch (error) { + console.error('Restore field error:', error); + res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Ошибка при восстановлении поля' }); + } + } + ); } diff --git a/server/services/contract-number.service.ts b/server/services/contract-number.service.ts index 2fa663e..946ac0d 100644 --- a/server/services/contract-number.service.ts +++ b/server/services/contract-number.service.ts @@ -1,5 +1,5 @@ import { db } from "../db"; -import { eq, and, sql } from "drizzle-orm"; +import { eq, and, sql, isNull } from "drizzle-orm"; import { tasks, taskFieldValues, contractNumberCounters, forms, formFields } from "@shared/schema"; export interface ContractNumberConfig { @@ -50,7 +50,8 @@ async function getTaskFieldValueByCode( .where( and( eq(formFields.formId, task.tasks.formId), - eq(formFields.code, fieldCode) + eq(formFields.code, fieldCode), + isNull(formFields.deletedAt) ) ) .limit(1); diff --git a/server/services/gantt-shift.service.ts b/server/services/gantt-shift.service.ts index 7c25700..0a72fa1 100644 --- a/server/services/gantt-shift.service.ts +++ b/server/services/gantt-shift.service.ts @@ -44,7 +44,8 @@ export async function getGanttFieldsByForm(formIds: number[], organizationId: nu .where(and( inArray(formFields.formId, formIds), eq(forms.organizationId, organizationId), - sql`${formFields.ganttRole} IS NOT NULL` + sql`${formFields.ganttRole} IS NOT NULL`, + isNull(formFields.deletedAt) )); return rows @@ -146,7 +147,8 @@ export async function shiftRelatedTasks( .innerJoin(forms, eq(formFields.formId, forms.id)) .where(and( eq(forms.organizationId, organizationId), - sql`${formFields.ganttRole} IS NOT NULL` + sql`${formFields.ganttRole} IS NOT NULL`, + isNull(formFields.deletedAt) )); const validFields: GanttFieldInfo[] = allGanttFields diff --git a/server/storage.ts b/server/storage.ts index aab5fdd..94ff445 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -147,6 +147,8 @@ export interface IStorage { // Form Fields getFormFields(formId: number, organizationId: number): Promise; getFormFieldsByCreationOrder(formId: number, organizationId: number): Promise; + getDeletedFormFields(formId: number, organizationId: number): Promise; + restoreFormField(id: number, formId: number, organizationId: number): Promise; getFormFieldById(id: number, organizationId: number): Promise; createFormField(insertField: InsertFormField): Promise; updateFormField(id: number, formId: number, organizationId: number, updates: Partial): Promise; diff --git a/server/storage/forms.storage.ts b/server/storage/forms.storage.ts index e4976ac..bfda484 100644 --- a/server/storage/forms.storage.ts +++ b/server/storage/forms.storage.ts @@ -1,6 +1,6 @@ import { forms, formTabs, formFields, formStatuses, statusTransitions, tasks, bookmarks, type Form, type FormTab, type FormField, type FormStatus, type StatusTransition, type InsertForm, type InsertFormTab, type InsertFormField, type InsertFormStatus, type InsertStatusTransition } from "@shared/schema"; import { db } from "../db"; -import { eq, and, desc, exists, sql } from "drizzle-orm"; +import { eq, and, desc, exists, sql, isNull } from "drizzle-orm"; import crypto from "crypto"; import { UserProfileStorage } from "./user-profile.storage"; import { invalidateAccessibleTasksForOrg } from "../utils/cache"; @@ -197,7 +197,7 @@ export class FormsStorage extends UserProfileStorage { .select() .from(formFields) .innerJoin(forms, eq(formFields.formId, forms.id)) - .where(and(eq(formFields.formId, formId), eq(forms.organizationId, organizationId))) + .where(and(eq(formFields.formId, formId), eq(forms.organizationId, organizationId), isNull(formFields.deletedAt))) .orderBy(formFields.displayOrder) .then(results => results.map(r => r.form_fields)); } @@ -207,11 +207,53 @@ export class FormsStorage extends UserProfileStorage { .select() .from(formFields) .innerJoin(forms, eq(formFields.formId, forms.id)) - .where(and(eq(formFields.formId, formId), eq(forms.organizationId, organizationId))) + .where(and(eq(formFields.formId, formId), eq(forms.organizationId, organizationId), isNull(formFields.deletedAt))) .orderBy(formFields.id) .then(results => results.map(r => r.form_fields)); } + // Архив удалённых (мягко) полей формы — для восстановления и блока «Удалённые поля» в задаче + async getDeletedFormFields(formId: number, organizationId: number): Promise { + return await db + .select() + .from(formFields) + .innerJoin(forms, eq(formFields.formId, forms.id)) + .where(and(eq(formFields.formId, formId), eq(forms.organizationId, organizationId), sql`${formFields.deletedAt} IS NOT NULL`)) + .orderBy(desc(formFields.deletedAt)) + .then(results => results.map(r => r.form_fields)); + } + + // Восстановление удалённого поля. Код проверяем на конфликт с активным полем формы. + async restoreFormField(id: number, formId: number, organizationId: number): Promise { + const [field] = await db + .select() + .from(formFields) + .innerJoin(forms, eq(formFields.formId, forms.id)) + .where(and(eq(formFields.id, id), eq(formFields.formId, formId), eq(forms.organizationId, organizationId))); + if (!field?.form_fields) throw new Error(`Поле ${id} не найдено`); + if (!field.form_fields.deletedAt) throw new Error(`Поле "${field.form_fields.name}" не удалено`); + + const [conflict] = await db + .select({ id: formFields.id, name: formFields.name }) + .from(formFields) + .where(and( + eq(formFields.formId, formId), + eq(formFields.code, field.form_fields.code), + isNull(formFields.deletedAt), + sql`${formFields.id} <> ${id}`, + )); + if (conflict) { + throw new Error(`Активное поле с кодом "${field.form_fields.code}" уже существует («${conflict.name}»). Переименуйте его или удалите.`); + } + + const [restored] = await db + .update(formFields) + .set({ deletedAt: null }) + .where(eq(formFields.id, id)) + .returning(); + return restored; + } + async getFormFieldById(id: number, organizationId: number): Promise { const [field] = await db .select({ field: formFields }) @@ -243,11 +285,14 @@ export class FormsStorage extends UserProfileStorage { } async deleteFormField(id: number, formId: number, organizationId: number): Promise { + // Мягкое удаление: значения в задачах сохраняются, поле доступно в архиве await db - .delete(formFields) + .update(formFields) + .set({ deletedAt: new Date() }) .where(and( eq(formFields.id, id), eq(formFields.formId, formId), + isNull(formFields.deletedAt), // Убедимся что form принадлежит организации через subquery exists(db.select().from(forms).where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId)))) )); @@ -290,9 +335,11 @@ export class FormsStorage extends UserProfileStorage { ): Promise { return await db.transaction(async (tx) => { for (const fieldId of fieldIdsToDelete) { + // Мягкое удаление: значения в задачах сохраняются, поле уходит в архив await tx - .delete(formFields) - .where(and(eq(formFields.id, fieldId), eq(formFields.formId, formId))); + .update(formFields) + .set({ deletedAt: new Date() }) + .where(and(eq(formFields.id, fieldId), eq(formFields.formId, formId), isNull(formFields.deletedAt))); } const resultFields: FormField[] = []; diff --git a/server/storage/tasks.storage.ts b/server/storage/tasks.storage.ts index db1dba7..ee9b296 100644 --- a/server/storage/tasks.storage.ts +++ b/server/storage/tasks.storage.ts @@ -21,6 +21,8 @@ export class TasksStorage extends TasksCoreStorage { users: Array; hasRelations: boolean; assignees: Array; + /** Удалённые (мягко) поля формы со значениями в fieldValues — блок «Удалённые поля» */ + deletedFields: FormField[]; } | null> { const taskResult = await db .select() @@ -34,7 +36,7 @@ export class TasksStorage extends TasksCoreStorage { const task = result.tasks; const form = result.forms; - const [fields, statuses, tabs, transitions, fieldValues, subtasks, messages, orgUsers, orgRoles, relationsCheck, assignees] = await Promise.all([ + const [fields, statuses, tabs, transitions, fieldValues, subtasks, messages, orgUsers, orgRoles, relationsCheck, assignees, deletedFields] = await Promise.all([ this.getFormFields(form.id, organizationId), this.getFormStatuses(form.id, organizationId), this.getFormTabs(form.id, organizationId), @@ -51,6 +53,7 @@ export class TasksStorage extends TasksCoreStorage { )) .limit(1), this.getTaskAssignees(task.id, organizationId), + this.getDeletedFormFields(form.id, organizationId), ]); const usersWithFullName = orgUsers.map((u: SafeUser) => ({ @@ -235,6 +238,7 @@ export class TasksStorage extends TasksCoreStorage { users: usersWithFullName, hasRelations: relationsCheck.length > 0, assignees, + deletedFields, }; } diff --git a/server/storage/users.storage.ts b/server/storage/users.storage.ts index 85a769a..bee7951 100644 --- a/server/storage/users.storage.ts +++ b/server/storage/users.storage.ts @@ -198,7 +198,8 @@ export class UsersStorage { and( eq(forms.organizationId, organizationId), eq(formFields.type, 'user'), - eq(formFields.linkToUserProfile, true) + eq(formFields.linkToUserProfile, true), + isNull(formFields.deletedAt) ) ); diff --git a/server/vpn/vpn.db.ts b/server/vpn/vpn.db.ts index f91e668..93ca417 100644 --- a/server/vpn/vpn.db.ts +++ b/server/vpn/vpn.db.ts @@ -8,7 +8,7 @@ import { type FormField, type FormStatus, } from '@shared/schema'; -import { eq, and, sql } from 'drizzle-orm'; +import { eq, and, sql, isNull } from 'drizzle-orm'; import { storage } from '../storage'; import { VPN_FORM_ID } from './vpn.config'; @@ -30,7 +30,7 @@ export async function getVpnFormCache(organizationId: number): Promise rows as FormField[]), db .select() diff --git a/shared/schema.ts b/shared/schema.ts index 146f77c..dd9ee50 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -561,10 +561,11 @@ export const formFields = pgTable("form_fields", { linkToUserProfile: boolean("link_to_user_profile").default(false), // связывать задачи формы с профилем пользователя (поле типа user) allowMultiple: boolean("allow_multiple").default(false), // разрешить множественный выбор для select/table ganttRole: varchar("gantt_role", { length: 20 }), // 'start' | 'end' | 'milestone' | null — роль поля в Гантте + deletedAt: timestamp("deleted_at"), // мягкое удаление: скрыто везде, значения в задачах сохранены createdAt: timestamp("created_at").defaultNow(), }, (table) => ({ - // Уникальность кода поля внутри формы - uniqueFieldCode: unique().on(table.formId, table.code), + // Уникальность кода поля внутри формы — только среди активных (удалённые не мешают) + uniqueFieldCode: uniqueIndex("form_fields_form_id_code_active_key").on(table.formId, table.code).where(sql`${table.deletedAt} IS NULL`), // Композитный уникальный ключ для referential integrity fieldFormComposite: unique().on(table.id, table.formId), }));