diff --git a/client/src/components/BatchTaskQrDialog.tsx b/client/src/components/BatchTaskQrDialog.tsx new file mode 100644 index 0000000..043b482 --- /dev/null +++ b/client/src/components/BatchTaskQrDialog.tsx @@ -0,0 +1,337 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import QRCode from 'qrcode'; +import JSZip from 'jszip'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Loader2, Download, Copy, Check, FileArchive } from 'lucide-react'; +import { getSyncedAutomations } from '@/lib/syncEngine'; +import { apiRequest } from '@/lib/queryClient'; + +interface BatchTaskQrDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + formId: number | null; +} + +interface TaskItem { + id: number; + title: string; +} + +interface QrTemplate { + key: string; + label: string; +} + +interface QrItem { + task: TaskItem; + template: QrTemplate; + url: string; + dataUrl: string | null; +} + +export function BatchTaskQrDialog({ open, onOpenChange, formId }: BatchTaskQrDialogProps) { + const [copiedId, setCopiedId] = useState(null); + const [qrItems, setQrItems] = useState([]); + const [generating, setGenerating] = useState(false); + const baseUrl = typeof window !== 'undefined' ? window.location.origin : ''; + + const { data, isLoading, error } = useQuery<{ success: boolean; tasks: TaskItem[] }>({ + queryKey: ['/api/forms', formId, 'tasks'], + enabled: open && !!formId && !!baseUrl, + staleTime: 5 * 60 * 1000, + queryFn: async () => { + const res = await apiRequest('GET', `/api/forms/${formId}/tasks`); + return res.json(); + }, + }); + + const tasks = data?.tasks || []; + + const [templateList, setTemplateList] = useState([]); + + useEffect(() => { + if (!open || !formId) { + setTemplateList([]); + setQrItems([]); + return; + } + let cancelled = false; + getSyncedAutomations().then((automations) => { + if (cancelled) return; + const keys = automations + .filter((a) => { + if (a.trigger !== 'qr.scan') return false; + const config = (a.triggerConfig as Record | undefined) || {}; + if (config.formId !== undefined && Number(config.formId) !== formId) return false; + return true; + }) + .map((a) => { + const config = (a.triggerConfig as Record | undefined) || {}; + return { + key: String(config.templateKey || `automation-${a.id}`), + label: String(config.templateKey || `automation-${a.id}`), + }; + }); + const unique = Array.from(new Map(keys.map((k) => [k.key, k])).values()); + setTemplateList(unique); + }); + return () => { + cancelled = true; + }; + }, [open, formId]); + + useEffect(() => { + if (!tasks.length || !templateList.length || !baseUrl) { + setQrItems([]); + return; + } + let cancelled = false; + setGenerating(true); + + const items: QrItem[] = []; + for (const task of tasks) { + for (const template of templateList) { + items.push({ + task, + template, + url: `${baseUrl}/qr/${encodeURIComponent(template.key)}/${task.id}`, + dataUrl: null, + }); + } + } + + setQrItems(items); + + (async () => { + for (let i = 0; i < items.length; i++) { + if (cancelled) return; + const item = items[i]; + try { + const dataUrl = await QRCode.toDataURL(item.url, { width: 200, margin: 2, errorCorrectionLevel: 'M' }); + if (cancelled) return; + setQrItems((prev) => { + const next = [...prev]; + const idx = next.findIndex( + (p) => p.task.id === item.task.id && p.template.key === item.template.key + ); + if (idx !== -1) { + next[idx] = { ...next[idx], dataUrl }; + } + return next; + }); + } catch { + // ignore single QR failure + } + } + if (!cancelled) setGenerating(false); + })(); + + return () => { + cancelled = true; + }; + }, [tasks, templateList, baseUrl]); + + const groupedByTemplate = useMemo(() => { + const map = new Map(); + for (const item of qrItems) { + const list = map.get(item.template.key) || []; + list.push(item); + map.set(item.template.key, list); + } + return map; + }, [qrItems]); + + const handleCopy = async (url: string, id: string) => { + try { + await navigator.clipboard.writeText(url); + setCopiedId(id); + setTimeout(() => setCopiedId(null), 1500); + } catch { + // ignore + } + }; + + const handleDownload = (dataUrl: string | null, filename: string) => { + if (!dataUrl) return; + const link = document.createElement('a'); + link.href = dataUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleDownloadAll = async (templateKey: string) => { + const items = groupedByTemplate.get(templateKey) || []; + const readyItems = items.filter((i) => i.dataUrl); + if (readyItems.length === 0) return; + + const zip = new JSZip(); + for (const item of readyItems) { + const base64 = item.dataUrl!.split(',')[1]; + zip.file(`qr-${item.task.id}.png`, base64, { base64: true }); + } + + const content = await zip.generateAsync({ type: 'blob' }); + const url = URL.createObjectURL(content); + const link = document.createElement('a'); + link.href = url; + link.download = `qr-${templateKey}-${readyItems.length}.zip`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + const sortedTasks = useMemo( + () => [...tasks].sort((a, b) => (a.title || '').localeCompare(b.title || '', 'ru')), + [tasks] + ); + + return ( + + + + QR-коды задач формы + + Пакетная генерация QR-кодов для всех задач формы по шаблонам автоматизаций. + + + + {isLoading && ( +
+ + Загрузка задач... +
+ )} + + {error && ( +
Не удалось загрузить список задач.
+ )} + + {!isLoading && !error && templateList.length === 0 && ( +
+ Нет QR-шаблонов для этой формы. Создайте автоматизацию с триггером «Сканирование QR» и + укажите formId={formId}. +
+ )} + + {!isLoading && !error && templateList.length > 0 && sortedTasks.length === 0 && ( +
В форме нет задач.
+ )} + + {!isLoading && !error && templateList.length > 0 && sortedTasks.length > 0 && ( + <> + {generating && ( +
+ + Генерация QR-кодов... +
+ )} + + + + {templateList.map((template) => ( + + {template.label} + + ))} + + {templateList.map((template) => { + const items = groupedByTemplate.get(template.key) || []; + const readyCount = items.filter((i) => i.dataUrl).length; + return ( + +
+
+ Задач: {items.length} | готово: {readyCount} +
+ +
+
+ {items.map((item) => { + const cardId = `${template.key}-${item.task.id}`; + return ( +
+
+ #{item.task.id} {item.task.title} +
+
{template.label}
+
+ {item.dataUrl ? ( + {`QR + ) : ( + + )} +
+ +
+ + +
+
+ ); + })} +
+
+ ); + })} +
+ + )} +
+
+ ); +} diff --git a/client/src/components/folders/FormFolderTree.tsx b/client/src/components/folders/FormFolderTree.tsx index 8842054..19f60b8 100644 --- a/client/src/components/folders/FormFolderTree.tsx +++ b/client/src/components/folders/FormFolderTree.tsx @@ -7,7 +7,7 @@ import { } 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 { Folder, FolderOpen, ChevronRight, ChevronDown, GripVertical, Pencil, Trash2, Plus, FileText, QrCode } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; @@ -18,6 +18,7 @@ interface TreeFormItem { name: string; description?: string; isActive: boolean; + qrGenerationEnabled?: boolean; folderId?: number | null; } @@ -39,6 +40,7 @@ interface FormFolderTreeProps { onSelectForm: (form: T) => void; onEditForm: (form: T) => void; onDeleteForm: (form: T) => void; + onGenerateQr?: (form: T) => void; overFolderId?: number | null; searchQuery?: string; } @@ -150,12 +152,14 @@ function DraggableFormRow({ onSelect, onEdit, onDelete, + onGenerateQr, }: { form: T; level: number; onSelect: (form: T) => void; onEdit: (form: T) => void; onDelete: (form: T) => void; + onGenerateQr?: (form: T) => void; }) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: `form-${form.id}`, @@ -190,6 +194,20 @@ function DraggableFormRow({ className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center" onClick={(e) => e.stopPropagation()} > + {onGenerateQr && form.qrGenerationEnabled && ( + + )}