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 (
+
+ );
+}
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),
}));