From 6826d8285003a885d32c71b1c9fe68a9babca13d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Wed, 8 Jul 2026 13:33:14 +0300 Subject: [PATCH] =?UTF-8?q?feat(documents):=20=D0=BF=D0=B0=D0=BF=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D0=BE=D0=B2,=20?= =?UTF-8?q?=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=B5=20document=5Ftemplate;=20fix(batch):?= =?UTF-8?q?=20=D0=B0=D1=82=D0=BE=D0=BC=D0=B0=D1=80=D0=BD=D0=BE=D0=B5=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D1=87,=20idempotency=20=D0=B8=20=D0=BC=D0=B0=D1=81?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=D0=BE=D0=B5=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B2=20=D1=80=D0=B5=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/TaskRegistry.tsx | 33 +- .../dashboard/BatchCreateDialog.tsx | 110 +++- .../documents/FolderMappingEditor.tsx | 386 ++++++++++++ .../documents/TemplateFolderTree.tsx | 575 ++++++++++++++++++ .../fields/DocumentTemplateField.tsx | 133 ++++ client/src/components/tabs/types.ts | 2 +- .../components/tasks/TaskDocumentButton.tsx | 10 + client/src/hooks/useDocuments.ts | 107 +++- client/src/hooks/useFolders.ts | 1 + client/src/pages/FormEditor.tsx | 189 +++++- client/src/pages/TaskDetail.tsx | 38 +- client/src/pages/TaskView.tsx | 160 ++++- client/src/pages/documents/DocumentsPage.tsx | 551 +++++++++++++---- .../pages/documents/TemplateEditorPage.tsx | 518 ++++++++++++---- ..._document_template_folders_and_targets.sql | 53 ++ migrations/0052_idempotency_keys.sql | 23 + migrations/meta/_journal.json | 7 + scripts/dev/start-deps.sh | 2 +- scripts/dev/start-dev.ps1 | 38 ++ scripts/dev/stop-deps.sh | 2 +- scripts/dev/sync-from-prod-deps.sh | 6 +- server/documents/data-resolution.service.ts | 120 +++- server/documents/docx-converter.service.ts | 6 +- server/documents/generation.service.ts | 162 ++++- server/documents/routes.ts | 119 ++++ server/documents/template.service.ts | 212 ++++++- server/index.ts | 36 +- server/routes/task-bulk.routes.ts | 49 +- server/routes/task-crud-write.routes.ts | 381 +++++++----- server/routes/task-fields.routes.ts | 4 +- server/storage/documents.storage.ts | 106 +++- shared/field-types.ts | 1 + shared/schema.ts | 93 ++- vite.config.ts | 4 + 34 files changed, 3770 insertions(+), 467 deletions(-) create mode 100644 client/src/components/documents/FolderMappingEditor.tsx create mode 100644 client/src/components/documents/TemplateFolderTree.tsx create mode 100644 client/src/components/fields/DocumentTemplateField.tsx create mode 100644 migrations/0040_document_template_folders_and_targets.sql create mode 100644 migrations/0052_idempotency_keys.sql create mode 100644 scripts/dev/start-dev.ps1 diff --git a/client/src/components/TaskRegistry.tsx b/client/src/components/TaskRegistry.tsx index 5495ca4..7408497 100644 --- a/client/src/components/TaskRegistry.tsx +++ b/client/src/components/TaskRegistry.tsx @@ -111,6 +111,11 @@ interface TaskRegistryProps { searches: string[]; onAddSearch: (value: string) => void; onRemoveSearch: (index: number) => void; + // Bulk selection + isAdmin?: boolean; + selectedTaskIds?: Set; + onToggleSelected?: (taskId: number) => void; + onToggleSelectAll?: () => void; } // Отображает значение поля с учётом типа @@ -248,7 +253,7 @@ const REGISTRY_INLINE_TYPES = new Set([ // Типы полей, которые переключаются по одиночному клику const REGISTRY_QUICK_TOGGLE_TYPES = new Set(['checkbox', 'toggle']); -const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = [], roles = [], formId, onFilteredCountChange, onTaskClick, filters, sort, setFilter, clearFilter, clearAll, setSort, clearSort, hasActiveFilters, searches, onAddSearch, onRemoveSearch }: TaskRegistryProps) => { +const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = [], roles = [], formId, onFilteredCountChange, onTaskClick, filters, sort, setFilter, clearFilter, clearAll, setSort, clearSort, hasActiveFilters, searches, onAddSearch, onRemoveSearch, isAdmin, selectedTaskIds, onToggleSelected, onToggleSelectAll }: TaskRegistryProps) => { const { toast } = useToast(); // Текущая редактируемая ячейка: { taskId, columnKey } @@ -754,6 +759,22 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = + {isAdmin && ( + + 0 && filteredTasks.every(t => selectedTaskIds?.has(t.id))} + data-state={ + filteredTasks.length > 0 && + filteredTasks.some(t => selectedTaskIds?.has(t.id)) && + !filteredTasks.every(t => selectedTaskIds?.has(t.id)) + ? 'indeterminate' + : (filteredTasks.length > 0 && filteredTasks.every(t => selectedTaskIds?.has(t.id)) ? 'checked' : 'unchecked') + } + onCheckedChange={() => onToggleSelectAll?.()} + aria-label="Выделить все видимые задачи" + /> + + )} {columnOrder.filter(key => visibleColumns[key]).map(key => { if (key === 'title') { return ( @@ -835,6 +856,16 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = return ( + {isAdmin && ( + e.stopPropagation()}> + onToggleSelected?.(task.id)} + onClick={(e) => e.stopPropagation()} + aria-label="Выделить задачу" + /> + + )} {columnOrder.filter(key => visibleColumns[key]).map(key => { if (key === 'title') { return ( diff --git a/client/src/components/dashboard/BatchCreateDialog.tsx b/client/src/components/dashboard/BatchCreateDialog.tsx index a58d23e..ae4d645 100644 --- a/client/src/components/dashboard/BatchCreateDialog.tsx +++ b/client/src/components/dashboard/BatchCreateDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -48,6 +48,23 @@ interface UserItem { middleName: string | null; } +interface LockRecord { + requestId: string; + startedAt: number; +} + +const LOCK_DURATION_MS = 10 * 60 * 1000; + +function getLockKey(formId: number) { + return `batch-create-lock-${formId}`; +} + +function getRemainingLockMinutes(startedAt: number): number { + const elapsed = Date.now() - startedAt; + const remaining = Math.max(0, LOCK_DURATION_MS - elapsed); + return Math.ceil(remaining / 60_000); +} + export function BatchCreateDialog({ formId, open, onOpenChange, onSuccess }: BatchCreateDialogProps) { const { toast } = useToast(); const [selectedType, setSelectedType] = useState(''); @@ -56,6 +73,9 @@ export function BatchCreateDialog({ formId, open, onOpenChange, onSuccess }: Bat const [assigneeId, setAssigneeId] = useState(''); const [dueDate, setDueDate] = useState(''); const [creating, setCreating] = useState(false); + const [requestId, setRequestId] = useState(() => crypto.randomUUID()); + const [isLocked, setIsLocked] = useState(false); + const [lockMinutes, setLockMinutes] = useState(0); const [fieldIdMap, setFieldIdMap] = useState>({}); const [types, setTypes] = useState>([]); const [objects, setObjects] = useState>([]); @@ -66,13 +86,41 @@ export function BatchCreateDialog({ formId, open, onOpenChange, onSuccess }: Bat }); const users = usersData?.users ?? []; + const checkLock = useCallback(() => { + try { + const raw = localStorage.getItem(getLockKey(formId)); + if (!raw) { + setIsLocked(false); + setLockMinutes(0); + return; + } + const record: LockRecord = JSON.parse(raw); + const elapsed = Date.now() - record.startedAt; + if (elapsed >= LOCK_DURATION_MS) { + localStorage.removeItem(getLockKey(formId)); + setIsLocked(false); + setLockMinutes(0); + } else { + setIsLocked(true); + setLockMinutes(getRemainingLockMinutes(record.startedAt)); + setRequestId(record.requestId); + } + } catch { + setIsLocked(false); + setLockMinutes(0); + } + }, [formId]); + useEffect(() => { if (!open) return; + setSelectedType(''); setDescription(''); setSelectedObjects(new Set()); setAssigneeId(''); setDueDate(''); + setRequestId(crypto.randomUUID()); + checkLock(); async function loadDialogData() { try { @@ -111,7 +159,16 @@ export function BatchCreateDialog({ formId, open, onOpenChange, onSuccess }: Bat } loadDialogData(); - }, [open, formId, toast]); + }, [open, formId, toast, checkLock]); + + // Update remaining lock minutes every 10 seconds while dialog is open + useEffect(() => { + if (!open || !isLocked) return; + const interval = setInterval(() => { + checkLock(); + }, 10_000); + return () => clearInterval(interval); + }, [open, isLocked, checkLock]); const toggleObject = (id: number) => { setSelectedObjects((prev) => { @@ -131,10 +188,23 @@ export function BatchCreateDialog({ formId, open, onOpenChange, onSuccess }: Bat }; const createTasks = async () => { + if (isLocked) { + toast({ title: `Подождите ${lockMinutes} мин. перед повторным созданием`, variant: 'destructive' }); + return; + } if (!selectedType) { toast({ title: 'Выберите тип', variant: 'destructive' }); return; } if (!description.trim()) { toast({ title: 'Введите описание', variant: 'destructive' }); return; } if (selectedObjects.size === 0) { toast({ title: 'Выберите объекты', variant: 'destructive' }); return; } + const currentRequestId = requestId || crypto.randomUUID(); + setRequestId(currentRequestId); + + // Lock immediately so repeated clicks (or retries after a network error) cannot double-create + const lockRecord: LockRecord = { requestId: currentRequestId, startedAt: Date.now() }; + localStorage.setItem(getLockKey(formId), JSON.stringify(lockRecord)); + setIsLocked(true); + setLockMinutes(getRemainingLockMinutes(lockRecord.startedAt)); + try { setCreating(true); const taskList: Array<{ @@ -159,10 +229,13 @@ export function BatchCreateDialog({ formId, open, onOpenChange, onSuccess }: Bat taskList.push(task); } - const res = await apiRequest('POST', `/api/forms/${formId}/tasks/batch`, { tasks: taskList }); + const res = await apiRequest('POST', `/api/forms/${formId}/tasks/batch`, { + idempotencyKey: currentRequestId, + tasks: taskList, + }); const data = await res.json(); if (data.success) { - toast({ title: `Создано задач: ${data.created}` }); + toast({ title: data.cached ? `Задачи уже созданы (${data.created})` : `Создано задач: ${data.created}` }); onOpenChange(false); onSuccess?.(); queryClient.invalidateQueries({ queryKey: ['/api/dashboard'] }); @@ -173,22 +246,34 @@ export function BatchCreateDialog({ formId, open, onOpenChange, onSuccess }: Bat toast({ title: 'Ошибка создания задач', variant: 'destructive' }); } finally { setCreating(false); + // Lock stays active for 10 minutes regardless of success or failure } }; + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen && creating) return; + onOpenChange(nextOpen); + }; + return ( - + Создание задач ТО Выберите тип, введите описание, назначьте исполнителя и отметьте объекты. + {isLocked && ( +
+ Пакетное создание уже выполнялось недавно. Повторное создание будет доступно через {lockMinutes} мин. +
+ )} +
- @@ -214,6 +299,7 @@ export function BatchCreateDialog({ formId, open, onOpenChange, onSuccess }: Bat onChange={setAssigneeId} placeholder="— не назначен —" dataTestid="select-batch-assignee" + disabled={isLocked} />
@@ -225,13 +311,14 @@ export function BatchCreateDialog({ formId, open, onOpenChange, onSuccess }: Bat onChange={(e) => setDescription(e.target.value)} placeholder="Описание работ" rows={3} + disabled={isLocked} />
- setDueDate(e.target.value)} /> + setDueDate(e.target.value)} disabled={isLocked} />
@@ -239,7 +326,7 @@ export function BatchCreateDialog({ formId, open, onOpenChange, onSuccess }: Bat
{objects.length > 0 && ( - )} @@ -252,14 +339,15 @@ export function BatchCreateDialog({ formId, open, onOpenChange, onSuccess }: Bat }} placeholder="Поиск объекта..." dataTestid="select-batch-objects" + disabled={isLocked} />
- - +
diff --git a/client/src/components/documents/FolderMappingEditor.tsx b/client/src/components/documents/FolderMappingEditor.tsx new file mode 100644 index 0000000..a7a022c --- /dev/null +++ b/client/src/components/documents/FolderMappingEditor.tsx @@ -0,0 +1,386 @@ +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Plus, Trash2 } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { + useFolderMappings, + useCreateFolderMapping, + useUpdateFolderMapping, + useDeleteFolderMapping, +} from "@/hooks/useDocuments"; +import { useQuery } from "@tanstack/react-query"; +import { apiRequest } from "@/lib/queryClient"; +import type { DocumentFolderMapping } from "@shared/schema"; +import { + SOURCE_OPTIONS, + RELATED_TASK_RELATION_TYPES, + TASK_SYSTEM_KEYS, + USER_SYSTEM_KEYS, + ORG_SYSTEM_KEYS, + TRANSFORM_OPTIONS, + getTransformConfig, + TransformParams, + useFormFieldsList, + AssetMapping, + TaskTableTabMapping, + RelatedTaskFieldMapping, + TaskFieldMapping, +} from "@/pages/documents/TemplateEditorPage"; + +interface FormItem { + id: number; + name: string; + code: string; +} + +interface FolderMappingEditorProps { + folderId: number | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function useFormsList() { + return useQuery<{ success: boolean; forms: FormItem[] }>({ + queryKey: ["/api/forms"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/forms"); + return res.json(); + }, + }); +} + +export function FolderMappingEditor({ folderId, open, onOpenChange }: FolderMappingEditorProps) { + const { data: mappings, isLoading } = useFolderMappings(folderId); + const { data: formsData } = useFormsList(); + const createMapping = useCreateFolderMapping(folderId || 0); + const updateMapping = useUpdateFolderMapping(folderId || 0); + const deleteMapping = useDeleteFolderMapping(folderId || 0); + const { toast } = useToast(); + + const [items, setItems] = useState[]>([]); + + useEffect(() => { + if (mappings) { + setItems(mappings.map((m) => ({ ...m }))); + } + }, [mappings]); + + const forms = formsData?.forms || []; + + const addMapping = () => { + setItems((prev) => [ + ...prev, + { + code: "", + label: "", + source: "static", + mapping: {}, + defaultValue: null, + sortOrder: prev.length, + }, + ]); + }; + + const removeMapping = (index: number) => { + const mapping = items[index]; + if (mapping?.id) { + deleteMapping.mutate(mapping.id); + } + setItems((prev) => { + const next = [...prev]; + next.splice(index, 1); + return next; + }); + }; + + const updateItem = (index: number, patch: Partial) => { + setItems((prev) => { + const next = [...prev]; + next[index] = { ...next[index], ...patch }; + return next; + }); + }; + + const updateItemMapping = (index: number, patch: Record) => { + setItems((prev) => { + const next = [...prev]; + next[index] = { + ...next[index], + mapping: { ...(next[index].mapping || {}), ...patch }, + }; + return next; + }); + }; + + const updateItemTransform = (index: number, type: string) => { + setItems((prev) => { + const next = [...prev]; + const mapping = { ...(next[index].mapping || {}) } as Record; + if (type === "none") { + delete mapping.transform; + } else { + const current = getTransformConfig(next[index]); + mapping.transform = { + type, + params: current?.type === type ? current.params || {} : {}, + }; + } + next[index] = { ...next[index], mapping }; + return next; + }); + }; + + const updateItemTransformParams = (index: number, params: Record) => { + setItems((prev) => { + const next = [...prev]; + const mapping = { ...(next[index].mapping || {}) } as Record; + const current = getTransformConfig(next[index]); + mapping.transform = { + type: current?.type || "none", + params: { ...(current?.params || {}), ...params }, + }; + next[index] = { ...next[index], mapping }; + return next; + }); + }; + + const handleSave = async () => { + if (!folderId) return; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (!item.code?.trim()) { + toast({ title: "Ошибка", description: `Код переменной #${i + 1} не заполнен`, variant: "destructive" }); + return; + } + + const payload = { + code: item.code.trim(), + label: item.label?.trim() || item.code.trim(), + source: item.source || "static", + mapping: item.mapping || {}, + defaultValue: item.defaultValue || null, + sortOrder: i, + }; + + if (item.id) { + await updateMapping.mutateAsync({ id: item.id, input: payload }); + } else { + await createMapping.mutateAsync(payload); + } + } + + onOpenChange(false); + }; + + return ( + + + + Маппинги переменных папки + + +
+

+ Переменные папки автоматически подставляются во все шаблоны этой папки. Шаблон может переопределить любую папочную переменную по коду. +

+ + {isLoading ? ( +

Загрузка маппингов...

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

Нет маппингов. Добавьте общие переменные папки.

+ ) : ( +
+ {items.map((item, index) => { + const source = item.source || "static"; + const mapping = (item.mapping || {}) as Record; + return ( +
+
+ + updateItem(index, { code: e.target.value })} + placeholder="{{code}}" + className="mt-1" + /> +
+
+ + updateItem(index, { label: e.target.value })} + placeholder="Название" + className="mt-1" + /> +
+
+ + +
+
+ + {(source === "task_field" || source === "task_system") && ( + updateItem(index, { mapping: { ...mapping, ...patch.mapping } })} + /> + )} + {source === "related_task_field" && ( + updateItem(index, { mapping: { ...mapping, ...patch.mapping } })} + /> + )} + {source === "task_table_tab" && ( + updateItem(index, { mapping: { ...mapping, ...patch.mapping } })} + /> + )} + {source === "asset" && ( + updateItem(index, { mapping: { ...mapping, ...patch.mapping } })} + /> + )} + {(source === "assignee" || source === "creator" || source === "current_user") && ( + + )} + {source === "organization" && ( + + )} + {(source === "static" || source === "current_date") && ( + updateItem(index, { defaultValue: e.target.value })} + placeholder={source === "current_date" ? "Формат даты" : "Значение по умолчанию"} + className="mt-1" + /> + )} +
+
+ + updateItem(index, { defaultValue: e.target.value })} + placeholder="-" + className="mt-1" + /> +
+
+ +
+ +
+
+ + +
+ updateItemTransformParams(index, params)} + /> +
+
+ ); + })} +
+ )} + + +
+ + + + + +
+
+ ); +} diff --git a/client/src/components/documents/TemplateFolderTree.tsx b/client/src/components/documents/TemplateFolderTree.tsx new file mode 100644 index 0000000..e6e6f21 --- /dev/null +++ b/client/src/components/documents/TemplateFolderTree.tsx @@ -0,0 +1,575 @@ +import { useMemo } from 'react'; +import { + SortableContext, + verticalListSortingStrategy, + useSortable, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable'; +import { KeyboardSensor, PointerSensor, useDraggable, useDroppable } from '@dnd-kit/core'; +import { CSS } from '@dnd-kit/utilities'; +import { Folder, FolderOpen, ChevronRight, ChevronDown, GripVertical, Pencil, Trash2, Plus, FileText } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import type { Folder as FolderType } from '@/hooks/useFolders'; + +export const FOLDER_SENSOR_CONFIG = { + pointer: { activationConstraint: { distance: 5 } }, + keyboard: { coordinateGetter: sortableKeyboardCoordinates }, +}; + +export function folderSortableId(id: number) { + return `folder-${id}`; +} +export function folderDroppableId(id: number) { + return `folder-drop-${id}`; +} +export function folderGapId(parentId: number | null, position: number) { + return `gap-${parentId ?? 'root'}-${position}`; +} +export function parseFolderSortableId(id: string | number): number | null { + if (typeof id === 'number') return id; + if (typeof id === 'string' && id.startsWith('folder-')) { + const num = parseInt(id.replace('folder-', ''), 10); + return isNaN(num) ? null : num; + } + return null; +} +export function parseTemplateDraggableId(id: string | number): number | null { + if (typeof id === 'number') return id; + if (typeof id === 'string' && id.startsWith('template-')) { + const num = parseInt(id.replace('template-', ''), 10); + return isNaN(num) ? null : num; + } + return null; +} + +interface TreeTemplateItem { + id: number; + name: string; + status: string; + folderId?: number | null; +} + +interface FolderTreeItem extends FolderType { + level: number; + children: FolderTreeItem[]; +} + +interface TemplateFolderTreeProps { + folders: FolderType[]; + templates: T[]; + expanded: Set; + selectedFolderId: number | null; + onToggleExpand: (id: number) => void; + onSelectFolder: (id: number | null) => void; + onEditFolder: (folder: FolderType) => void; + onDeleteFolder: (folder: FolderType) => void; + onCreateSubfolder: (parentId: number) => void; + onEditMappings?: (folder: FolderType) => void; + onSelectTemplate: (template: T) => void; + onEditTemplate: (template: T) => void; + onDeleteTemplate: (template: T) => void; + overFolderId?: number | null; + searchQuery?: string; +} + +function buildTree(folders: FolderType[]): FolderTreeItem[] { + const map = new Map(); + const roots: FolderTreeItem[] = []; + const sorted = [...folders].sort((a, b) => a.position - b.position); + + for (const f of sorted) { + map.set(f.id, { ...f, level: 0, children: [] }); + } + + for (const f of sorted) { + const node = map.get(f.id)!; + if (f.parentId === null) { + roots.push(node); + } else { + const parent = map.get(f.parentId); + if (parent) { + node.level = parent.level + 1; + parent.children.push(node); + } else { + roots.push(node); + } + } + } + + return roots; +} + +function filterTree( + tree: FolderTreeItem[], + templates: T[], + query: string +): { tree: FolderTreeItem[]; expanded: Set } { + if (!query.trim()) return { tree, expanded: new Set() }; + const q = query.toLowerCase(); + const matchingFolderIds = new Set(); + for (const t of templates) { + if (t.name.toLowerCase().includes(q)) { + matchingFolderIds.add(t.folderId ?? null); + } + } + const expanded = new Set(); + function walk(nodes: FolderTreeItem[]): FolderTreeItem[] { + const visible: FolderTreeItem[] = []; + for (const node of nodes) { + const nameMatch = node.name.toLowerCase().includes(q); + const templateMatch = matchingFolderIds.has(node.id); + const children = walk(node.children); + const childMatch = children.length > 0; + if (nameMatch || templateMatch || childMatch) { + visible.push({ ...node, children }); + if (childMatch || templateMatch) expanded.add(node.id); + } + } + return visible; + } + return { tree: walk(tree), expanded }; +} + +function DropGap({ parentId, position, level }: { parentId: number | null; position: number; level: number }) { + const { setNodeRef, isOver } = useDroppable({ + id: folderGapId(parentId, position), + data: { type: 'gap', parentId, position }, + }); + return ( +
+ ); +} + +function DraggableTemplateRow({ + template, + level, + onSelect, + onEdit, + onDelete, +}: { + template: T; + level: number; + onSelect: (template: T) => void; + onEdit: (template: T) => void; + onDelete: (template: T) => void; +}) { + const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ + id: `template-${template.id}`, + data: { type: 'template', templateId: template.id }, + }); + const style = { + transform: CSS.Transform.toString(transform), + opacity: isDragging ? 0.4 : 1, + }; + + return ( +
onSelect(template)} + > +
e.stopPropagation()} + > + +
+ + {template.name} + + {template.status === 'active' ? 'Активен' : 'Черновик'} + +
e.stopPropagation()} + > + + +
+
+ ); +} + +function SortableFolderNode( + props: { + item: FolderTreeItem; + selectedFolderId: number | null; + expanded: Set; + onToggleExpand: (id: number) => void; + onSelectFolder: (id: number | null) => void; + onEdit: (folder: FolderType) => void; + onDelete: (folder: FolderType) => void; + onCreateSubfolder: (parentId: number) => void; + onEditMappings?: (folder: FolderType) => void; + overFolderId?: number | null; + } +) { + const { item } = props; + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: folderSortableId(item.id), + data: { type: 'folder', folderId: item.id }, + }); + const { setNodeRef: setDropRef, isOver } = useDroppable({ + id: folderDroppableId(item.id), + data: { type: 'folder', folderId: item.id }, + }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + }; + const isOverFolder = isOver || props.overFolderId === item.id; + + return ( +
{ + setNodeRef(node); + setDropRef(node); + }} + style={style} + > + +
+ ); +} + +function FolderNodeContent({ + item, + selectedFolderId, + expanded, + onToggleExpand, + onSelectFolder, + onEdit, + onDelete, + onCreateSubfolder, + onEditMappings, + isOver, + dragHandleProps, +}: { + item: FolderTreeItem; + selectedFolderId: number | null; + expanded: Set; + onToggleExpand: (id: number) => void; + onSelectFolder: (id: number | null) => void; + onEdit: (folder: FolderType) => void; + onDelete: (folder: FolderType) => void; + onCreateSubfolder: (parentId: number) => void; + onEditMappings?: (folder: FolderType) => void; + isOver?: boolean; + dragHandleProps?: { attributes: any; listeners: any }; +}) { + const hasChildren = item.children.length > 0; + const isSelected = selectedFolderId === item.id; + + return ( +
onSelectFolder(item.id)} + onDoubleClick={() => onToggleExpand(item.id)} + > + + + {expanded.has(item.id) ? ( + + ) : ( + + )} + + {item.name} + +
e.stopPropagation()} + > + {onEditMappings && ( + + )} + + + +
+ + {dragHandleProps && ( +
e.stopPropagation()} + > + +
+ )} +
+ ); +} + +function FolderNode({ + node, + templates, + expanded, + effectiveExpanded, + ...props +}: { + node: FolderTreeItem; + templates: T[]; + expanded: Set; + effectiveExpanded: Set; + selectedFolderId: number | null; + onToggleExpand: (id: number) => void; + onSelectFolder: (id: number | null) => void; + onEditFolder: (folder: FolderType) => void; + onDeleteFolder: (folder: FolderType) => void; + onCreateSubfolder: (parentId: number) => void; + onEditMappings?: (folder: FolderType) => void; + onSelectTemplate: (template: T) => void; + onEditTemplate: (template: T) => void; + onDeleteTemplate: (template: T) => void; + overFolderId?: number | null; +}) { + const isExpanded = effectiveExpanded.has(node.id); + const directTemplates = templates.filter((t) => (t.folderId ?? null) === node.id); + + return ( +
+ + {isExpanded && ( +
+ {directTemplates.map((template) => ( + + ))} + {node.children.map((child) => ( + + ))} +
+ )} +
+ ); +} + +export function TemplateFolderTree({ + folders, + templates, + expanded, + selectedFolderId, + onToggleExpand, + onSelectFolder, + onEditFolder, + onDeleteFolder, + onCreateSubfolder, + onEditMappings, + onSelectTemplate, + onEditTemplate, + onDeleteTemplate, + overFolderId, + searchQuery, +}: TemplateFolderTreeProps) { + const tree = useMemo(() => buildTree(folders), [folders]); + const filtered = useMemo(() => filterTree(tree, templates, searchQuery || ''), [tree, templates, searchQuery]); + const effectiveExpanded = useMemo( + () => (searchQuery?.trim() ? new Set([...expanded, ...filtered.expanded]) : expanded), + [searchQuery, expanded, filtered.expanded] + ); + const displayTree = useMemo(() => (searchQuery?.trim() ? filtered.tree : tree), [searchQuery, filtered.tree, tree]); + + const allFolderIds = useMemo(() => { + const ids: string[] = []; + function walk(nodes: FolderTreeItem[]) { + for (const n of nodes) { + ids.push(folderSortableId(n.id)); + walk(n.children); + } + } + walk(displayTree); + return ids; + }, [displayTree]); + + const rootTemplates = templates.filter((t) => t.folderId === null || t.folderId === undefined); + + const rows = useMemo(() => { + const result: ( + | { type: 'gap'; parentId: number | null; position: number; level: number } + | { type: 'folder'; item: FolderTreeItem } + )[] = []; + function walk(items: FolderTreeItem[]) { + for (let i = 0; i <= items.length; i++) { + const parentId = items[0]?.parentId ?? null; + const level = items[0]?.level ?? 0; + result.push({ type: 'gap', parentId, position: i, level }); + if (i < items.length) { + const item = items[i]; + result.push({ type: 'folder', item }); + if (effectiveExpanded.has(item.id)) { + walk(item.children); + } + } + } + } + walk(displayTree); + return result; + }, [displayTree, effectiveExpanded]); + + return ( + +
+ {rootTemplates.length > 0 && ( +
+
Без папки
+ {rootTemplates.map((template) => ( + + ))} +
+ )} + {rows.map((row, idx) => + row.type === 'gap' ? ( + + ) : ( + + ) + )} +
+
+ ); +} diff --git a/client/src/components/fields/DocumentTemplateField.tsx b/client/src/components/fields/DocumentTemplateField.tsx new file mode 100644 index 0000000..4a8fa55 --- /dev/null +++ b/client/src/components/fields/DocumentTemplateField.tsx @@ -0,0 +1,133 @@ +import { useState, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { FileText, Loader2 } from "lucide-react"; +import { useDocumentTemplates } from "@/hooks/useDocuments"; +import { useGenerateDocument } from "@/hooks/useDocuments"; +import { useToast } from "@/hooks/use-toast"; +import { queryClient } from "@/lib/queryClient"; +import type { FormFieldData } from "@/components/task-form-fields"; + +interface DocumentTemplateFieldProps { + field: FormFieldData; + value: any; + taskId: number; + onChange?: (value: { templateId?: number; folderId?: number }) => void; + readOnly?: boolean; +} + +export function DocumentTemplateField({ field, value, taskId, onChange, readOnly }: DocumentTemplateFieldProps) { + const { toast } = useToast(); + const generate = useGenerateDocument(); + + const config = (field.options || {}) as { + documentTemplateSource?: "folder" | "template"; + documentTemplateFolderId?: number; + documentTemplateId?: number; + }; + + const source = config.documentTemplateSource || "template"; + const configFolderId = config.documentTemplateFolderId; + const configTemplateId = config.documentTemplateId; + + const { data: templates } = useDocumentTemplates({ + folderId: source === "folder" && configFolderId ? configFolderId : undefined, + }); + + const currentValue = useMemo(() => { + if (!value) return {}; + if (typeof value === "string") { + try { + return JSON.parse(value); + } catch { + return {}; + } + } + return value; + }, [value]); + + const selectedTemplateId = currentValue.templateId || (source === "template" ? configTemplateId : undefined); + const selectedFolderId = currentValue.folderId || configFolderId; + + const selectedTemplate = useMemo(() => { + return templates?.find((t) => t.id === selectedTemplateId); + }, [templates, selectedTemplateId]); + + const handleTemplateChange = (templateId: number) => { + onChange?.({ templateId, folderId: selectedFolderId }); + }; + + const handleGenerate = async () => { + const templateId = selectedTemplateId; + if (!templateId) { + toast({ title: "Ошибка", description: "Не выбран шаблон", variant: "destructive" }); + return; + } + + const result = await generate.mutateAsync({ + templateId, + taskId, + format: "both", + }); + + if (result.unresolvedVariables.length > 0) { + toast({ + title: "Документ сгенерирован с оговорками", + description: `Незаполненные переменные: ${result.unresolvedVariables.join(", ")}`, + }); + } else { + toast({ title: "Документ сгенерирован", description: "Файл сохранён в указанное поле задачи" }); + } + + queryClient.invalidateQueries({ queryKey: ["/api/tasks", taskId, "field-values"] }); + queryClient.invalidateQueries({ queryKey: ["/api/tasks", taskId] }); + }; + + if (source === "template" && configTemplateId) { + return ( +
+ + {selectedTemplate?.name || "Шаблон документа"} + {!readOnly && ( + + )} +
+ ); + } + + return ( +
+ + {selectedTemplateId && !readOnly && ( + + )} +
+ ); +} diff --git a/client/src/components/tabs/types.ts b/client/src/components/tabs/types.ts index 0a2fa6a..979d7ef 100644 --- a/client/src/components/tabs/types.ts +++ b/client/src/components/tabs/types.ts @@ -36,7 +36,7 @@ export interface FormField { displayOrder?: number; tabId?: number | null; linkedFormId?: number | null; - buttonActionType?: 'open_url' | 'create_task' | null; + buttonActionType?: 'open_url' | 'create_task' | 'generate_document' | null; buttonUrl?: string | null; buttonFormId?: number | null; companyAutofill?: Record; diff --git a/client/src/components/tasks/TaskDocumentButton.tsx b/client/src/components/tasks/TaskDocumentButton.tsx index ded85af..7686dee 100644 --- a/client/src/components/tasks/TaskDocumentButton.tsx +++ b/client/src/components/tasks/TaskDocumentButton.tsx @@ -18,6 +18,7 @@ import { Label } from "@/components/ui/label"; import { Loader2, FileText, Download, AlertCircle } from "lucide-react"; import { useDocumentTemplates, useGenerateDocument } from "@/hooks/useDocuments"; import { useToast } from "@/hooks/use-toast"; +import { queryClient } from "@/lib/queryClient"; interface TaskDocumentButtonProps { taskId: number; @@ -45,8 +46,17 @@ export function TaskDocumentButton({ taskId }: TaskDocumentButtonProps) { description: `Незаполненные переменные: ${result.unresolvedVariables.join(", ")}`, variant: "default", }); + } else { + toast({ + title: "Документ сгенерирован", + description: "Файл сохранён в указанное поле задачи", + }); } + // Обновляем поля текущей задачи, если файл сохранился в неё + queryClient.invalidateQueries({ queryKey: ["/api/tasks", taskId, "field-values"] }); + queryClient.invalidateQueries({ queryKey: ["/api/tasks", taskId] }); + setOpen(false); }; diff --git a/client/src/hooks/useDocuments.ts b/client/src/hooks/useDocuments.ts index 931502a..2cf367d 100644 --- a/client/src/hooks/useDocuments.ts +++ b/client/src/hooks/useDocuments.ts @@ -7,6 +7,7 @@ import type { DocumentTemplateCategory, OrganizationAsset, DocumentGeneration, + DocumentFolderMapping, } from "@shared/schema"; export type OutputFormat = "pdf" | "docx" | "both"; @@ -116,11 +117,13 @@ export interface DocumentSignature { export function useDocumentTemplates(filters?: { status?: string; categoryId?: number; + folderId?: number | null; formId?: number; }) { const queryParams = new URLSearchParams(); if (filters?.status) queryParams.set("status", filters.status); - if (filters?.categoryId) queryParams.set("categoryId", String(filters.categoryId)); + if (filters?.categoryId !== undefined) queryParams.set("categoryId", String(filters.categoryId)); + if (filters?.folderId !== undefined) queryParams.set("folderId", String(filters.folderId)); if (filters?.formId) queryParams.set("formId", String(filters.formId)); return useQuery({ @@ -213,6 +216,28 @@ export function useDeleteDocumentTemplate() { }); } +export function useMoveDocumentTemplateToFolder() { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + return useMutation({ + mutationFn: async ({ templateId, folderId }: { templateId: number; folderId: number | null }) => { + const res = await apiRequest("PATCH", `/api/documents/templates/${templateId}/folder`, { folderId }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || "Ошибка перемещения шаблона"); + return data.template as DocumentTemplate; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/documents/templates"] }); + queryClient.invalidateQueries({ queryKey: ["/api/documents/folders"] }); + toast({ title: "Шаблон перемещён" }); + }, + onError: (error: Error) => { + toast({ title: "Ошибка", description: error.message, variant: "destructive" }); + }, + }); +} + // ── Categories ─────────────────────────────────────────────────────────────── export function useDocumentTemplateCategories() { @@ -561,3 +586,83 @@ export function useImportDocxTemplate() { }, }); } + +// ── Folder Mappings ────────────────────────────────────────────────────────── + +export function useFolderMappings(folderId: number | null) { + return useQuery({ + queryKey: ["/api/documents/folders", folderId, "mappings"], + queryFn: async () => { + const res = await apiRequest("GET", `/api/documents/folders/${folderId}/mappings`); + const data = await res.json(); + return data.mappings as DocumentFolderMapping[]; + }, + enabled: !!folderId, + }); +} + +export function useCreateFolderMapping(folderId: number) { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + return useMutation({ + mutationFn: async (input: Partial) => { + const res = await apiRequest("POST", `/api/documents/folders/${folderId}/mappings`, input); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Ошибка создания маппинга"); + return data.mapping as DocumentFolderMapping; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/documents/folders", folderId, "mappings"] }); + queryClient.invalidateQueries({ queryKey: ["/api/documents/templates"] }); + toast({ title: "Маппинг создан" }); + }, + onError: (error: Error) => { + toast({ title: "Ошибка", description: error.message, variant: "destructive" }); + }, + }); +} + +export function useUpdateFolderMapping(folderId: number) { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + return useMutation({ + mutationFn: async ({ id, input }: { id: number; input: Partial }) => { + const res = await apiRequest("PUT", `/api/documents/folders/${folderId}/mappings/${id}`, input); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Ошибка обновления маппинга"); + return data.mapping as DocumentFolderMapping; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/documents/folders", folderId, "mappings"] }); + queryClient.invalidateQueries({ queryKey: ["/api/documents/templates"] }); + toast({ title: "Маппинг обновлён" }); + }, + onError: (error: Error) => { + toast({ title: "Ошибка", description: error.message, variant: "destructive" }); + }, + }); +} + +export function useDeleteFolderMapping(folderId: number) { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + return useMutation({ + mutationFn: async (id: number) => { + const res = await apiRequest("DELETE", `/api/documents/folders/${folderId}/mappings/${id}`); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Ошибка удаления маппинга"); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/documents/folders", folderId, "mappings"] }); + queryClient.invalidateQueries({ queryKey: ["/api/documents/templates"] }); + toast({ title: "Маппинг удалён" }); + }, + onError: (error: Error) => { + toast({ title: "Ошибка", description: error.message, variant: "destructive" }); + }, + }); +} diff --git a/client/src/hooks/useFolders.ts b/client/src/hooks/useFolders.ts index 7c6b4b3..ffaae18 100644 --- a/client/src/hooks/useFolders.ts +++ b/client/src/hooks/useFolders.ts @@ -125,3 +125,4 @@ function createFolderHooks(basePath: string, listQueryKey: string[]) { export const formFolderHooks = createFolderHooks('/api/forms/folders', ['/api/forms/folders']); export const directoryFolderHooks = createFolderHooks('/api/directories/folders', ['/api/directories/folders']); +export const documentFolderHooks = createFolderHooks('/api/documents/folders', ['/api/documents/folders']); diff --git a/client/src/pages/FormEditor.tsx b/client/src/pages/FormEditor.tsx index 573b606..2ad4be3 100644 --- a/client/src/pages/FormEditor.tsx +++ b/client/src/pages/FormEditor.tsx @@ -107,6 +107,8 @@ import { CSS } from '@dnd-kit/utilities'; import { useToast } from '@/hooks/use-toast'; import { useAuth } from '@/hooks/useAuth'; import { hasPermission } from '@/lib/permissions'; +import { documentFolderHooks } from '@/hooks/useFolders'; +import { useDocumentTemplates } from '@/hooks/useDocuments'; import { queryClient, apiRequest } from '@/lib/queryClient'; import { TabRenderer, useTabTypeOptions } from '@/components/tabs'; import { JsComponentRenderer } from '@/components/tabs/JsComponentRenderer'; @@ -206,7 +208,7 @@ interface FormField { tabId?: number | null; linkedFormId?: number | null; taskRelationType?: 'parent' | 'child' | null; - buttonActionType?: 'open_url' | 'create_task' | null; + buttonActionType?: 'open_url' | 'create_task' | 'generate_document' | null; buttonUrl?: string | null; buttonFormId?: number | null; companyAutofill?: Record; @@ -501,7 +503,9 @@ const FormEditor = () => { const { toast } = useToast(); const { user } = useAuth(); const tabTypeOptions = useTabTypeOptions(); - + const { data: documentFoldersData } = documentFolderHooks.useFolders(); + const { data: allDocumentTemplates } = useDocumentTemplates(); + const [fields, setFields] = useState([]); const [statuses, setStatuses] = useState([]); const [transitions, setTransitions] = useState([]); @@ -1976,10 +1980,10 @@ const FormEditor = () => { updateField(index, { buttonActionType: null, buttonUrl: null, buttonFormId: null }); return; } - updateField(index, { - buttonActionType: value as 'open_url' | 'create_task', + updateField(index, { + buttonActionType: value as 'open_url' | 'create_task' | 'generate_document', buttonUrl: value === 'open_url' ? '' : null, - buttonFormId: value === 'create_task' ? null : null + buttonFormId: value === 'create_task' ? null : null, }); }} > @@ -1990,6 +1994,7 @@ const FormEditor = () => { Не настроено Открыть URL Создать задачу по форме + Сгенерировать документ
@@ -2039,6 +2044,83 @@ const FormEditor = () => {

)} + + {field.buttonActionType === 'generate_document' && ( +
+
+ + +
+ +
+ + +
+ +
+ + { + updateField(index, { + options: { + ...field.options, + generateReturnTo: e.target.value || undefined, + }, + }); + }} + placeholder="Оставьте пустым для возврата на текущую страницу" + data-testid={`input-button-generate-return-to-${index}`} + /> +
+ +

+ При нажатии кнопки будет сгенерирован документ и сохранён в настроенное file-поле шаблона +

+
+ )} )} @@ -2082,6 +2164,103 @@ const FormEditor = () => { )} + {/* Настройки поля «Шаблон документа» */} + {field.type === 'document_template' && ( +
+
+ + +
+ + {field.options?.documentTemplateSource === 'folder' ? ( +
+ + +

+ В задаче можно будет выбрать любой шаблон из этой папки +

+
+ ) : ( +
+ + +

+ В задаче будет доступна генерация по этому шаблону +

+
+ )} +
+ )} + {/* Настройки автозаполнения для организации (DaData) */} {field.type === 'company' && (
diff --git a/client/src/pages/TaskDetail.tsx b/client/src/pages/TaskDetail.tsx index 11faecc..5a0414e 100644 --- a/client/src/pages/TaskDetail.tsx +++ b/client/src/pages/TaskDetail.tsx @@ -52,6 +52,7 @@ import { UserRoleRenderer, UserRoleEditor } from '@/components/fields'; import { TaskFieldSelect } from '@/components/task-form-fields'; import { TaskDocumentButton } from '@/components/tasks/TaskDocumentButton'; import { TaskQrButton } from '@/components/TaskQrButton'; +import { DocumentTemplateField } from '@/components/fields/DocumentTemplateField'; import { validateRequiredFields, validateRequiredFieldsDetailed, @@ -147,7 +148,7 @@ interface FormFieldData { tabId?: number | null; linkedFormId?: number | null; taskRelationType?: 'parent' | 'child' | null; - buttonActionType?: 'open_url' | 'create_task' | null; + buttonActionType?: 'open_url' | 'create_task' | 'generate_document' | null; buttonUrl?: string | null; buttonFormId?: number | null; companyAutofill?: Record; @@ -301,7 +302,7 @@ function TableValueDisplay({ tableId, displayColumn, rowId }: { tableId: number; } // Компонент для рендеринга кнопки с действием -function ButtonField({ field }: { field: FormFieldData }) { +function ButtonField({ field, taskId }: { field: FormFieldData; taskId?: number }) { const [, setLocation] = useLocation(); const handleButtonClick = () => { @@ -309,12 +310,25 @@ function ButtonField({ field }: { field: FormFieldData }) { window.open(field.buttonUrl, '_blank', 'noopener,noreferrer'); } else if (field.buttonActionType === 'create_task' && field.buttonFormId) { setLocation(`/forms/${field.buttonFormId}/tasks/new`); + } else if (field.buttonActionType === 'generate_document' && taskId) { + const opts = (field.options || {}) as { + documentTemplateId?: number; + generateFormat?: 'docx' | 'pdf' | 'both'; + generateReturnTo?: string; + }; + const templateId = opts.documentTemplateId; + if (!templateId) return; + const format = opts.generateFormat || 'both'; + const returnTo = opts.generateReturnTo || window.location.href; + const url = `/api/documents/generate-link?templateId=${templateId}&taskId=${taskId}&format=${format}&returnTo=${encodeURIComponent(returnTo)}`; + window.location.href = url; } }; const isConfigured = field.buttonActionType && ( (field.buttonActionType === 'open_url' && field.buttonUrl) || - (field.buttonActionType === 'create_task' && field.buttonFormId) + (field.buttonActionType === 'create_task' && field.buttonFormId) || + (field.buttonActionType === 'generate_document' && taskId && (field.options as any)?.documentTemplateId) ); return ( @@ -327,6 +341,7 @@ function ButtonField({ field }: { field: FormFieldData }) { > {field.buttonActionType === 'open_url' && } {field.buttonActionType === 'create_task' && } + {field.buttonActionType === 'generate_document' && } {field.name}
@@ -720,7 +735,7 @@ function FieldValueRow({ onRequestGlobalEditMode?.(); }; - const saveValue = async (val: string | number | boolean | null | undefined) => { + const saveValue = async (val: string | number | boolean | Record | null | undefined) => { try { await apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${field.id}`, { value: val }); // Сохраняем pending autofill-поля (при выборе/очистке компании) @@ -1207,7 +1222,7 @@ function FieldValueRow({ // Кнопка — всегда read-only (своя логика действий) if (field.type === 'button') { - return ; + return ; } // Файл — своя логика загрузки @@ -1215,6 +1230,19 @@ function FieldValueRow({ return ; } + // Шаблон документа — выбор шаблона и генерация + if (field.type === 'document_template') { + return ( + saveValue(val)} + readOnly={readOnly} + /> + ); + } + const fieldUrl = `/api/tasks/${taskId}/field-values/${field.id}`; const isFieldPending = isPending ? isPending(fieldUrl) : false; diff --git a/client/src/pages/TaskView.tsx b/client/src/pages/TaskView.tsx index 2b42b95..e24a17e 100644 --- a/client/src/pages/TaskView.tsx +++ b/client/src/pages/TaskView.tsx @@ -8,7 +8,8 @@ import Layout from '@/components/Layout'; import { Sidebar } from '@/components/Sidebar'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { ArrowLeft, Grid, Table, Plus, Link2, AlertCircle } from 'lucide-react'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { ArrowLeft, Grid, Table, Plus, Link2, AlertCircle, Trash2, X } from 'lucide-react'; import { Link } from 'wouter'; import TaskRegistry from '../components/TaskRegistry'; import { useColumnFilters } from '@/hooks/useColumnFilters'; @@ -73,6 +74,8 @@ const TaskView = () => { const [activeTab, setActiveTab] = useState('registry'); const [filteredTasksCount, setFilteredTasksCount] = useState(0); const [createTaskDialogOpen, setCreateTaskDialogOpen] = useState(false); + const [selectedTaskIds, setSelectedTaskIds] = useState>(new Set()); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); // Предзаполнение полей из URL (например, ?prefill_field_3=123 для QR-кодов машин) // Флаг ref, чтобы автооткрытие произошло только один раз при первоначальной загрузке страницы. @@ -239,6 +242,53 @@ const TaskView = () => { }, }); + // Массовое удаление задач (только для администратора приложения) + const bulkDeleteMutation = useMutation({ + mutationFn: async (taskIds: number[]) => { + const res = await apiRequest('DELETE', '/api/tasks/bulk', { taskIds }); + if (!res.ok) throw new Error('Ошибка при удалении задач'); + return res.json() as Promise<{ success: boolean; deleted: number; total: number }>; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] }); + setSelectedTaskIds(new Set()); + setDeleteDialogOpen(false); + toast({ title: 'Удалено', description: `Удалено задач: ${data.deleted}` }); + }, + onError: (err: any) => { + toast({ title: 'Ошибка', description: err.message, variant: 'destructive' }); + }, + }); + + const toggleTask = (taskId: number) => { + setSelectedTaskIds(prev => { + const next = new Set(prev); + if (next.has(taskId)) next.delete(taskId); + else next.add(taskId); + return next; + }); + }; + + const selectAllVisible = () => { + const visibleIds = tasks.map(t => t.id); + const allSelected = visibleIds.length > 0 && visibleIds.every(id => selectedTaskIds.has(id)); + if (allSelected) { + setSelectedTaskIds(prev => { + const next = new Set(prev); + visibleIds.forEach(id => next.delete(id)); + return next; + }); + } else { + setSelectedTaskIds(prev => { + const next = new Set(prev); + visibleIds.forEach(id => next.add(id)); + return next; + }); + } + }; + + const clearSelection = () => setSelectedTaskIds(new Set()); + // Автооткрытие диалога создания задачи при предзаполнении из URL (QR-коды). // Сначала запускаем автоматизации task.before_create через check-create endpoint. // Диалог открывается только если автоматизации не блокируют создание. @@ -378,31 +428,58 @@ const TaskView = () => { - - { - setLocation(`/forms/${formId}/tasks/${task.id}`); - }} - filters={taskFilters.filters} - sort={taskFilters.sort} - setFilter={taskFilters.setFilter} - clearFilter={taskFilters.clearFilter} - clearAll={taskFilters.clearAll} - setSort={taskFilters.setSort} - clearSort={taskFilters.clearSort} - hasActiveFilters={taskFilters.hasActiveFilters} - searches={taskFilters.searches} - onAddSearch={taskFilters.addSearch} - onRemoveSearch={taskFilters.removeSearch} - /> + + {isAdmin(currentUser) && selectedTaskIds.size > 0 && ( +
+ {selectedTaskIds.size} выбрано + +
+ +
+ )} +
+ { + setLocation(`/forms/${formId}/tasks/${task.id}`); + }} + filters={taskFilters.filters} + sort={taskFilters.sort} + setFilter={taskFilters.setFilter} + clearFilter={taskFilters.clearFilter} + clearAll={taskFilters.clearAll} + setSort={taskFilters.setSort} + clearSort={taskFilters.clearSort} + hasActiveFilters={taskFilters.hasActiveFilters} + searches={taskFilters.searches} + onAddSearch={taskFilters.addSearch} + onRemoveSearch={taskFilters.removeSearch} + isAdmin={isAdmin(currentUser)} + selectedTaskIds={selectedTaskIds} + onToggleSelected={toggleTask} + onToggleSelectAll={selectAllVisible} + /> +
@@ -440,6 +517,37 @@ const TaskView = () => { formId={formId} fields={taskFields} /> + + + + + Удалить задачи + + Будет удалено {selectedTaskIds.size} задач. Это действие нельзя отменить. + + +
+ + +
+
+
); }; diff --git a/client/src/pages/documents/DocumentsPage.tsx b/client/src/pages/documents/DocumentsPage.tsx index ea012fa..7f2872f 100644 --- a/client/src/pages/documents/DocumentsPage.tsx +++ b/client/src/pages/documents/DocumentsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from "react"; +import { useState, useMemo, useRef } from "react"; import { Link, useLocation } from "wouter"; import { DocumentLayout } from "@/components/documents/DocumentLayout"; import { Button } from "@/components/ui/button"; @@ -19,20 +19,87 @@ import { useDocumentTemplates, useDeleteDocumentTemplate, useImportDocxTemplate, + useMoveDocumentTemplateToFolder, } from "@/hooks/useDocuments"; -import { Plus, FileText, Pencil, Trash2, Upload } from "lucide-react"; +import { documentFolderHooks, type Folder as FolderType } from "@/hooks/useFolders"; +import { CreateFolderDialog } from "@/components/folders/CreateFolderDialog"; +import { FolderMappingEditor } from "@/components/documents/FolderMappingEditor"; +import { TemplateFolderTree, parseFolderSortableId, parseTemplateDraggableId, folderGapId, folderSortableId, folderDroppableId } from "@/components/documents/TemplateFolderTree"; +import { Plus, FileText, Pencil, Trash2, Upload, Search, X, Folder } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { queryClient, apiRequest } from "@/lib/queryClient"; +import { + DndContext, + DragEndEvent, + DragOverEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, + defaultDropAnimationSideEffects, +} from '@dnd-kit/core'; + +interface TemplateItem { + id: number; + name: string; + description?: string | null; + status: string; + folderId?: number | null; +} + +const dropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { active: { opacity: '0.5' } }, + }), +}; export function DocumentsPage() { - const { data: templates, isLoading } = useDocumentTemplates(); + const { data: templatesData, isLoading: isLoadingTemplates } = useDocumentTemplates(); + const { data: foldersData } = documentFolderHooks.useFolders(); const deleteTemplate = useDeleteDocumentTemplate(); const importDocx = useImportDocxTemplate(); + const createFolderMutation = documentFolderHooks.useCreateFolder(); + const updateFolderMutation = documentFolderHooks.useUpdateFolder(); + const deleteFolderMutation = documentFolderHooks.useDeleteFolder(); + const reorderFoldersMutation = documentFolderHooks.useReorderFolders(); + const moveTemplateToFolderMutation = useMoveDocumentTemplateToFolder(); const [, setLocation] = useLocation(); + const { toast } = useToast(); + + const templates = templatesData || []; + const folders = foldersData?.folders || []; const [importOpen, setImportOpen] = useState(false); const [importFile, setImportFile] = useState(null); const [importName, setImportName] = useState(""); const fileInputRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(''); + const [expanded, setExpanded] = useState>(new Set()); + const [selectedFolderId, setSelectedFolderId] = useState(null); + const [folderDialogOpen, setFolderDialogOpen] = useState(false); + const [editingFolder, setEditingFolder] = useState(null); + const [creatingSubfolderParentId, setCreatingSubfolderParentId] = useState(null); + const [mappingFolderId, setMappingFolderId] = useState(null); + const [mappingDialogOpen, setMappingDialogOpen] = useState(false); + const [activeId, setActiveId] = useState(null); + const [overFolderId, setOverFolderId] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) + ); + + const filteredTemplates = useMemo(() => { + if (!searchQuery.trim() && selectedFolderId === null) return templates; + const q = searchQuery.toLowerCase(); + return templates.filter((t) => { + const matchesSearch = !searchQuery.trim() || t.name.toLowerCase().includes(q) || (t.description ?? '').toLowerCase().includes(q); + const matchesFolder = selectedFolderId === null || (t.folderId ?? null) === selectedFolderId; + return matchesSearch && matchesFolder; + }); + }, [templates, searchQuery, selectedFolderId]); + const handleImport = () => { if (!importFile) return; importDocx.mutate( @@ -48,135 +115,373 @@ export function DocumentsPage() { ); }; + const toggleExpand = (id: number) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const openCreateFolderDialog = (parentId: number | null) => { + setCreatingSubfolderParentId(parentId); + setEditingFolder(null); + setFolderDialogOpen(true); + }; + + const openEditFolderDialog = (folder: FolderType) => { + setEditingFolder(folder); + setCreatingSubfolderParentId(null); + setFolderDialogOpen(true); + }; + + const handleFolderSubmit = (name: string) => { + if (editingFolder) { + updateFolderMutation.mutate({ id: editingFolder.id, name }); + } else { + createFolderMutation.mutate({ + name, + parentId: creatingSubfolderParentId, + position: folders.filter((f) => f.parentId === creatingSubfolderParentId).length, + }); + } + }; + + const handleDeleteFolder = (folder: FolderType) => { + if (confirm(`Удалить папку «${folder.name}»? Шаблоны из папки останутся, дочерние папки поднимутся на уровень выше.`)) { + deleteFolderMutation.mutate(folder.id); + } + }; + + const openMappingDialog = (folder: FolderType) => { + setMappingFolderId(folder.id); + setMappingDialogOpen(true); + }; + + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id); + }; + + const handleDragOver = (event: DragOverEvent) => { + const { over } = event; + if (!over) { + setOverFolderId(null); + return; + } + const overData = over.data.current as any; + if (overData?.type === 'folder') { + setOverFolderId(overData.folderId as number); + } else { + setOverFolderId(null); + } + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + setOverFolderId(null); + if (!over) return; + + const activeData = active.data.current as any; + const overData = over.data.current as any; + if (!activeData) return; + + if (activeData.type === 'template') { + const templateId = activeData.templateId as number; + let targetFolderId: number | null = null; + if (overData?.type === 'folder') { + targetFolderId = overData.folderId as number; + } else if (over.id === 'root-drop') { + targetFolderId = null; + } + if (targetFolderId !== undefined) { + moveTemplateToFolderMutation.mutate({ templateId, folderId: targetFolderId }); + } + return; + } + + if (activeData.type === 'folder') { + const folderId = activeData.folderId as number; + const folder = folders.find((f) => f.id === folderId); + if (!folder) return; + + let parentId: number | null = folder.parentId; + let position = folder.position; + + if (overData?.type === 'folder') { + parentId = overData.folderId as number; + position = 0; + } else if (overData?.type === 'gap') { + parentId = overData.parentId as number | null; + position = overData.position as number; + } + + // Нельзя переносить папку внутрь самой себя или своих потомков + if (parentId !== null && isDescendant(folders, folderId, parentId)) return; + + const siblings = folders + .filter((f) => (f.parentId ?? null) === (parentId ?? null) && f.id !== folderId) + .sort((a, b) => a.position - b.position); + const reordered = [...siblings]; + reordered.splice(position, 0, folder); + + const updates = reordered.map((f, idx) => ({ id: f.id, parentId, position: idx })); + reorderFoldersMutation.mutate(updates); + } + }; + + const activeFolder = activeId && String(activeId).startsWith('folder-') + ? folders.find((f) => f.id === parseFolderSortableId(activeId as string)) + : undefined; + const activeTemplate = activeId && String(activeId).startsWith('template-') + ? templates.find((t) => t.id === parseTemplateDraggableId(activeId as string)) + : undefined; + return ( -
-
-
-

Шаблоны документов

-

- Создавайте и редактируйте шаблоны для генерации документов -

-
-
- - - - +
+
+
+ + setSearchQuery(e.target.value)} + /> +
+ {searchQuery && ( + + )} +
+
+ {isLoadingTemplates ? ( +
Загрузка...
+ ) : ( + ({ id: t.id, name: t.name, status: t.status, folderId: t.folderId }))} + expanded={expanded} + selectedFolderId={selectedFolderId} + onToggleExpand={toggleExpand} + onSelectFolder={setSelectedFolderId} + onEditFolder={openEditFolderDialog} + onDeleteFolder={handleDeleteFolder} + onCreateSubfolder={(parentId) => openCreateFolderDialog(parentId)} + onEditMappings={openMappingDialog} + onSelectTemplate={(template) => setLocation(`/documents/templates/${template.id}/edit`)} + onEditTemplate={(template) => setLocation(`/documents/templates/${template.id}/edit`)} + onDeleteTemplate={(template) => { + if (confirm("Удалить шаблон?")) { + deleteTemplate.mutate(template.id); + } + }} + overFolderId={overFolderId} + searchQuery={searchQuery} + /> + )} +
+
+ + {/* Main: templates */} +
+
+
+

+ {selectedFolderId + ? folders.find((f) => f.id === selectedFolderId)?.name || 'Шаблоны' + : 'Все шаблоны'} +

+

+ {searchQuery ? `Найдено: ${filteredTemplates.length}` : `${filteredTemplates.length} шаблонов`} +

+
+
+ {selectedFolderId !== null && ( + + )} + + + + +
+
+ + {isLoadingTemplates ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : filteredTemplates.length === 0 ? ( + + + +

+ {searchQuery ? 'Ничего не найдено' : 'Пока нет ни одного шаблона'} +

+ {!searchQuery && ( + + + + )} +
+
+ ) : ( +
+ {filteredTemplates.map((template) => ( + + +
+
+ {template.name} + + {template.description || "Без описания"} + +
+ + {template.status === "active" ? "Активен" : "Черновик"} + +
+
+ +
+ + + + +
+
+
+ ))} +
+ )}
- - - - Импорт шаблона из DOCX - - Загрузите готовый документ Word. CRM извлечёт текст, картинки и создаст черновик шаблона. - - -
-
- - setImportFile(e.target.files?.[0] || null)} - /> -
-
- - setImportName(e.target.value)} - placeholder="Например, Коммерческое предложение" - /> -
- {importDocx.isPending && ( -

Идёт импорт, пожалуйста, подождите…

- )} + + {activeFolder ? ( +
+ + {activeFolder.name}
- - - - - -
+ ) : activeTemplate ? ( +
+ {activeTemplate.name} +
+ ) : null} + + - {isLoading ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} + + + + Импорт шаблона из DOCX + + Загрузите готовый документ Word. CRM извлечёт текст, картинки и создаст черновик шаблона. + + +
+
+ + setImportFile(e.target.files?.[0] || null)} + /> +
+
+ + setImportName(e.target.value)} + placeholder="Например, Коммерческое предложение" + /> +
+ {importDocx.isPending && ( +

Идёт импорт, пожалуйста, подождите…

+ )}
- ) : templates?.length === 0 ? ( - - - -

Пока нет ни одного шаблона

- - - -
-
- ) : ( -
- {templates?.map((template) => ( - - -
-
- {template.name} - - {template.description || "Без описания"} - -
- - {template.status === "active" ? "Активен" : "Черновик"} - -
-
- -
- - - - -
-
-
- ))} -
- )} -
+ + + + + +
+ + f.id === creatingSubfolderParentId)?.name : null} + /> + + ); } + +function isDescendant(folders: FolderType[], ancestorId: number, childId: number): boolean { + const folder = folders.find((f) => f.id === childId); + if (!folder || folder.parentId === null) return false; + if (folder.parentId === ancestorId) return true; + return isDescendant(folders, ancestorId, folder.parentId); +} diff --git a/client/src/pages/documents/TemplateEditorPage.tsx b/client/src/pages/documents/TemplateEditorPage.tsx index f725a57..3e9f0e0 100644 --- a/client/src/pages/documents/TemplateEditorPage.tsx +++ b/client/src/pages/documents/TemplateEditorPage.tsx @@ -45,10 +45,11 @@ interface FormFieldItem { type: string; } -const SOURCE_OPTIONS: { value: string; label: string }[] = [ +export const SOURCE_OPTIONS: { value: string; label: string }[] = [ { value: "static", label: "Значение по умолчанию" }, { value: "task_field", label: "Поле задачи" }, { value: "task_system", label: "Системное поле задачи" }, + { value: "related_task_field", label: "Поле связанной задачи" }, { value: "task_table_tab", label: "Таблица задачи" }, { value: "asset", label: "Актив (печать / подпись)" }, { value: "assignee", label: "Исполнитель" }, @@ -58,7 +59,14 @@ const SOURCE_OPTIONS: { value: string; label: string }[] = [ { value: "current_date", label: "Текущая дата" }, ]; -const TASK_SYSTEM_KEYS = [ +export const RELATED_TASK_RELATION_TYPES = [ + { value: "task_field", label: "Поле задачи (task)" }, + { value: "parent", label: "Родительская задача" }, + { value: "child", label: "Дочерняя задача" }, + { value: "contract_legal_entity", label: "Юрлицо по договору" }, +]; + +export const TASK_SYSTEM_KEYS = [ { value: "title", label: "Название задачи" }, { value: "description", label: "Описание задачи" }, { value: "dueDate", label: "Срок выполнения" }, @@ -68,7 +76,7 @@ const TASK_SYSTEM_KEYS = [ { value: "id", label: "ID задачи" }, ]; -const USER_SYSTEM_KEYS = [ +export const USER_SYSTEM_KEYS = [ { value: "fullName", label: "ФИО" }, { value: "lastName", label: "Фамилия" }, { value: "firstName", label: "Имя" }, @@ -78,12 +86,12 @@ const USER_SYSTEM_KEYS = [ { value: "initials", label: "Инициалы" }, ]; -const ORG_SYSTEM_KEYS = [ +export const ORG_SYSTEM_KEYS = [ { value: "name", label: "Название" }, { value: "displayName", label: "Отображаемое название" }, ]; -const TRANSFORM_OPTIONS: { value: string; label: string }[] = [ +export const TRANSFORM_OPTIONS: { value: string; label: string }[] = [ { value: "none", label: "Без преобразования" }, { value: "uppercase", label: "ВЕРХНИЙ РЕГИСТР" }, { value: "lowercase", label: "нижний регистр" }, @@ -97,7 +105,7 @@ const TRANSFORM_OPTIONS: { value: string; label: string }[] = [ { value: "currency_number", label: "Сумма числом (2 знака)" }, ]; -const DECLENSION_CASES = [ +export const DECLENSION_CASES = [ { value: "nominative", label: "Именительный (кто?)" }, { value: "genitive", label: "Родительный (кого?)" }, { value: "dative", label: "Дательный (кому?)" }, @@ -106,7 +114,7 @@ const DECLENSION_CASES = [ { value: "prepositional", label: "Предложный (о ком?)" }, ]; -const NAME_PART_OPTIONS = [ +export const NAME_PART_OPTIONS = [ { value: "lastName", label: "Фамилия" }, { value: "firstName", label: "Имя" }, { value: "middleName", label: "Отчество" }, @@ -115,14 +123,14 @@ const NAME_PART_OPTIONS = [ { value: "fullName", label: "Полное ФИО" }, ]; -const CURRENCY_OPTIONS = [ +export const CURRENCY_OPTIONS = [ { value: "rub", label: "Рубли" }, { value: "usd", label: "Доллары" }, { value: "eur", label: "Евро" }, { value: "none", label: "Без валюты" }, ]; -function getTransformConfig( +export function getTransformConfig( variable: Partial, ): { type: string; params?: Record } | null { const mapping = (variable.mapping || {}) as Record; @@ -133,7 +141,7 @@ function getTransformConfig( return null; } -function TransformParams({ +export function TransformParams({ variable, onChange, }: { @@ -235,7 +243,7 @@ const ASSET_TYPES = [ { value: "image", label: "Изображение" }, ]; -function useFormFieldsList(formId: string) { +export function useFormFieldsList(formId: string) { return useQuery<{ success: boolean; fields: FormFieldItem[]; @@ -249,7 +257,73 @@ function useFormFieldsList(formId: string) { }); } -function AssetMapping({ +function TargetFieldSelector({ + formId, + value, + onChange, + disabled, +}: { + formId: string; + value: string; + onChange: (value: string) => void; + disabled?: boolean; +}) { + const { data: fieldsData, isLoading } = useFormFieldsList(formId); + const fileFields = (fieldsData?.fields || []).filter((f) => f.type === "file"); + + return ( +
+ + +
+ ); +} + +function TargetSourceFieldSelector({ + formId, + value, + onChange, +}: { + formId: string; + value: string; + onChange: (value: string) => void; +}) { + const { data: fieldsData, isLoading } = useFormFieldsList(formId); + const taskFields = (fieldsData?.fields || []).filter((f) => f.type === "task"); + + return ( +
+ + +
+ ); +} + +export function AssetMapping({ forms, mapping, onChange, @@ -380,7 +454,7 @@ function AssetMapping({ ); } -function TaskTableTabMapping({ +export function TaskTableTabMapping({ forms, mapping, onChange, @@ -450,7 +524,151 @@ function TaskTableTabMapping({ ); } -function TaskFieldMapping({ +export function RelatedTaskFieldMapping({ + forms, + mapping, + onChange, +}: { + forms: FormItem[]; + mapping: Record; + onChange: (patch: Partial) => void; +}) { + const relationType = mapping.relationType || "task_field"; + const sourceFormId = mapping.sourceFormId ? String(mapping.sourceFormId) : ""; + const targetFormId = mapping.targetFormId ? String(mapping.targetFormId) : ""; + + const { data: sourceFieldsData, isLoading: isLoadingSourceFields } = useFormFieldsList(sourceFormId); + const { data: targetFieldsData, isLoading: isLoadingTargetFields } = useFormFieldsList(targetFormId); + + const taskFields = (sourceFieldsData?.fields || []).filter((f) => f.type === "task"); + + const handleRelationChange = (value: string) => { + onChange({ + source: "related_task_field", + mapping: { + relationType: value as any, + sourceFieldCode: undefined, + sourceFormId: undefined, + targetFormId: undefined, + targetFieldCode: undefined, + }, + }); + }; + + return ( +
+ + + {relationType === "task_field" && ( + <> + + + + + )} + + + + +
+ ); +} + +export function TaskFieldMapping({ forms, mapping, onChange, @@ -567,6 +785,10 @@ export function TemplateEditorPage() { const [status, setStatus] = useState("draft"); const [variables, setVariables] = useState[]>([]); const [docxUploading, setDocxUploading] = useState(false); + const [targetFormId, setTargetFormId] = useState(""); + const [targetFieldCode, setTargetFieldCode] = useState(""); + const [targetTaskSource, setTargetTaskSource] = useState("current"); + const [targetSourceFieldCode, setTargetSourceFieldCode] = useState(""); const fileInputRef = useRef(null); useEffect(() => { @@ -576,6 +798,10 @@ export function TemplateEditorPage() { setCategoryId(existingTemplate.categoryId ? String(existingTemplate.categoryId) : ""); setStatus(existingTemplate.status); setVariables(existingTemplate.variables || []); + setTargetFormId(existingTemplate.targetFormId ? String(existingTemplate.targetFormId) : ""); + setTargetFieldCode(existingTemplate.targetFieldCode || ""); + setTargetTaskSource(existingTemplate.targetTaskSource || "current"); + setTargetSourceFieldCode(existingTemplate.targetSourceFieldCode || ""); } }, [existingTemplate]); @@ -587,6 +813,10 @@ export function TemplateEditorPage() { categoryId: categoryId ? Number(categoryId) : null, status, margins: DEFAULT_MARGINS, + targetFormId: targetFormId ? Number(targetFormId) : null, + targetFieldCode: targetFieldCode || null, + targetTaskSource: targetTaskSource || "current", + targetSourceFieldCode: targetSourceFieldCode || null, variables: variables.map((v) => ({ id: v.id, code: v.code || "", @@ -839,6 +1069,62 @@ export function TemplateEditorPage() { + + + + Сохранение результата + + +
+ + +
+ + + +
+ + +
+ + {targetTaskSource === "related_task_field" && ( + + )} + +

+ После генерации документ будет автоматически добавлен в выбранное файловое поле. +

+
+
@@ -858,16 +1144,18 @@ export function TemplateEditorPage() { ) : ( variables.map((variable, index) => { const source = variable.source || "static"; - const mapping = (variable.mapping || {}) as Record; + const mapping = (variable.mapping || {}) as Record; + const isFolderMapping = (variable as any).isFolderMapping; return ( -
+
- + updateVariable(index, { code: e.target.value })} placeholder="{{code}}" className="mt-1" + disabled={isFolderMapping} />
@@ -877,6 +1165,7 @@ export function TemplateEditorPage() { onChange={(e) => updateVariable(index, { label: e.target.value })} placeholder="Название" className="mt-1" + disabled={isFolderMapping} />
@@ -884,6 +1173,7 @@ export function TemplateEditorPage() { updateMapping(index, { systemKey: value })} - > - - - - - {USER_SYSTEM_KEYS.map((opt) => ( - - {opt.label} - - ))} - - - )} - {source === "organization" && ( - - )} - {(source === "static" || source === "current_date") && ( - updateVariable(index, { defaultValue: e.target.value })} - placeholder={source === "current_date" ? "Формат даты" : "Значение по умолчанию"} - className="mt-1" - /> + {isFolderMapping ? ( +

Наследуется из папки шаблонов

+ ) : ( + <> + {(source === "task_field" || source === "task_system") && ( + updateVariable(index, patch)} + /> + )} + {source === "related_task_field" && ( + updateVariable(index, patch)} + /> + )} + {source === "task_table_tab" && ( + updateVariable(index, patch)} + /> + )} + {source === "asset" && ( + updateVariable(index, patch)} + /> + )} + {(source === "assignee" || source === "creator" || source === "current_user") && ( + + )} + {source === "organization" && ( + + )} + {(source === "static" || source === "current_date") && ( + updateVariable(index, { defaultValue: e.target.value })} + placeholder={source === "current_date" ? "Формат даты" : "Значение по умолчанию"} + className="mt-1" + /> + )} + )}
@@ -970,38 +1273,43 @@ export function TemplateEditorPage() { onChange={(e) => updateVariable(index, { defaultValue: e.target.value })} placeholder="-" className="mt-1" + disabled={isFolderMapping} />
- + {!isFolderMapping && ( + + )}
-
-
- - + {!isFolderMapping && ( +
+
+ + +
+ updateTransformParams(index, params)} + />
- updateTransformParams(index, params)} - /> -
+ )}
); }) diff --git a/migrations/0040_document_template_folders_and_targets.sql b/migrations/0040_document_template_folders_and_targets.sql new file mode 100644 index 0000000..f9c4a9c --- /dev/null +++ b/migrations/0040_document_template_folders_and_targets.sql @@ -0,0 +1,53 @@ +-- Document Center: template folders, folder-level variable mappings, and generation targets +-- Safe to run multiple times (CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS). + +CREATE TABLE IF NOT EXISTS "document_template_folders" ( + "id" serial PRIMARY KEY NOT NULL, + "organization_id" integer NOT NULL, + "parent_id" integer, + "name" varchar(100) NOT NULL, + "position" integer DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); +--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "document_folder_mappings" ( + "id" serial PRIMARY KEY NOT NULL, + "folder_id" integer NOT NULL, + "code" varchar(100) NOT NULL, + "label" varchar(200), + "description" text, + "source" varchar(30) NOT NULL, + "mapping" jsonb DEFAULT '{}'::jsonb, + "default_value" text, + "sort_order" integer DEFAULT 0, + "created_at" timestamp DEFAULT now() +); +--> statement-breakpoint + +-- Add folder and target columns to document_templates +ALTER TABLE "document_templates" ADD COLUMN IF NOT EXISTS "folder_id" integer; +ALTER TABLE "document_templates" ADD COLUMN IF NOT EXISTS "target_form_id" integer; +ALTER TABLE "document_templates" ADD COLUMN IF NOT EXISTS "target_field_code" varchar(100); +ALTER TABLE "document_templates" ADD COLUMN IF NOT EXISTS "target_task_source" varchar(20) DEFAULT 'current'; +ALTER TABLE "document_templates" ADD COLUMN IF NOT EXISTS "target_source_field_code" varchar(100); + +-- Foreign keys +ALTER TABLE "document_template_folders" DROP CONSTRAINT IF EXISTS "document_template_folders_organization_id_organizations_id_fk"; +ALTER TABLE "document_template_folders" ADD CONSTRAINT "document_template_folders_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE cascade; +-- parent_id не объявляем как FK, чтобы избежать циклической ссылки в Drizzle ORM (как у других деревьев: form_folders, directory_folders, roles) + +ALTER TABLE "document_folder_mappings" DROP CONSTRAINT IF EXISTS "document_folder_mappings_folder_id_document_template_folders_id_fk"; +ALTER TABLE "document_folder_mappings" ADD CONSTRAINT "document_folder_mappings_folder_id_document_template_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "document_template_folders"("id") ON DELETE cascade; + +ALTER TABLE "document_templates" DROP CONSTRAINT IF EXISTS "document_templates_folder_id_document_template_folders_id_fk"; +ALTER TABLE "document_templates" ADD CONSTRAINT "document_templates_folder_id_document_template_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "document_template_folders"("id") ON DELETE set null; +ALTER TABLE "document_templates" DROP CONSTRAINT IF EXISTS "document_templates_target_form_id_forms_id_fk"; +ALTER TABLE "document_templates" ADD CONSTRAINT "document_templates_target_form_id_forms_id_fk" FOREIGN KEY ("target_form_id") REFERENCES "forms"("id") ON DELETE set null; + +-- Indexes +CREATE INDEX IF NOT EXISTS "document_template_folders_org_parent_idx" ON "document_template_folders" ("organization_id", "parent_id"); +CREATE INDEX IF NOT EXISTS "document_template_folders_position_idx" ON "document_template_folders" ("position"); +CREATE UNIQUE INDEX IF NOT EXISTS "document_folder_mappings_code_unique" ON "document_folder_mappings" ("folder_id", "code"); +CREATE INDEX IF NOT EXISTS "document_templates_org_folder_idx" ON "document_templates" ("organization_id", "folder_id"); diff --git a/migrations/0052_idempotency_keys.sql b/migrations/0052_idempotency_keys.sql new file mode 100644 index 0000000..7c04acd --- /dev/null +++ b/migrations/0052_idempotency_keys.sql @@ -0,0 +1,23 @@ +-- Idempotency keys for batch task creation and other heavy write endpoints +CREATE TABLE IF NOT EXISTS idempotency_keys ( + id SERIAL PRIMARY KEY, + key VARCHAR(255) NOT NULL, + organization_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + endpoint VARCHAR(255) NOT NULL, + payload_hash VARCHAR(64) NOT NULL, + response_json JSONB NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + expires_at TIMESTAMP NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idempotency_keys_org_key_idx ON idempotency_keys(organization_id, key); +CREATE INDEX IF NOT EXISTS idempotency_keys_expires_at_idx ON idempotency_keys(expires_at); + +-- Enable tenant isolation for idempotency keys +ALTER TABLE idempotency_keys ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_iso ON idempotency_keys; +CREATE POLICY tenant_iso ON idempotency_keys USING ( + current_setting('app.is_superadmin', true) = 'true' + OR organization_id = NULLIF(current_setting('app.current_org_id', true), '')::int +); diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 16ef461..a7da9be 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1783000000000, "tag": "0039_add_asset_form_link", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1783000001000, + "tag": "0040_document_template_folders_and_targets", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/dev/start-deps.sh b/scripts/dev/start-deps.sh index 6423604..027af5e 100755 --- a/scripts/dev/start-deps.sh +++ b/scripts/dev/start-deps.sh @@ -27,7 +27,7 @@ if [[ "$1" == "--with-ollama" ]]; then fi echo "=== Starting CRM dev dependencies (project: ${PROJECT_NAME}) ===" -docker compose -f "${COMPOSE_FILE}" -p "${PROJECT_NAME}" "${EXTRA_PROFILES[@]}" up -d +docker compose --env-file "${PROJECT_ROOT}/.env.local" -f "${COMPOSE_FILE}" -p "${PROJECT_NAME}" "${EXTRA_PROFILES[@]}" up -d echo "" echo "Dependencies are starting up:" diff --git a/scripts/dev/start-dev.ps1 b/scripts/dev/start-dev.ps1 new file mode 100644 index 0000000..791f49d --- /dev/null +++ b/scripts/dev/start-dev.ps1 @@ -0,0 +1,38 @@ +# Start the CRM app in native development mode on Windows. +# Dependencies must already be running (see start-deps.sh / docker-compose.deps.yml). +# Usage: .\scripts\dev\start-dev.ps1 + +$ErrorActionPreference = "Stop" + +$ProjectRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +Set-Location $ProjectRoot + +if (-not (Test-Path ".env.local")) { + Write-Error "ERROR: .env.local not found." + exit 1 +} + +# Load environment variables from .env.local +Get-Content ".env.local" | ForEach-Object { + $line = $_.Trim() + if ($line -and -not $line.StartsWith("#")) { + $idx = $line.IndexOf("=") + if ($idx -gt 0) { + $key = $line.Substring(0, $idx).Trim() + $value = $line.Substring($idx + 1).Trim() + # Remove surrounding quotes if present + if ($value.StartsWith('"') -and $value.EndsWith('"')) { + $value = $value.Substring(1, $value.Length - 2) + } + [Environment]::SetEnvironmentVariable($key, $value, "Process") + } + } +} + +$env:NODE_ENV = "development" + +Write-Host "=== Starting CRM dev server ===" +Write-Host "Make sure dependencies are running: docker compose --env-file .env.local -f docker-compose.deps.yml -p crm-local up -d" +Write-Host "" + +& .\node_modules\.bin\tsx server\index.ts diff --git a/scripts/dev/stop-deps.sh b/scripts/dev/stop-deps.sh index 72cee1b..0aaffb1 100755 --- a/scripts/dev/stop-deps.sh +++ b/scripts/dev/stop-deps.sh @@ -10,4 +10,4 @@ PROJECT_NAME="crm-local" COMPOSE_FILE="${PROJECT_ROOT}/docker-compose.deps.yml" echo "=== Stopping CRM dev dependencies (project: ${PROJECT_NAME}) ===" -docker compose -f "${COMPOSE_FILE}" -p "${PROJECT_NAME}" down +docker compose --env-file "${PROJECT_ROOT}/.env.local" -f "${COMPOSE_FILE}" -p "${PROJECT_NAME}" down diff --git a/scripts/dev/sync-from-prod-deps.sh b/scripts/dev/sync-from-prod-deps.sh index ee41cec..9e84a07 100755 --- a/scripts/dev/sync-from-prod-deps.sh +++ b/scripts/dev/sync-from-prod-deps.sh @@ -51,10 +51,10 @@ ssh -o IdentitiesOnly=yes -i "${SSH_KEY}" "${SSH_HOST}" \ echo "Dump saved to: ${DUMP_FILE}" echo "=== 3. Stopping and recreating local database ===" -docker compose -f "${COMPOSE_FILE}" -p "${PROJECT_NAME}" stop db -docker compose -f "${COMPOSE_FILE}" -p "${PROJECT_NAME}" rm -f db +docker compose --env-file "${PROJECT_ROOT}/.env.local" -f "${COMPOSE_FILE}" -p "${PROJECT_NAME}" stop db +docker compose --env-file "${PROJECT_ROOT}/.env.local" -f "${COMPOSE_FILE}" -p "${PROJECT_NAME}" rm -f db docker volume rm "${PROJECT_NAME}_postgres_data" >/dev/null 2>&1 || true -docker compose -f "${COMPOSE_FILE}" -p "${PROJECT_NAME}" up -d db +docker compose --env-file "${PROJECT_ROOT}/.env.local" -f "${COMPOSE_FILE}" -p "${PROJECT_NAME}" up -d db echo "=== 4. Waiting for local database ===" for i in {1..30}; do diff --git a/server/documents/data-resolution.service.ts b/server/documents/data-resolution.service.ts index 9ea7808..ee9c307 100644 --- a/server/documents/data-resolution.service.ts +++ b/server/documents/data-resolution.service.ts @@ -1,6 +1,6 @@ import { db } from "../db"; -import { tasks, taskFieldValues, users, organizations, forms, formFields, formTabs, regularTableRows } from "@shared/schema"; -import { eq, and, asc } from "drizzle-orm"; +import { tasks, taskFieldValues, users, organizations, forms, formFields, formTabs, regularTableRows, taskRelations } from "@shared/schema"; +import { eq, and, asc, sql } from "drizzle-orm"; import type { TemplateVariable } from "@shared/schema"; import { TransformService, type TransformConfig, type TransformType } from "./transform.service"; import * as storage from "../storage"; @@ -110,6 +110,10 @@ export class DataResolutionService { tabId?: number; transform?: TransformConfig | TransformType | string; locale?: string; + relationType?: 'task_field' | 'parent' | 'child' | 'contract_legal_entity'; + sourceFieldCode?: string; + targetFormId?: number; + targetFieldCode?: string; }; let rawValue: string | null = null; @@ -149,8 +153,12 @@ export class DataResolutionService { break; } case 'related_task_field': { - // TODO: реализовать при необходимости - rawValue = null; + rawValue = await this.resolveRelatedTaskField( + variable, + mapping, + context, + organizationId, + ); break; } case 'task_table_tab': { @@ -327,4 +335,108 @@ export class DataResolutionService { if (!asset) return null; return `__ASSET__:${asset.fileKey}`; } + + private async resolveRelatedTaskField( + variable: TemplateVariable, + mapping: Record, + context: ResolutionContext, + organizationId: number, + ): Promise { + const relationType = mapping.relationType || 'task_field'; + const targetFormId = mapping.targetFormId ? Number(mapping.targetFormId) : undefined; + const targetFieldCode = mapping.targetFieldCode as string | undefined; + + if (!targetFieldCode) return null; + + const relatedTaskId = await this.getRelatedTaskId( + relationType, + mapping.sourceFieldCode as string | undefined, + context, + organizationId, + targetFormId, + ); + + if (!relatedTaskId) return null; + + const relatedFieldValues = await this.getRelatedTaskFieldValues(relatedTaskId); + const fv = relatedFieldValues.find((f) => f.code === targetFieldCode); + if (!fv) return null; + + return this.extractFieldDisplayValue(fv.value, fv.type); + } + + private async getRelatedTaskId( + relationType: string, + sourceFieldCode: string | undefined, + context: ResolutionContext, + organizationId: number, + targetFormId?: number, + ): Promise { + switch (relationType) { + case 'task_field': { + if (!sourceFieldCode) return null; + const fv = context.fieldValues.find((f) => f.code === sourceFieldCode); + if (!fv) return null; + const value = fv.value; + if (value === null || value === undefined || value === '') return null; + // task-поле хранит ID связанной задачи числом + if (typeof value === 'number') return value; + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; + } + case 'contract_legal_entity': { + // Ищем поле типа contract-number и берём legalEntityTaskId + const contractFv = context.fieldValues.find((f) => f.type === 'contract-number'); + if (!contractFv || !contractFv.value) return null; + const obj = typeof contractFv.value === 'string' ? JSON.parse(contractFv.value) : contractFv.value; + const legalEntityTaskId = obj?.legalEntityTaskId; + if (legalEntityTaskId && typeof legalEntityTaskId === 'number') return legalEntityTaskId; + return null; + } + case 'parent': + case 'child': { + const relations = await db + .select() + .from(taskRelations) + .where(and( + eq(taskRelations.organizationId, organizationId), + relationType === 'parent' + ? eq(taskRelations.childTaskId, context.task.id) + : eq(taskRelations.parentTaskId, context.task.id), + )); + + if (relations.length === 0) return null; + + const candidateIds = relations.map((r) => + relationType === 'parent' ? r.parentTaskId : r.childTaskId, + ); + + if (!targetFormId) return candidateIds[0] || null; + + for (const id of candidateIds) { + const [relatedTask] = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1); + if (relatedTask && relatedTask.formId === targetFormId) return id; + } + return null; + } + default: + return null; + } + } + + private async getRelatedTaskFieldValues(taskId: number): Promise> { + return db + .select({ + code: formFields.code, + value: taskFieldValues.value, + type: formFields.type, + name: formFields.name, + }) + .from(taskFieldValues) + .innerJoin(formFields, eq(formFields.id, taskFieldValues.fieldId)) + .where(eq(taskFieldValues.taskId, taskId)); + } } diff --git a/server/documents/docx-converter.service.ts b/server/documents/docx-converter.service.ts index 9f9b6d2..b348965 100644 --- a/server/documents/docx-converter.service.ts +++ b/server/documents/docx-converter.service.ts @@ -18,7 +18,11 @@ export class DOCXConverterService { lang: options.lang || 'ru-RU', }; - const result = await HTMLtoDOCX(html, null, opts); + const convertFn = typeof HTMLtoDOCX === 'function' ? HTMLtoDOCX : (HTMLtoDOCX as any).default; + if (typeof convertFn !== 'function') { + throw new Error('HTMLtoDOCX converter is not available'); + } + const result = await convertFn(html, null, opts); if (result instanceof ArrayBuffer) { return Buffer.from(result); } diff --git a/server/documents/generation.service.ts b/server/documents/generation.service.ts index da7704f..a831526 100644 --- a/server/documents/generation.service.ts +++ b/server/documents/generation.service.ts @@ -1,7 +1,8 @@ import { uploadToS3, uploadToS3FromFile, downloadFromS3, isS3Enabled } from "../utils/s3"; import { uploadsDir } from "../utils/upload"; import { db } from "../db"; -import { fileUploads } from "@shared/schema"; +import { fileUploads, formFields, forms, tasks, taskFieldValues } from "@shared/schema"; +import { eq, and } from "drizzle-orm"; import { DocumentTemplateService } from "./template.service"; import { DataResolutionService } from "./data-resolution.service"; import { AssetService } from "./asset.service"; @@ -55,6 +56,11 @@ interface AssetPlacement { opacity?: number; } +interface FileKeyInfo { + key: string; + size: number; +} + export class DocumentGenerationService { private pdfConverter = new PDFConverterService(); private docxConverter = new DOCXConverterService(); @@ -96,7 +102,7 @@ export class DocumentGenerationService { request.userId, ); - const fileKeys: { pdf?: string; docx?: string } = {}; + const fileKeys: { pdf?: FileKeyInfo; docx?: FileKeyInfo } = {}; if (request.outputFormat === 'pdf' || request.outputFormat === 'both') { const pdfBuffer = await this.pdfConverter.convert(html, { @@ -115,7 +121,7 @@ export class DocumentGenerationService { `${template.name || 'document'}-${generation.id}.pdf`, 'application/pdf', ); - fileKeys.pdf = key; + fileKeys.pdf = { key, size: pdfBuffer.length }; await this.trackFile(key, `${template.name || 'document'}-${generation.id}.pdf`, pdfBuffer.length, request); } @@ -129,7 +135,7 @@ export class DocumentGenerationService { `${template.name || 'document'}-${generation.id}.docx`, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', ); - fileKeys.docx = key; + fileKeys.docx = { key, size: docxBuffer.length }; await this.trackFile(key, `${template.name || 'document'}-${generation.id}.docx`, docxBuffer.length, request); } @@ -142,19 +148,20 @@ export class DocumentGenerationService { await documentStorage.updateDocumentGeneration(generation.id, request.organizationId, { status: 'completed', - pdfFileKey: fileKeys.pdf || null, - docxFileKey: fileKeys.docx || null, + pdfFileKey: fileKeys.pdf?.key || null, + docxFileKey: fileKeys.docx?.key || null, substitutedData: resolved, unresolvedVariables: unresolvedVariables.length > 0 ? unresolvedVariables : null, generationDurationMs: Date.now() - startTime, }); await this.attachToTaskChat(request, template.name || 'document', fileKeys); + await this.saveToTargetField(template, request, fileKeys); return { generationId: generation.id, - pdfUrl: fileKeys.pdf ? `/api/files/${fileKeys.pdf}` : undefined, - docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx}` : undefined, + pdfUrl: fileKeys.pdf ? `/api/files/${fileKeys.pdf.key}` : undefined, + docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx.key}` : undefined, unresolvedVariables, status: 'completed', }; @@ -248,7 +255,7 @@ export class DocumentGenerationService { await fs.copyFile(sourcePath, filledPath); } - const fileKeys: { pdf?: string; docx?: string } = {}; + const fileKeys: { pdf?: FileKeyInfo; docx?: FileKeyInfo } = {}; if (request.outputFormat === 'docx' || request.outputFormat === 'both') { const { key } = await uploadToS3FromFile( @@ -256,8 +263,9 @@ export class DocumentGenerationService { `${template.name || 'document'}-${generation.id}.docx`, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', ); - fileKeys.docx = key; - await this.trackFile(key, `${template.name || 'document'}-${generation.id}.docx`, (await fs.stat(filledPath)).size, request); + const docxSize = (await fs.stat(filledPath)).size; + fileKeys.docx = { key, size: docxSize }; + await this.trackFile(key, `${template.name || 'document'}-${generation.id}.docx`, docxSize, request); } if (request.outputFormat === 'pdf' || request.outputFormat === 'both') { @@ -270,27 +278,28 @@ export class DocumentGenerationService { `${template.name || 'document'}-${generation.id}.pdf`, 'application/pdf', ); - fileKeys.pdf = key; + fileKeys.pdf = { key, size: pdfBuffer.length }; await this.trackFile(key, `${template.name || 'document'}-${generation.id}.pdf`, pdfBuffer.length, request); } await documentStorage.updateDocumentGeneration(generation.id, request.organizationId, { status: 'completed', - pdfFileKey: fileKeys.pdf || null, - docxFileKey: fileKeys.docx || null, + pdfFileKey: fileKeys.pdf?.key || null, + docxFileKey: fileKeys.docx?.key || null, substitutedData: resolved, unresolvedVariables: unresolved.length > 0 ? unresolved : null, generationDurationMs: Date.now() - startTime, }); await this.attachToTaskChat(request, template.name || 'document', fileKeys); + await this.saveToTargetField(template, request, fileKeys); await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); return { generationId: generation.id, - pdfUrl: fileKeys.pdf ? `/api/files/${fileKeys.pdf}` : undefined, - docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx}` : undefined, + pdfUrl: fileKeys.pdf ? `/api/files/${fileKeys.pdf.key}` : undefined, + docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx.key}` : undefined, unresolvedVariables: unresolved, status: 'completed', }; @@ -323,7 +332,7 @@ export class DocumentGenerationService { private async attachToTaskChat( request: GenerateRequest, templateName: string, - fileKeys: { pdf?: string; docx?: string }, + fileKeys: { pdf?: FileKeyInfo; docx?: FileKeyInfo }, ): Promise { try { const task = await storage.getTask(request.taskId, request.organizationId); @@ -332,18 +341,18 @@ export class DocumentGenerationService { const attachments: any[] = []; if (fileKeys.pdf) { attachments.push({ - url: `/api/files/${fileKeys.pdf}`, + url: `/api/files/${fileKeys.pdf.key}`, name: `${templateName}.pdf`, mimeType: 'application/pdf', - size: 0, + size: fileKeys.pdf.size, }); } if (fileKeys.docx) { attachments.push({ - url: `/api/files/${fileKeys.docx}`, + url: `/api/files/${fileKeys.docx.key}`, name: `${templateName}.docx`, mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - size: 0, + size: fileKeys.docx.size, }); } @@ -362,6 +371,117 @@ export class DocumentGenerationService { } } + private async saveToTargetField( + template: any, + request: GenerateRequest, + fileKeys: { pdf?: FileKeyInfo; docx?: FileKeyInfo }, + ): Promise { + const targetFormId = template.targetFormId as number | undefined; + const targetFieldCode = template.targetFieldCode as string | undefined; + const targetTaskSource = (template.targetTaskSource as string | undefined) || 'current'; + const targetSourceFieldCode = template.targetSourceFieldCode as string | undefined; + + if (!targetFormId || !targetFieldCode) return; + + try { + const currentTask = await storage.getTask(request.taskId, request.organizationId); + if (!currentTask) return; + + let targetTaskId: number | null = null; + + if (targetTaskSource === 'current') { + targetTaskId = request.taskId; + } else if (targetTaskSource === 'related_task_field') { + if (!targetSourceFieldCode) return; + const sourceFieldValue = await db + .select({ value: taskFieldValues.value }) + .from(taskFieldValues) + .innerJoin(formFields, eq(formFields.id, taskFieldValues.fieldId)) + .where(and( + eq(taskFieldValues.taskId, request.taskId), + eq(formFields.code, targetSourceFieldCode), + )) + .limit(1); + const rawValue = sourceFieldValue[0]?.value; + if (rawValue === null || rawValue === undefined || rawValue === '') return; + if (typeof rawValue === 'number') { + targetTaskId = rawValue; + } else if (typeof rawValue === 'string') { + const parsed = Number(rawValue); + targetTaskId = Number.isFinite(parsed) ? parsed : null; + } else { + return; + } + } + + if (!targetTaskId) return; + + const targetTask = await storage.getTask(targetTaskId, request.organizationId); + if (!targetTask) return; + + const [targetField] = await db + .select() + .from(formFields) + .where(and( + eq(formFields.formId, targetFormId), + eq(formFields.code, targetFieldCode), + )) + .limit(1); + + if (!targetField || targetField.type !== 'file') { + console.warn(`[Documents] Target field ${targetFieldCode} is not a file field or not found`); + return; + } + + const existingValues = await storage.getTaskFieldValues(targetTaskId, request.organizationId); + const existingValue = existingValues.find((v) => v.fieldId === targetField.id); + const existingFiles: Array<{ url: string; name: string; size: number }> = Array.isArray(existingValue?.value) + ? (existingValue.value as any[]) + : []; + + const newFiles: Array<{ url: string; name: string; size: number }> = []; + if (fileKeys.pdf) { + newFiles.push({ + url: `/api/files/${fileKeys.pdf.key}`, + name: `${template.name || 'document'}.pdf`, + size: fileKeys.pdf.size, + }); + } + if (fileKeys.docx) { + newFiles.push({ + url: `/api/files/${fileKeys.docx.key}`, + name: `${template.name || 'document'}.docx`, + size: fileKeys.docx.size, + }); + } + + const mergedFiles = [...existingFiles, ...newFiles]; + + if (existingValue) { + await storage.updateTaskFieldValue(targetTaskId, targetField.id, request.organizationId, { + value: mergedFiles, + }); + } else { + await storage.createTaskFieldValue({ + taskId: targetTaskId, + fieldId: targetField.id, + formId: targetFormId, + value: mergedFiles, + }); + } + + // Обновляем fileUploads: привязываем сгенерированные файлы к целевой задаче/полю + for (const file of newFiles) { + const key = file.url.replace('/api/files/', ''); + await db.update(fileUploads) + .set({ taskId: targetTaskId, fieldId: targetField.id }) + .where(eq(fileUploads.fileKey, key)); + } + } catch (err) { + console.warn('[Documents] Failed to save generated document to target field:', err); + } + } + private async trackFile( key: string, originalName: string, diff --git a/server/documents/routes.ts b/server/documents/routes.ts index 6be81bd..a537625 100644 --- a/server/documents/routes.ts +++ b/server/documents/routes.ts @@ -9,6 +9,7 @@ import { PackageService } from "./package.service"; import { SignatureService } from "./signature.service"; import { DocxImporterService } from "./docx-importer.service"; import * as storage from "../storage/documents.storage"; +import { storage as appStorage } from "../storage/index"; export async function registerDocumentRoutes(app: Express) { const templateService = new DocumentTemplateService(); @@ -49,6 +50,79 @@ export async function registerDocumentRoutes(app: Express) { res.json({ success: true }); }); + // ── Template Folders ─────────────────────────────────────────────────────── + + app.get("/api/documents/folders", authenticateToken, async (req: AuthenticatedRequest, res) => { + const folders = await templateService.listFolders(req.organizationId!); + res.json({ success: true, folders }); + }); + + app.post("/api/documents/folders", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => { + const folder = await templateService.createFolder({ + ...req.body, + organizationId: req.organizationId!, + }); + res.status(201).json({ success: true, folder }); + }); + + app.put("/api/documents/folders/:id", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => { + const folder = await templateService.updateFolder( + Number(req.params.id), + req.organizationId!, + req.body, + ); + if (!folder) return res.status(404).json({ error: "Папка не найдена" }); + res.json({ success: true, folder }); + }); + + app.delete("/api/documents/folders/:id", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => { + try { + await templateService.deleteFolder(Number(req.params.id), req.organizationId!); + res.json({ success: true }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Ошибка удаления папки"; + res.status(400).json({ error: message }); + } + }); + + app.patch("/api/documents/folders/reorder", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => { + try { + await templateService.reorderFolders(req.organizationId!, req.body.updates || []); + res.json({ success: true }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Ошибка изменения порядка папок"; + res.status(400).json({ error: message }); + } + }); + + app.get("/api/documents/folders/:id/mappings", authenticateToken, async (req: AuthenticatedRequest, res) => { + const mappings = await templateService.listFolderMappings(Number(req.params.id), req.organizationId!); + res.json({ success: true, mappings }); + }); + + app.post("/api/documents/folders/:id/mappings", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => { + const mapping = await templateService.createFolderMapping({ + ...req.body, + folderId: Number(req.params.id), + }); + res.status(201).json({ success: true, mapping }); + }); + + app.put("/api/documents/folders/:id/mappings/:mappingId", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => { + const mapping = await templateService.updateFolderMapping( + Number(req.params.mappingId), + Number(req.params.id), + req.body, + ); + if (!mapping) return res.status(404).json({ error: "Маппинг не найден" }); + res.json({ success: true, mapping }); + }); + + app.delete("/api/documents/folders/:id/mappings/:mappingId", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => { + await templateService.deleteFolderMapping(Number(req.params.mappingId), Number(req.params.id)); + res.json({ success: true }); + }); + // ── Templates ────────────────────────────────────────────────────────────── app.get("/api/documents/templates", authenticateToken, async (req: AuthenticatedRequest, res) => { @@ -56,6 +130,7 @@ export async function registerDocumentRoutes(app: Express) { organizationId: req.organizationId!, status: req.query.status as string | undefined, categoryId: req.query.categoryId ? Number(req.query.categoryId) : undefined, + folderId: req.query.folderId ? Number(req.query.folderId) : undefined, formId: req.query.formId ? Number(req.query.formId) : undefined, }); res.json({ success: true, templates }); @@ -132,6 +207,16 @@ export async function registerDocumentRoutes(app: Express) { res.json({ success: true, template }); }); + app.patch("/api/documents/templates/:id/folder", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => { + const template = await templateService.moveTemplateToFolder( + Number(req.params.id), + req.organizationId!, + req.body.folderId === null ? null : Number(req.body.folderId), + ); + if (!template) return res.status(404).json({ error: "Шаблон не найден" }); + res.json({ success: true, template }); + }); + app.delete("/api/documents/templates/:id", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => { try { await templateService.delete(Number(req.params.id), req.organizationId!); @@ -236,6 +321,40 @@ export async function registerDocumentRoutes(app: Express) { } }); + app.get("/api/documents/generate-link", authenticateToken, async (req: AuthenticatedRequest, res) => { + try { + const templateId = Number(req.query.templateId); + const taskId = Number(req.query.taskId); + const format = (req.query.format as string) || 'both'; + const returnTo = (req.query.returnTo as string) || ''; + + if (!templateId || !taskId) { + return res.status(400).json({ error: "templateId и taskId обязательны" }); + } + if (!['pdf', 'docx', 'both'].includes(format)) { + return res.status(400).json({ error: "Недопустимый формат" }); + } + + await generationService.generate({ + templateId, + taskId, + organizationId: req.organizationId!, + userId: req.user!.id, + outputFormat: format as any, + }); + + let redirectUrl = returnTo; + if (!redirectUrl) { + const task = await appStorage.getTask(taskId, req.organizationId!); + redirectUrl = task ? `/forms/${task.formId}/tasks/${taskId}` : '/'; + } + res.redirect(redirectUrl); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Ошибка генерации документа"; + res.status(400).json({ error: message }); + } + }); + app.post("/api/documents/generations/preview", authenticateToken, async (req: AuthenticatedRequest, res) => { try { const preview = await generationService.preview( diff --git a/server/documents/template.service.ts b/server/documents/template.service.ts index b6c000f..cf2ad03 100644 --- a/server/documents/template.service.ts +++ b/server/documents/template.service.ts @@ -8,13 +8,36 @@ import { type InsertDocumentTemplateCategory, type TemplateVariable, type InsertTemplateVariable, + type DocumentFolderMapping, + type DocumentTemplateFolder, + type InsertDocumentTemplateFolder, + type InsertDocumentFolderMapping, } from "@shared/schema"; import * as storage from "../storage/documents.storage"; const VARIABLE_REGEX = /\{\{\s*([^}]+?)\s*\}\}/g; +const VALID_SOURCES = new Set([ + 'static', + 'task_field', + 'task_system', + 'task_table_tab', + 'related_task_field', + 'asset', + 'assignee', + 'creator', + 'organization', + 'current_user', + 'current_date', +]); + +export interface EnrichedVariable extends TemplateVariable { + isFolderMapping?: boolean; + folderMappingId?: number; +} + export interface TemplateWithVariables extends DocumentTemplate { - variables: TemplateVariable[]; + variables: EnrichedVariable[]; category?: DocumentTemplateCategory | null; } @@ -45,12 +68,112 @@ export class DocumentTemplateService { return storage.deleteDocumentTemplateCategory(id, organizationId); } + // ── Folders ──────────────────────────────────────────────────────────────── + + async listFolders(organizationId: number): Promise { + return storage.listDocumentTemplateFolders(organizationId); + } + + async getFolder(id: number, organizationId: number): Promise { + return storage.getDocumentTemplateFolder(id, organizationId); + } + + async createFolder(data: InsertDocumentTemplateFolder): Promise { + return storage.createDocumentTemplateFolder(data); + } + + async updateFolder( + id: number, + organizationId: number, + updates: Partial, + ): Promise { + return storage.updateDocumentTemplateFolder(id, organizationId, updates); + } + + async deleteFolder(id: number, organizationId: number): Promise { + const folder = await this.getFolder(id, organizationId); + if (!folder) throw new Error('Папка не найдена'); + + // Переносим дочерние папки на уровень удаляемой (parentId = удаляемой.parentId) + const allFolders = await this.listFolders(organizationId); + const children = allFolders.filter((f) => f.parentId === id); + for (const child of children) { + await storage.updateDocumentTemplateFolder(child.id, organizationId, { parentId: folder.parentId }); + } + + // Удаляем маппинги папки + await storage.deleteDocumentFolderMappingsByFolder(id); + + // Удаляем саму папку + await storage.deleteDocumentTemplateFolder(id, organizationId); + } + + async reorderFolders( + organizationId: number, + updates: Array<{ id: number; parentId: number | null; position: number }>, + ): Promise { + for (const update of updates) { + const folder = await this.getFolder(update.id, organizationId); + if (!folder) continue; + await storage.updateDocumentTemplateFolder(update.id, organizationId, { + parentId: update.parentId, + position: update.position, + }); + } + } + + async moveTemplateToFolder( + templateId: number, + organizationId: number, + folderId: number | null, + ): Promise { + const template = await storage.getDocumentTemplate(templateId, organizationId); + if (!template) return undefined; + if (folderId !== null) { + const folder = await this.getFolder(folderId, organizationId); + if (!folder) throw new Error('Папка не найдена'); + } + return storage.updateDocumentTemplate(templateId, organizationId, { folderId }); + } + + // ── Folder Mappings ──────────────────────────────────────────────────────── + + async listFolderMappings(folderId: number, organizationId: number): Promise { + const folder = await this.getFolder(folderId, organizationId); + if (!folder) throw new Error('Папка не найдена'); + return storage.listDocumentFolderMappings(folderId); + } + + async createFolderMapping(data: InsertDocumentFolderMapping): Promise { + this.validateVariable(data, 0); + return storage.createDocumentFolderMapping(data); + } + + async updateFolderMapping( + id: number, + folderId: number, + updates: Partial, + ): Promise { + if (updates.source !== undefined || updates.mapping !== undefined) { + this.validateVariable( + { source: updates.source || 'static', mapping: updates.mapping || {} }, + 0, + ); + } + return storage.updateDocumentFolderMapping(id, folderId, updates); + } + + async deleteFolderMapping(id: number, folderId: number): Promise { + return storage.deleteDocumentFolderMapping(id, folderId); + } + // ── Templates ────────────────────────────────────────────────────────────── async list(filters: { organizationId: number; status?: string; categoryId?: number; + folderId?: number; formId?: number; }): Promise { return storage.listDocumentTemplates(filters); @@ -59,26 +182,72 @@ export class DocumentTemplateService { async getById(id: number, organizationId: number): Promise { const template = await storage.getDocumentTemplate(id, organizationId); if (!template) return undefined; - const variables = await storage.listTemplateVariables(id); + const templateVariables = await storage.listTemplateVariables(id); const category = template.categoryId ? await this.getCategory(template.categoryId, organizationId) : null; + const variables = await this.mergeFolderVariables(template, templateVariables); return { ...template, variables, category: category || undefined }; } + private async mergeFolderVariables( + template: DocumentTemplate, + templateVariables: TemplateVariable[], + ): Promise { + if (!template.folderId) { + return templateVariables.map((v) => ({ ...v, isFolderMapping: false })); + } + + const folderMappings = await storage.listDocumentFolderMappings(template.folderId); + const merged = new Map(); + + for (const m of folderMappings) { + merged.set(m.code, this.mappingToVariable(m, template.id)); + } + + // Переменные шаблона переопределяют маппинги папки + for (const v of templateVariables) { + merged.set(v.code, { ...v, isFolderMapping: false }); + } + + return Array.from(merged.values()).sort((a, b) => { + const orderDiff = (a.sortOrder ?? 0) - (b.sortOrder ?? 0); + if (orderDiff !== 0) return orderDiff; + return (a.id ?? 0) - (b.id ?? 0); + }); + } + + private mappingToVariable(mapping: DocumentFolderMapping, templateId: number): EnrichedVariable { + return { + id: -mapping.id, + templateId, + code: mapping.code, + label: mapping.label, + description: mapping.description, + source: mapping.source, + mapping: mapping.mapping, + defaultValue: mapping.defaultValue, + sortOrder: mapping.sortOrder ?? 0, + createdAt: mapping.createdAt, + isFolderMapping: true, + folderMappingId: mapping.id, + }; + } + async getByIdRaw(id: number, organizationId: number): Promise { return storage.getDocumentTemplate(id, organizationId); } async create(data: InsertDocumentTemplate & { variables?: InsertTemplateVariable[] }): Promise { const template = await storage.createDocumentTemplate(data); - const variables: TemplateVariable[] = []; if (data.variables && data.variables.length > 0) { - for (const v of data.variables) { - variables.push(await storage.createTemplateVariable({ ...v, templateId: template.id })); + for (let i = 0; i < data.variables.length; i++) { + const v = data.variables[i]; + this.validateVariable(v, i); + await storage.createTemplateVariable({ ...v, templateId: template.id }); } } - return { ...template, variables }; + return this.getById(template.id, template.organizationId) as Promise; } async update( @@ -99,6 +268,8 @@ export class DocumentTemplateService { for (let i = 0; i < updates.variables.length; i++) { const v = updates.variables[i]; + if (v.id && v.id < 0) continue; // маппинги папки редактируются отдельно + this.validateVariable(v, i); const variableData: InsertTemplateVariable = { templateId: id, code: v.code || `var_${i}`, @@ -120,6 +291,32 @@ export class DocumentTemplateService { return this.getById(id, organizationId); } + private validateVariable(v: Partial, index: number): void { + const source = v.source || 'static'; + if (!VALID_SOURCES.has(source)) { + throw new Error(`Некорректный источник переменной #${index + 1}: ${source}`); + } + + const mapping = (v.mapping || {}) as Record; + + if (source === 'related_task_field') { + if (!mapping.relationType) { + throw new Error(`Для переменной #${index + 1} с источником «Связанная задача» необходимо указать тип связи`); + } + if (!mapping.targetFieldCode) { + throw new Error(`Для переменной #${index + 1} с источником «Связанная задача» необходимо указать целевое поле`); + } + } + + if (source === 'task_field' && !mapping.fieldCode) { + throw new Error(`Для переменной #${index + 1} с источником «Поле задачи» необходимо указать код поля`); + } + + if (source === 'task_table_tab' && !mapping.tabId) { + throw new Error(`Для переменной #${index + 1} с источником «Таблица задачи» необходимо выбрать вкладку`); + } + } + async delete(id: number, organizationId: number): Promise { await storage.deleteTemplateVariablesByTemplate(id); await storage.deleteDocumentTemplate(id, organizationId); @@ -140,7 +337,8 @@ export class DocumentTemplateService { const variables: TemplateVariable[] = []; for (const v of source.variables) { - const { id: _vid, templateId, createdAt: _vCreatedAt, ...vRest } = v; + if (v.isFolderMapping) continue; + const { id: _vid, templateId, createdAt: _vCreatedAt, isFolderMapping, folderMappingId, ...vRest } = v; variables.push( await storage.createTemplateVariable({ ...vRest, diff --git a/server/index.ts b/server/index.ts index bbad604..dc2f430 100644 --- a/server/index.ts +++ b/server/index.ts @@ -133,20 +133,40 @@ app.get('/api/files/:key/presigned', authenticateToken, async (req: Authenticate }); // Helper to set Content-Type and Content-Disposition headers based on file extension. -function setFileResponseHeaders(res: Response, fileKey: string, overrideContentType?: string, overrideContentDisposition?: string) { +function setFileResponseHeaders( + res: Response, + fileKey: string, + overrideContentType?: string, + overrideContentDisposition?: string, + originalName?: string | null, +) { const ext = getFileExt(fileKey); const contentType = overrideContentType || EXT_TO_MIME[ext]?.[0] || 'application/octet-stream'; const isPdf = ext === 'pdf'; + const utf8Name = originalName ? `filename*=UTF-8''${encodeURIComponent(originalName)}` : ''; // PDFs are always shown inline (browser iframe preview). // Other files use S3-provided disposition if available, defaulting to attachment. + // Prefer file_uploads.originalName (UTF-8) over raw fileKey to avoid encoding issues. const contentDisposition = isPdf - ? `inline; filename="${fileKey}"` - : (overrideContentDisposition || `attachment; filename="${fileKey}"`); + ? (utf8Name ? `inline; ${utf8Name}` : `inline; filename="${fileKey}"`) + : (overrideContentDisposition || (utf8Name ? `attachment; ${utf8Name}` : `attachment; filename="${fileKey}"`)); res.setHeader('Content-Type', contentType); res.setHeader('Content-Disposition', contentDisposition); res.setHeader('X-Content-Type-Options', 'nosniff'); } +async function getFileOriginalName(fileKey: string): Promise { + try { + const rows = await db.select({ originalName: fileUploads.originalName }) + .from(fileUploads) + .where(eq(fileUploads.fileKey, fileKey)) + .limit(1); + return rows[0]?.originalName || undefined; + } catch { + return undefined; + } +} + if (isS3Enabled) { // S3/MinIO mode: files served via /api/files/:key. app.use('/api/files/:key', tryPresignedOrAuth(), async (req: AuthenticatedRequest, res: Response) => { @@ -160,7 +180,8 @@ if (isS3Enabled) { const obj = await streamFromS3(key); if (!obj) return res.status(404).end(); - setFileResponseHeaders(res, key, obj.contentType, obj.contentDisposition || undefined); + const originalName = await getFileOriginalName(key); + setFileResponseHeaders(res, key, obj.contentType, obj.contentDisposition || undefined, originalName); if (obj.contentLength) res.setHeader('Content-Length', obj.contentLength); (obj.body as any).pipe(res); }); @@ -181,7 +202,8 @@ if (isS3Enabled) { const filePath = path.join(uploadsDir, filename); if (!fs.existsSync(filePath)) return res.status(404).end(); - setFileResponseHeaders(res, filename); + const originalName = await getFileOriginalName(filename); + setFileResponseHeaders(res, filename, undefined, undefined, originalName); res.sendFile(filePath); }); } @@ -1100,10 +1122,12 @@ async function runStartupDataPatches() { // this serves both the API and the client. // It is the only port that is not firewalled. const port = parseInt(process.env.PORT || '5000', 10); + // reusePort is not supported on Windows; skip it for native Windows dev + const isWindows = process.platform === 'win32'; server.listen({ port, host: "0.0.0.0", - reusePort: true, + ...(isWindows ? {} : { reusePort: true }), }, () => { log(`serving on port ${port}`); }); diff --git a/server/routes/task-bulk.routes.ts b/server/routes/task-bulk.routes.ts index 24bf974..6a8b1fa 100644 --- a/server/routes/task-bulk.routes.ts +++ b/server/routes/task-bulk.routes.ts @@ -1,9 +1,10 @@ import { Router } from 'express'; -import { authenticateToken, type AuthenticatedRequest } from '../middleware/auth.middleware'; +import { authenticateToken, requirePermission, type AuthenticatedRequest } from '../middleware/auth.middleware'; import { tenantIsolation } from '../middleware/tenant.middleware'; import { storage } from '../storage'; import { pool } from '../db'; import { executeTaskTransition } from '../services/task-transition.service'; +import { tasksMinimalCache } from '../utils/cache'; import type { ReminderRecipient } from '@shared/schema'; const taskBulkRouter = Router(); @@ -250,4 +251,50 @@ taskBulkRouter.post('/api/tasks/bulk/transition', } ); +// Delete multiple tasks (admin only) +// DELETE /api/tasks/bulk +// Body: { taskIds: number[] } +taskBulkRouter.delete('/api/tasks/bulk', + authenticateToken, + tenantIsolation, + requirePermission('tasks.edit_all'), + async (req: AuthenticatedRequest, res) => { + try { + const taskIds = validateTaskIds(req.body); + if (!taskIds) { + return res.status(400).json({ success: false, error: 'Укажите от 1 до 100 ID задач' }); + } + + const organizationId = req.user!.organizationId; + let deleted = 0; + const errors: Array<{ taskId: number; error: string }> = []; + + for (const taskId of taskIds) { + const existingTask = await storage.getTask(taskId, organizationId); + if (!existingTask) { + errors.push({ taskId, error: 'Задача не найдена' }); + continue; + } + + try { + await storage.deleteTask(taskId, organizationId); + deleted++; + } catch (err: any) { + console.error(`[bulk/delete] Failed to delete task ${taskId}:`, err); + errors.push({ taskId, error: err?.message || 'Ошибка удаления' }); + } + } + + if (deleted > 0) { + tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`); + } + + res.json({ success: true, deleted, total: taskIds.length, errors: errors.length > 0 ? errors : undefined }); + } catch (err) { + console.error('[bulk/delete]', err); + res.status(500).json({ success: false, error: 'Ошибка при массовом удалении задач' }); + } + } +); + export default taskBulkRouter; diff --git a/server/routes/task-crud-write.routes.ts b/server/routes/task-crud-write.routes.ts index 98ae332..23f2f36 100644 --- a/server/routes/task-crud-write.routes.ts +++ b/server/routes/task-crud-write.routes.ts @@ -1,9 +1,10 @@ import { Router } from "express"; +import crypto from "crypto"; import { storage } from "../storage"; import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { validateRequest } from "../middleware/validation.middleware"; -import { insertTaskSchema } from "@shared/schema"; +import { insertTaskSchema, idempotencyKeys, taskFieldValues, taskAuditLog } from "@shared/schema"; import { notificationService } from "../services/notification.service"; import { buildSystemFieldValues, buildTableRowMap, commitPendingUploads, eventBus, formatFieldValueForTitle, resolveTaskFieldTitles } from "./shared"; import { tasksMinimalCache } from "../utils/cache"; @@ -15,6 +16,8 @@ import { validateRequiredFields } from "../utils/validate-required-fields"; import { shiftRelatedTasks, calculateDateShift } from "../services/gantt-shift.service"; import { runAutomationsByTrigger, type AutomationRunResult } from "./automation.routes"; import { normalizeFieldValueForStorage } from "../utils/normalize-field-value"; +import { db, withTenant } from "../db"; +import { eq, and, sql } from "drizzle-orm"; function normalizeCustomFields( customFields: Record | undefined, @@ -706,7 +709,7 @@ export function registerTaskCrudWriteRoutes(router: ReturnType; + } + + // ── Phase 1: validate and run pre-create automations for ALL tasks before any DB write ── + const preparedTasks: PreparedTask[] = []; for (let i = 0; i < taskList.length; i++) { - const taskBody = taskList[i]; + const taskBody = taskList[i] as any; const { customFields, dueDate, ...baseTaskData } = taskBody; - let parsedDueDate = null; + let parsedDueDate: Date | null = null; if (dueDate && dueDate !== '') { if (dueDate instanceof Date) { parsedDueDate = dueDate; @@ -758,6 +767,49 @@ export function registerTaskCrudWriteRoutes(router: ReturnType 0) { + return res.status(400).json({ + success: false, + error: `Ошибка в элементе ${i + 1}: ${validationErrors.join('; ')}`, + }); + } + + const preCreateResults = await runAutomationsByTrigger(req.organizationId!, 'task.before_create', { + formId, + task: { + formId, + title: baseTaskData.title, + currentStatusId: initialStatus.id, + dueDate: parsedDueDate, + customFields: normalizedCustomFields, + }, + }); + for (const run of preCreateResults) { + const result = run.result as { allow?: boolean; error?: string; activeRentalTaskId?: number; assignedToName?: string | null } | undefined; + if (result && result.allow === false) { + return res.status(400).json({ + success: false, + error: `Элемент ${i + 1}: ${result.error || 'Создание задачи запрещено автоматизацией'}`, + automationId: run.automationId, + activeRentalTaskId: result.activeRentalTaskId, + assignedToName: result.assignedToName, + }); + } + } + const taskData = { ...baseTaskData, formId, @@ -767,168 +819,221 @@ export function registerTaskCrudWriteRoutes(router: ReturnType 0) { - return res.status(400).json({ - success: false, - error: `Ошибка в элементе ${i + 1}: ${batchValidationErrors.join('; ')}`, - }); - } + preparedTasks.push({ taskData, normalizedCustomFields }); + } - let task = await storage.createTask(taskData); + const payloadHash = crypto.createHash('sha256').update(JSON.stringify(taskList)).digest('hex'); + const endpointPath = `/api/forms/${formId}/tasks/batch`; + const creatorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API'; - // Sync the primary assignee into task_assignees so TaskDetail can display it - if (task.assignedTo) { - await storage.addTaskAssignee(task.id, task.assignedTo, req.organizationId!).catch(() => {}); - } - - if (customFields && typeof customFields === 'object') { - const normalizedCustomFields = normalizeCustomFields(customFields, formFields); - for (const [fieldKey, value] of Object.entries(normalizedCustomFields)) { - const fieldIdMatch = fieldKey.match(/^customField_(\d+)$/); - if (fieldIdMatch && value !== undefined && value !== '') { - const fieldId = parseInt(fieldIdMatch[1]); - const field = fieldMap.get(fieldId); - if (!field) continue; - - await storage.createTaskFieldValue({ - taskId: task.id, - fieldId, - formId, - value: normalizeFieldValueForStorage(value, field.type) - }); + // ── Phase 2: atomic creation inside one tenant transaction ── + const txResult = await withTenant(req.organizationId!, async () => { + // Idempotency check (inside transaction so it shares RLS context) + if (idempotencyKey && idempotencyKey.length <= 255) { + const existing = await db + .select() + .from(idempotencyKeys) + .where( + and( + eq(idempotencyKeys.organizationId, req.organizationId!), + eq(idempotencyKeys.key, idempotencyKey), + sql`${idempotencyKeys.expiresAt} > NOW()` + ) + ) + .limit(1); + if (existing.length > 0) { + const stored = existing[0]; + if (stored.payloadHash !== payloadHash) { + throw new Error('Idempotency key used with different payload'); } + const resp = stored.responseJson as { created?: number; taskIds?: number[] }; + return { cached: true as const, created: resp.created ?? 0, taskIds: resp.taskIds ?? [] }; } } - if (existingForm.titleTemplate) { - const savedValues = await storage.getTaskFieldValues(task.id, req.organizationId!); - const valueByFieldId = new Map(savedValues.map(v => [v.fieldId, v.value])); - const sysValues = await buildSystemFieldValues(task, req.organizationId!, { statuses: formStatusesList, usersMap }); - const taskTitleMap = await resolveTaskFieldTitles(valueByFieldId, formFields, req.organizationId!); - const computedTitle = existingForm.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_: string, code: string) => { - if (sysValues.has(code)) return sysValues.get(code)!; - const field = fieldCodeMap.get(code); - if (!field) return ''; - const val = valueByFieldId.get(field.id); - if (val === null || val === undefined) return ''; - if (val === '__auto_prolongation__' && (field.type === 'date' || field.type === 'datetime')) { - return field.autoProlongationLabel || 'Автопролонгация'; - } - return formatFieldValueForTitle(val, field.type, { usersMap, rolesMap, taskTitleMap, tableRowMap }, field.options); - }); - task = await storage.updateTask(task.id, req.organizationId!, { title: computedTitle }); - } + const createdIds: number[] = []; + const fieldValuesToInsert: any[] = []; + const auditLogsToInsert: any[] = []; + const assigneesToAdd: Array<{ taskId: number; userId: number }> = []; + const autoTransitionTasks: Array<{ taskId: number; assignedTo?: number | null }> = []; - // Auto-assign task based on transition rules from current status - if (!task.assignedTo && task.currentStatusId) { - const outgoingTransitions = transitions.filter(t => t.fromStatusId === task.currentStatusId); - let targetUserId: number | null = null; + for (let i = 0; i < preparedTasks.length; i++) { + const { taskData, normalizedCustomFields } = preparedTasks[i]; - for (const transition of outgoingTransitions) { - if (transition.assigneeUserId) { - targetUserId = transition.assigneeUserId; - break; + let task = await storage.createTask(taskData); + + if (task.assignedTo) { + assigneesToAdd.push({ taskId: task.id, userId: task.assignedTo }); + } + + // Collect field values for batch insert + if (normalizedCustomFields && typeof normalizedCustomFields === 'object') { + for (const [fieldKey, value] of Object.entries(normalizedCustomFields)) { + const fieldIdMatch = fieldKey.match(/^customField_(\d+)$/); + if (fieldIdMatch && value !== undefined && value !== '') { + const fieldId = parseInt(fieldIdMatch[1]); + const field = fieldMap.get(fieldId); + if (!field) continue; + fieldValuesToInsert.push({ + taskId: task.id, + fieldId, + formId, + value: normalizeFieldValueForStorage(value, field.type), + }); + } } - if (transition.assigneeConditions) { - const conditions = transition.assigneeConditions as Record; - if (conditions.assignees && Array.isArray(conditions.assignees)) { - const firstUser = conditions.assignees.find((a: any) => a.type === 'user'); - if (firstUser?.id) { - targetUserId = firstUser.id; + } + + if (existingForm.titleTemplate) { + const savedValues = await storage.getTaskFieldValues(task.id, req.organizationId!); + const valueByFieldId = new Map(savedValues.map(v => [v.fieldId, v.value])); + const sysValues = await buildSystemFieldValues(task, req.organizationId!, { statuses: formStatusesList, usersMap }); + const taskTitleMap = await resolveTaskFieldTitles(valueByFieldId, formFields, req.organizationId!); + const computedTitle = existingForm.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_: string, code: string) => { + if (sysValues.has(code)) return sysValues.get(code)!; + const field = fieldCodeMap.get(code); + if (!field) return ''; + const val = valueByFieldId.get(field.id); + if (val === null || val === undefined) return ''; + if (val === '__auto_prolongation__' && (field.type === 'date' || field.type === 'datetime')) { + return field.autoProlongationLabel || 'Автопролонгация'; + } + return formatFieldValueForTitle(val, field.type, { usersMap, rolesMap, taskTitleMap, tableRowMap }, field.options); + }); + task = await storage.updateTask(task.id, req.organizationId!, { title: computedTitle }); + } + + // Auto-assign task based on transition rules from current status + if (!task.assignedTo && task.currentStatusId) { + const outgoingTransitions = transitions.filter(t => t.fromStatusId === task.currentStatusId); + let targetUserId: number | null = null; + + for (const transition of outgoingTransitions) { + if (transition.assigneeUserId) { + targetUserId = transition.assigneeUserId; + break; + } + if (transition.assigneeConditions) { + const conditions = transition.assigneeConditions as Record; + if (conditions.assignees && Array.isArray(conditions.assignees)) { + const firstUser = conditions.assignees.find((a: any) => a.type === 'user'); + if (firstUser?.id) { + targetUserId = firstUser.id; + break; + } + } + if (conditions.fallbackAssigneeUserId) { + targetUserId = conditions.fallbackAssigneeUserId as number; + break; + } + if (conditions.assigneeId) { + targetUserId = conditions.assigneeId as number; break; } } - if (conditions.fallbackAssigneeUserId) { - targetUserId = conditions.fallbackAssigneeUserId as number; - break; - } - if (conditions.assigneeId) { - targetUserId = conditions.assigneeId as number; - break; - } + } + + if (targetUserId) { + await storage.updateTask(task.id, req.organizationId!, { assignedTo: targetUserId }); + assigneesToAdd.push({ taskId: task.id, userId: targetUserId }); + const refreshed = await storage.getTask(task.id, req.organizationId!); + if (refreshed) task = refreshed; } } - if (targetUserId) { - await storage.updateTask(task.id, req.organizationId!, { assignedTo: targetUserId }); - await storage.addTaskAssignee(task.id, targetUserId, req.organizationId!).catch(() => {}); - const refreshed = await storage.getTask(task.id, req.organizationId!); - if (refreshed) task = refreshed; + auditLogsToInsert.push({ + taskId: task.id, + organizationId: req.organizationId!, + action: 'task.created', + changedBy: req.user?.id ?? null, + changedByName: creatorName, + metadata: { title: task.title }, + }); + + createdIds.push(task.id); + autoTransitionTasks.push({ taskId: task.id, assignedTo: task.assignedTo }); + } + + // Batch insert all custom field values + if (fieldValuesToInsert.length > 0) { + await db.insert(taskFieldValues).values(fieldValuesToInsert); + } + + // Batch insert all audit logs + if (auditLogsToInsert.length > 0) { + await db.insert(taskAuditLog).values(auditLogsToInsert as any); + } + + // Sync assignees + for (const { taskId, userId } of assigneesToAdd) { + await storage.addTaskAssignee(taskId, userId, req.organizationId!).catch(() => {}); + } + + // Persist idempotency key atomically with the created tasks + if (idempotencyKey && idempotencyKey.length <= 255) { + await db.insert(idempotencyKeys).values({ + key: idempotencyKey, + organizationId: req.organizationId!, + userId: req.user!.id, + endpoint: endpointPath, + payloadHash, + responseJson: { created: createdIds.length, taskIds: createdIds }, + expiresAt: sql`NOW() + INTERVAL '1 hour'`, + }); + } + + return { cached: false as const, created: createdIds.length, taskIds: createdIds, autoTransitionTasks }; + }); + + // ── Phase 3: post-commit side effects (must happen AFTER commit so other requests see data) ── + if (!txResult.cached) { + for (const { taskId, assignedTo } of txResult.autoTransitionTasks) { + const autoResult = await evaluateAutoTransitions(taskId, req.organizationId!, { + triggeredBy: req.user?.id ?? null, + preloaded: { + statuses: formStatusesList, + formFields, + users, + roles, + transitions, + } + }); + let finalTask = await storage.getTask(taskId, req.organizationId!); + if (autoResult.changed && finalTask) { + finalTask = await storage.getTask(taskId, req.organizationId!) ?? finalTask; } - } + if (!finalTask) continue; - // Audit log - const creatorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API'; - storage.addTaskAuditLog({ - taskId: task.id, - organizationId: req.organizationId!, - action: 'task.created', - changedBy: req.user?.id ?? null, - changedByName: creatorName, - metadata: { title: task.title }, - }).catch(() => {}); + eventBus.publishEvent({ + type: 'task_updated', + organizationId: req.organizationId!, + data: { taskId, formId, task: finalTask } + }); - // Notify assignee - if (task.assignedTo && task.assignedTo !== req.user!.id) { - notifyTaskAssigned(task, task.assignedTo, req.user!.id, req.organizationId!) - .catch((err) => console.error('[BatchCreate] notifyTaskAssigned error:', err)); - } - - // Evaluate auto-transitions based on entry conditions (with preloaded data) - const autoResult = await evaluateAutoTransitions(task.id, req.organizationId!, { - triggeredBy: req.user?.id ?? null, - preloaded: { - statuses: formStatusesList, - formFields, - users, - roles, - transitions, + if (assignedTo && assignedTo !== req.user!.id) { + notifyTaskAssigned(finalTask, assignedTo, req.user!.id, req.organizationId!) + .catch((err) => console.error('[BatchCreate] notifyTaskAssigned error:', err)); } - }); - if (autoResult.changed) { - const refreshed = await storage.getTask(task.id, req.organizationId!); - if (refreshed) task = refreshed; - } - // Notify other users in real-time - eventBus.publishEvent({ - type: 'task_updated', - organizationId: req.organizationId!, - data: { taskId: task.id, formId: task.formId, task: task } - }); - - indexTaskAsync(task.id, req.organizationId!).catch(() => {}); - createdIds.push(task.id); - - // Yield to event loop to prevent blocking other requests - if (i < taskList.length - 1) { - await new Promise(r => setTimeout(r, 50)); + indexTaskAsync(taskId, req.organizationId!).catch(() => {}); } } tasksMinimalCache.invalidatePrefix(`tasks:${req.organizationId}:minimal:`); - res.status(201).json({ + res.status(txResult.cached ? 200 : 201).json({ success: true, - message: `Создано ${createdIds.length} задач`, - created: createdIds.length, - taskIds: createdIds + message: txResult.cached + ? `Задачи уже созданы (${txResult.created})` + : `Создано ${txResult.created} задач`, + created: txResult.created, + taskIds: txResult.taskIds, + cached: txResult.cached, }); } catch (error) { console.error('Batch create tasks error:', error); - res.status(500).json({ success: false, error: 'Ошибка при пакетном создании задач' }); + const message = error instanceof Error ? error.message : 'Ошибка при пакетном создании задач'; + res.status(500).json({ success: false, error: message }); } }); } diff --git a/server/routes/task-fields.routes.ts b/server/routes/task-fields.routes.ts index e4ff632..5b294a7 100644 --- a/server/routes/task-fields.routes.ts +++ b/server/routes/task-fields.routes.ts @@ -434,7 +434,7 @@ export function registerTaskFieldRoutes(router: ReturnType parseInt(k, 10)); - const INLINE_EDITABLE_FIELD_TYPES = new Set(['text', 'number', 'textarea', 'date', 'datetime', 'select', 'radio-group', 'checkbox', 'toggle', 'user', 'file', 'geo', 'checklist', 'contact']); + const INLINE_EDITABLE_FIELD_TYPES = new Set(['text', 'number', 'textarea', 'date', 'datetime', 'select', 'radio-group', 'checkbox', 'toggle', 'user', 'file', 'geo', 'checklist', 'contact', 'document_template']); const formFields = await storage.getFormFields(existingTask.formId, req.organizationId!); const fieldMap = new Map(formFields.map(f => [f.id, f])); const existingValues = await storage.getTaskFieldValues(taskId, req.organizationId!); diff --git a/server/storage/documents.storage.ts b/server/storage/documents.storage.ts index 0aa4f6a..aa85304 100644 --- a/server/storage/documents.storage.ts +++ b/server/storage/documents.storage.ts @@ -1,5 +1,7 @@ import { documentTemplateCategories, + documentTemplateFolders, + documentFolderMappings, documentTemplates, templateVariables, organizationAssets, @@ -9,6 +11,10 @@ import { documentSignatures, type DocumentTemplateCategory, type InsertDocumentTemplateCategory, + type DocumentTemplateFolder, + type InsertDocumentTemplateFolder, + type DocumentFolderMapping, + type InsertDocumentFolderMapping, type DocumentTemplate, type InsertDocumentTemplate, type TemplateVariable, @@ -25,9 +31,9 @@ import { type InsertDocumentSignature, } from "@shared/schema"; import { db } from "../db"; -import { eq, and, desc, asc, sql } from "drizzle-orm"; +import { eq, and, desc, asc, sql, isNull } from "drizzle-orm"; -// ── Document Template Categories ───────────────────────────────────────────── +// ── Document Template Categories (legacy) ──────────────────────────────────── export async function listDocumentTemplateCategories(organizationId: number): Promise { return db @@ -69,12 +75,101 @@ export async function deleteDocumentTemplateCategory(id: number, organizationId: .where(and(eq(documentTemplateCategories.id, id), eq(documentTemplateCategories.organizationId, organizationId))); } +// ── Document Template Folders ───────────────────────────────────────────────── + +export async function listDocumentTemplateFolders(organizationId: number): Promise { + return db + .select() + .from(documentTemplateFolders) + .where(eq(documentTemplateFolders.organizationId, organizationId)) + .orderBy(asc(documentTemplateFolders.position), asc(documentTemplateFolders.name)); +} + +export async function getDocumentTemplateFolder(id: number, organizationId: number): Promise { + const [folder] = await db + .select() + .from(documentTemplateFolders) + .where(and(eq(documentTemplateFolders.id, id), eq(documentTemplateFolders.organizationId, organizationId))); + return folder; +} + +export async function createDocumentTemplateFolder(data: InsertDocumentTemplateFolder): Promise { + const [folder] = await db.insert(documentTemplateFolders).values(data).returning(); + return folder; +} + +export async function updateDocumentTemplateFolder( + id: number, + organizationId: number, + updates: Partial, +): Promise { + const [folder] = await db + .update(documentTemplateFolders) + .set({ ...updates, updatedAt: new Date() }) + .where(and(eq(documentTemplateFolders.id, id), eq(documentTemplateFolders.organizationId, organizationId))) + .returning(); + return folder; +} + +export async function deleteDocumentTemplateFolder(id: number, organizationId: number): Promise { + await db + .delete(documentTemplateFolders) + .where(and(eq(documentTemplateFolders.id, id), eq(documentTemplateFolders.organizationId, organizationId))); +} + +// ── Document Folder Mappings ────────────────────────────────────────────────── + +export async function listDocumentFolderMappings(folderId: number): Promise { + return db + .select() + .from(documentFolderMappings) + .where(eq(documentFolderMappings.folderId, folderId)) + .orderBy(asc(documentFolderMappings.sortOrder), asc(documentFolderMappings.id)); +} + +export async function getDocumentFolderMapping(id: number, folderId: number): Promise { + const [mapping] = await db + .select() + .from(documentFolderMappings) + .where(and(eq(documentFolderMappings.id, id), eq(documentFolderMappings.folderId, folderId))); + return mapping; +} + +export async function createDocumentFolderMapping(data: InsertDocumentFolderMapping): Promise { + const [mapping] = await db.insert(documentFolderMappings).values(data).returning(); + return mapping; +} + +export async function updateDocumentFolderMapping( + id: number, + folderId: number, + updates: Partial, +): Promise { + const [mapping] = await db + .update(documentFolderMappings) + .set(updates) + .where(and(eq(documentFolderMappings.id, id), eq(documentFolderMappings.folderId, folderId))) + .returning(); + return mapping; +} + +export async function deleteDocumentFolderMapping(id: number, folderId: number): Promise { + await db + .delete(documentFolderMappings) + .where(and(eq(documentFolderMappings.id, id), eq(documentFolderMappings.folderId, folderId))); +} + +export async function deleteDocumentFolderMappingsByFolder(folderId: number): Promise { + await db.delete(documentFolderMappings).where(eq(documentFolderMappings.folderId, folderId)); +} + // ── Document Templates ─────────────────────────────────────────────────────── export interface TemplateListFilters { organizationId: number; status?: string; categoryId?: number; + folderId?: number; formId?: number; } @@ -82,6 +177,13 @@ export async function listDocumentTemplates(filters: TemplateListFilters): Promi const conditions = [eq(documentTemplates.organizationId, filters.organizationId)]; if (filters.status) conditions.push(eq(documentTemplates.status, filters.status)); if (filters.categoryId !== undefined) conditions.push(eq(documentTemplates.categoryId, filters.categoryId)); + if (filters.folderId !== undefined) { + if (filters.folderId === null) { + conditions.push(isNull(documentTemplates.folderId)); + } else { + conditions.push(eq(documentTemplates.folderId, filters.folderId)); + } + } if (filters.formId !== undefined) conditions.push(eq(documentTemplates.formId, filters.formId)); return db diff --git a/shared/field-types.ts b/shared/field-types.ts index 1db43dd..bad6db5 100644 --- a/shared/field-types.ts +++ b/shared/field-types.ts @@ -34,6 +34,7 @@ export const FIELD_TYPE_DEFINITIONS: FieldTypeDef[] = [ { id: "formula", label: "Формула", iconName: "Calculator", supportsOptions: false, allowedIn: ["form", "global", "table", "user"] }, { id: "contract-number", label: "Номер договора", iconName: "FileSignature", supportsOptions: false, allowedIn: ["form", "global", "table", "user"] }, { id: "contact", label: "Контакт", iconName: "User", supportsOptions: false, allowedIn: ["form", "global", "table", "user"] }, + { id: "document_template", label: "Шаблон документа", iconName: "FileText", supportsOptions: false, allowedIn: ["form", "global", "table", "user"] }, ]; export const ALL_FIELD_TYPES = FIELD_TYPE_DEFINITIONS.map((t) => t.id); diff --git a/shared/schema.ts b/shared/schema.ts index 92feb08..2bd4e03 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -425,7 +425,7 @@ export const formFields = pgTable("form_fields", { tabId: integer("tab_id").references(() => formTabs.id, { onDelete: "set null" }), // вкладка для поля name: varchar("name", { length: 200 }).notNull(), code: varchar("code", { length: 100 }).notNull(), - type: varchar("type", { length: 50 }).notNull(), // text, number, date, select, checkbox, toggle, button, user, file, textarea, rich-text, datetime, history-number, table, task + type: varchar("type", { length: 50 }).notNull(), // text, number, date, select, checkbox, toggle, button, user, file, textarea, rich-text, datetime, history-number, table, task, document_template isRequired: boolean("is_required").default(false), placeholder: text("placeholder"), defaultValue: text("default_value"), @@ -1135,7 +1135,7 @@ export const createFormSchema = z.object({ fields: z.array(z.object({ name: z.string().min(1, "Название поля обязательно").max(200), code: z.string().min(1, "Код поля обязателен").max(100).regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, "Код поля может содержать только буквы, цифры и подчеркивания"), - type: z.enum(['text', 'number', 'date', 'select', 'checkbox', 'toggle', 'button', 'user', 'file', 'textarea', 'rich-text', 'datetime', 'history-number']), + type: z.enum(['text', 'number', 'date', 'select', 'checkbox', 'toggle', 'button', 'user', 'file', 'textarea', 'rich-text', 'datetime', 'history-number', 'document_template']), isRequired: z.boolean().default(false), placeholder: z.string().optional(), defaultValue: z.string().optional(), @@ -3111,10 +3111,65 @@ export const documentTemplateCategories = pgTable("document_template_categories" export type DocumentTemplateCategory = typeof documentTemplateCategories.$inferSelect; export type InsertDocumentTemplateCategory = typeof documentTemplateCategories.$inferInsert; +export const documentTemplateFolders = pgTable("document_template_folders", { + id: serial("id").primaryKey(), + organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + parentId: integer("parent_id"), + name: varchar("name", { length: 100 }).notNull(), + position: integer("position").notNull().default(0), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), +}, (table) => ({ + orgParentIdx: index("document_template_folders_org_parent_idx").on(table.organizationId, table.parentId), + positionIdx: index("document_template_folders_position_idx").on(table.position), +})); + +export type DocumentTemplateFolder = typeof documentTemplateFolders.$inferSelect; +export type InsertDocumentTemplateFolder = typeof documentTemplateFolders.$inferInsert; + +export const documentFolderMappings = pgTable("document_folder_mappings", { + id: serial("id").primaryKey(), + folderId: integer("folder_id").notNull().references(() => documentTemplateFolders.id, { onDelete: "cascade" }), + code: varchar("code", { length: 100 }).notNull(), + label: varchar("label", { length: 200 }), + description: text("description"), + source: varchar("source", { length: 30 }).notNull(), + mapping: jsonb("mapping").$type<{ + fieldCode?: string; + formId?: number; + systemKey?: string; + staticValue?: string; + userId?: number; + relatedTaskId?: number; + tabId?: number; + assetType?: string; + assetId?: number; + selectionMode?: string; + taskFieldCode?: string; + // related_task_field support + relationType?: 'task_field' | 'parent' | 'child' | 'contract_legal_entity'; + sourceFormId?: number; + sourceFieldCode?: string; + targetFormId?: number; + targetFieldCode?: string; + transform?: { type: string; params?: Record } | string; + locale?: string; + }>().default({}), + defaultValue: text("default_value"), + sortOrder: integer("sort_order").default(0), + createdAt: timestamp("created_at").defaultNow(), +}, (table) => ({ + uniqueCodePerFolder: unique("document_folder_mappings_code_unique").on(table.folderId, table.code), +})); + +export type DocumentFolderMapping = typeof documentFolderMappings.$inferSelect; +export type InsertDocumentFolderMapping = typeof documentFolderMappings.$inferInsert; + export const documentTemplates = pgTable("document_templates", { id: serial("id").primaryKey(), organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), categoryId: integer("category_id").references(() => documentTemplateCategories.id, { onDelete: "set null" }), + folderId: integer("folder_id").references(() => documentTemplateFolders.id, { onDelete: "set null" }), formId: integer("form_id").references(() => forms.id, { onDelete: "set null" }), name: varchar("name", { length: 200 }).notNull(), description: text("description"), @@ -3133,6 +3188,11 @@ export const documentTemplates = pgTable("document_templates", { opacity?: number; }>>().default([]), sourceDocxFileKey: varchar("source_docx_file_key", { length: 500 }), + // Target file field where generated document should be saved + targetFormId: integer("target_form_id").references(() => forms.id, { onDelete: "set null" }), + targetFieldCode: varchar("target_field_code", { length: 100 }), + targetTaskSource: varchar("target_task_source", { length: 20 }).default("current"), + targetSourceFieldCode: varchar("target_source_field_code", { length: 100 }), status: varchar("status", { length: 20 }).notNull().default("draft"), createdBy: integer("created_by").notNull().references(() => users.id), createdAt: timestamp("created_at").defaultNow(), @@ -3140,6 +3200,7 @@ export const documentTemplates = pgTable("document_templates", { }, (table) => ({ orgStatusIdx: index("document_templates_org_status_idx").on(table.organizationId, table.status), orgCategoryIdx: index("document_templates_org_category_idx").on(table.organizationId, table.categoryId), + orgFolderIdx: index("document_templates_org_folder_idx").on(table.organizationId, table.folderId), })); export type DocumentTemplate = typeof documentTemplates.$inferSelect; @@ -3164,6 +3225,12 @@ export const templateVariables = pgTable("template_variables", { assetId?: number; selectionMode?: string; taskFieldCode?: string; + // related_task_field support + relationType?: 'task_field' | 'parent' | 'child' | 'contract_legal_entity'; + sourceFormId?: number; + sourceFieldCode?: string; + targetFormId?: number; + targetFieldCode?: string; transform?: { type: string; params?: Record } | string; locale?: string; }>().default({}), @@ -3462,6 +3529,28 @@ export interface AutoDetectConfig { trees: AutoDetectTree[]; } +// ===================== +// Idempotency Keys (защита от дублей при тяжёлых batch-операциях) +// ===================== + +export const idempotencyKeys = pgTable("idempotency_keys", { + id: serial("id").primaryKey(), + key: varchar("key", { length: 255 }).notNull(), + organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + endpoint: varchar("endpoint", { length: 255 }).notNull(), + payloadHash: varchar("payload_hash", { length: 64 }).notNull(), + responseJson: jsonb("response_json").notNull(), + createdAt: timestamp("created_at").defaultNow(), + expiresAt: timestamp("expires_at").notNull(), +}, (table) => ({ + orgKeyIdx: uniqueIndex("idempotency_keys_org_key_idx").on(table.organizationId, table.key), + expiresAtIdx: index("idempotency_keys_expires_at_idx").on(table.expiresAt), +})); + +export type IdempotencyKey = typeof idempotencyKeys.$inferSelect; +export type InsertIdempotencyKey = typeof idempotencyKeys.$inferInsert; + export const FINANCE_COLORS = [ "#2563eb", "#e11d48", "#16a34a", "#f59e0b", "#8b5cf6", "#06b6d4", "#ea580c", "#84cc16", diff --git a/vite.config.ts b/vite.config.ts index 8c795ac..b3b6224 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -77,5 +77,9 @@ export default defineConfig({ strict: true, deny: ["**/.*"], }, + watch: { + usePolling: true, + interval: 300, + }, }, });