feat(fields): мягкое удаление полей формы — архив удалённых с восстановлением и блок «Удалённые поля» в задаче

This commit is contained in:
2026-09-21 14:35:45 +03:00
parent d5f2925886
commit e3f0ebfb89
18 changed files with 296 additions and 48 deletions

View File

@@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" data-testid="button-deleted-fields">
<Archive className="w-4 h-4 mr-2" />
Удалённые поля
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-sm">Удалённые поля</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground">
Удалённые поля скрыты в форме, но их значения в задачах сохранены. Восстановление вернёт поле и все его значения.
</p>
<div className="max-h-72 overflow-auto space-y-1.5 py-1">
{isLoading ? (
<p className="text-xs text-muted-foreground text-center py-6">Загрузка…</p>
) : fields.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-6">Нет удалённых полей</p>
) : (
fields.map((field) => (
<div key={field.id} className="flex items-center gap-2 rounded border px-2 py-1.5">
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">{field.name}</div>
<div className="text-[10px] text-muted-foreground truncate">
{getFieldTypeDef(field.type)?.label || field.type} · {field.code}
{field.deletedAt ? ` · удалено ${new Date(field.deletedAt).toLocaleDateString('ru-RU')}` : ''}
</div>
</div>
<Badge variant="secondary" className="text-[10px] shrink-0">удалено</Badge>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
title="Восстановить"
disabled={restoreMutation.isPending}
onClick={() => restoreMutation.mutate(field.id)}
data-testid={`button-restore-field-${field.id}`}
>
<RotateCcw className="w-3.5 h-3.5" />
</Button>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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({
</CardDescription>
</div>
<div className="flex gap-2">
{id && <DeletedFieldsDialog formId={Number(id)} />}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" data-testid="button-add-tab">

View File

@@ -2,6 +2,7 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
import { FieldValueRow } from '@/components/task-fields/FieldValueRow';
import type { FormFieldData, TaskData, TaskFieldValueData } from '@/components/task-fields/FieldValueRow';
import { useOfflineSyncContext } from '@/contexts/OfflineSyncContext';
import { getSelectOptionLabel } from '@/lib/fieldOptions';
import type { UserSummary } from './types';
interface TabFieldsContentProps {
@@ -21,6 +22,29 @@ interface TabFieldsContentProps {
isFieldImmutableForTask: (field: FormFieldData) => boolean;
fieldErrors: Record<number, string>;
setFieldErrors: React.Dispatch<React.SetStateAction<Record<number, string>>>;
/** Удалённые (мягко) поля формы — архив со значениями внизу вкладки */
deletedFields?: FormFieldData[];
}
/** Read-only отображение значения удалённого поля (без редакторов и запросов) */
function formatDeletedValue(field: FormFieldData, value: unknown): string {
if (value === null || value === undefined || value === '') return '—';
if (Array.isArray(value)) {
const items = value.map(v =>
typeof v === 'object' && v !== null
? String((v as Record<string, unknown>).name ?? (v as Record<string, unknown>).label ?? JSON.stringify(v))
: String(v)
);
return items.join(', ') || '—';
}
if (typeof value === 'object') {
const obj = value as Record<string, unknown>;
return String(obj.name ?? obj.label ?? obj.address ?? JSON.stringify(value));
}
if (field.type === 'select' || field.type === 'radio-group' || field.type === 'checklist') {
return getSelectOptionLabel(field.options, value as string) || String(value);
}
return String(value);
}
export const TabFieldsContent = ({
@@ -40,11 +64,16 @@ export const TabFieldsContent = ({
isFieldImmutableForTask,
fieldErrors,
setFieldErrors,
deletedFields,
}: TabFieldsContentProps) => {
const { isPending: isUrlPending, getPendingBody } = useOfflineSyncContext();
const visibleFields = fields.filter(
f => f.type !== 'company' && (tabId === undefined || f.tabId === tabId) && isFieldVisibleForTask(f)
);
// Удалённые поля с сохранёнными значениями — архив внизу основной вкладки полей
const deletedWithValues = (deletedFields ?? [])
.map(field => ({ field, value: fieldValues.find(fv => fv.fieldId === field.id)?.value }))
.filter(({ value }) => value !== undefined && value !== null && value !== '');
return (
<div className="space-y-2 text-sm">
{visibleFields && visibleFields.length > 0 ? (
@@ -85,6 +114,24 @@ export const TabFieldsContent = ({
</div>
) : null
)}
{tabId === undefined && deletedWithValues.length > 0 && (
<details className="rounded-md border border-dashed mt-4">
<summary className="cursor-pointer px-3 py-2 text-xs text-muted-foreground select-none">
Удалённые поля ({deletedWithValues.length})
</summary>
<div className="px-3 pb-3 pt-1 space-y-1.5">
{deletedWithValues.map(({ field, value }) => (
<div key={field.id} className="flex items-start gap-2 text-xs">
<span className="text-muted-foreground min-w-[140px] shrink-0 truncate" title={field.name}>
{field.name}
</span>
<span className="text-muted-foreground/50 text-[10px] shrink-0 pt-0.5">удалено</span>
<span className="text-muted-foreground/80 break-all">{formatDeletedValue(field, value)}</span>
</div>
))}
</div>
</details>
)}
</div>
);
};

View File

@@ -34,6 +34,8 @@ export interface TaskContentProps {
isFieldImmutableForTask: (field: FormFieldData) => boolean;
fieldErrors: Record<number, string>;
setFieldErrors: React.Dispatch<React.SetStateAction<Record<number, string>>>;
/** Удалённые (мягко) поля формы — архив со значениями внизу вкладки полей */
deletedFields?: FormFieldData[];
modulesData?: { modules: TabModuleInfo[] };
subtasks: Array<TaskData & { formId?: number }>;
formNamesMap: Record<number, string>;
@@ -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}
/>
</>
)}

View File

@@ -33,6 +33,8 @@ interface TaskMobileAccordionProps {
isFieldImmutableForTask: (field: FormFieldData) => boolean;
fieldErrors: Record<number, string>;
setFieldErrors: React.Dispatch<React.SetStateAction<Record<number, string>>>;
/** Удалённые (мягко) поля формы — архив со значениями */
deletedFields?: FormFieldData[];
orderedSystemTabs: string[];
subtasks: Array<TaskData & { formId?: number }>;
formNamesMap: Record<number, string>;
@@ -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}
/>
</AccordionContent>
</AccordionItem>

View File

@@ -99,6 +99,8 @@ export interface TaskDetailResponse extends CachedTaskDetail {
messages: unknown[];
users: UserSummary[];
hasRelations: boolean;
/** Удалённые (мягко) поля формы — архив со значениями в fieldValues */
deletedFields?: FormFieldData[];
}
export interface TaskRoleData {

View File

@@ -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<string, unknown> = {};
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:<id>) или 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}
/>
<TaskTabsBar