feat(qr): пакетная генерация QR-кодов по форме

- Добавлена миграция и поле forms.qr_generation_enabled.
- В редакторе формы (вкладка «Основное») добавлен переключатель «Показывать кнопку генерации QR в реестре форм».
- В реестре форм (/forms) рядом со статусом формы появляется кнопка QR, если toggle включён.
- Новый компонент BatchTaskQrDialog: генерирует QR для всех задач формы по всем qr.scan-шаблонам, позволяет скачать отдельный PNG или все сразу ZIP-архивом.
- Добавлена зависимость jszip.
This commit is contained in:
2026-07-13 16:58:25 +03:00
parent 0da6c04790
commit 9dbeae86de
8 changed files with 399 additions and 5 deletions

View File

@@ -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<string | null>(null);
const [qrItems, setQrItems] = useState<QrItem[]>([]);
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<QrTemplate[]>([]);
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<string, unknown> | undefined) || {};
if (config.formId !== undefined && Number(config.formId) !== formId) return false;
return true;
})
.map((a) => {
const config = (a.triggerConfig as Record<string, unknown> | 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<string, QrItem[]>();
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>QR-коды задач формы</DialogTitle>
<DialogDescription>
Пакетная генерация QR-кодов для всех задач формы по шаблонам автоматизаций.
</DialogDescription>
</DialogHeader>
{isLoading && (
<div className="flex items-center justify-center py-8 gap-2 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
Загрузка задач...
</div>
)}
{error && (
<div className="text-sm text-destructive py-4">Не удалось загрузить список задач.</div>
)}
{!isLoading && !error && templateList.length === 0 && (
<div className="text-sm text-muted-foreground py-4">
Нет QR-шаблонов для этой формы. Создайте автоматизацию с триггером «Сканирование QR» и
укажите formId={formId}.
</div>
)}
{!isLoading && !error && templateList.length > 0 && sortedTasks.length === 0 && (
<div className="text-sm text-muted-foreground py-4">В форме нет задач.</div>
)}
{!isLoading && !error && templateList.length > 0 && sortedTasks.length > 0 && (
<>
{generating && (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
<Loader2 className="w-4 h-4 animate-spin" />
Генерация QR-кодов...
</div>
)}
<Tabs defaultValue={templateList[0]?.key} className="w-full">
<TabsList className="flex-wrap h-auto">
{templateList.map((template) => (
<TabsTrigger key={template.key} value={template.key} className="text-xs">
{template.label}
</TabsTrigger>
))}
</TabsList>
{templateList.map((template) => {
const items = groupedByTemplate.get(template.key) || [];
const readyCount = items.filter((i) => i.dataUrl).length;
return (
<TabsContent key={template.key} value={template.key}>
<div className="flex items-center justify-between mb-3">
<div className="text-xs text-muted-foreground">
Задач: {items.length} | готово: {readyCount}
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1.5"
onClick={() => handleDownloadAll(template.key)}
disabled={readyCount === 0}
>
<FileArchive className="w-3.5 h-3.5" />
Скачать все (ZIP)
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 py-2">
{items.map((item) => {
const cardId = `${template.key}-${item.task.id}`;
return (
<div
key={cardId}
className="border rounded-lg p-3 flex flex-col items-center gap-2 bg-card"
>
<div className="text-sm font-medium text-center line-clamp-2 min-h-[2.5rem]">
#{item.task.id} {item.task.title}
</div>
<div className="text-xs text-muted-foreground">{template.label}</div>
<div className="w-[160px] h-[160px] flex items-center justify-center bg-white rounded border">
{item.dataUrl ? (
<img
src={item.dataUrl}
alt={`QR ${item.task.title}`}
className="w-[150px] h-[150px]"
/>
) : (
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
)}
</div>
<Input value={item.url} readOnly className="text-[10px] h-7" />
<div className="flex gap-2 w-full">
<Button
variant="outline"
size="sm"
className="flex-1 h-7 text-xs"
onClick={() => handleCopy(item.url, cardId)}
>
{copiedId === cardId ? (
<Check className="w-3 h-3 mr-1" />
) : (
<Copy className="w-3 h-3 mr-1" />
)}
{copiedId === cardId ? 'Скопировано' : 'URL'}
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 h-7 text-xs"
onClick={() =>
handleDownload(
item.dataUrl,
`qr-${template.key}-${item.task.id}.png`
)
}
disabled={!item.dataUrl}
>
<Download className="w-3 h-3 mr-1" />
PNG
</Button>
</div>
</div>
);
})}
</div>
</TabsContent>
);
})}
</Tabs>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -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<T extends TreeFormItem> {
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<T extends TreeFormItem>({
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<T extends TreeFormItem>({
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center"
onClick={(e) => e.stopPropagation()}
>
{onGenerateQr && form.qrGenerationEnabled && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="QR-коды задач"
onClick={(e) => {
e.stopPropagation();
onGenerateQr(form);
}}
>
<QrCode className="w-3.5 h-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
@@ -396,6 +414,7 @@ function FolderNode<T extends TreeFormItem>({
onSelectForm: (form: T) => void;
onEditForm: (form: T) => void;
onDeleteForm: (form: T) => void;
onGenerateQr?: (form: T) => void;
overFolderId?: number | null;
}) {
const isExpanded = effectiveExpanded.has(node.id);
@@ -424,6 +443,7 @@ function FolderNode<T extends TreeFormItem>({
onSelect={props.onSelectForm}
onEdit={props.onEditForm}
onDelete={props.onDeleteForm}
onGenerateQr={props.onGenerateQr}
/>
))}
{node.children.map((child) => (
@@ -448,6 +468,7 @@ export function FormFolderTree<T extends TreeFormItem>({
onSelectForm,
onEditForm,
onDeleteForm,
onGenerateQr,
overFolderId,
searchQuery,
}: FormFolderTreeProps<T>) {
@@ -510,6 +531,7 @@ export function FormFolderTree<T extends TreeFormItem>({
onSelect={onSelectForm}
onEdit={onEditForm}
onDelete={onDeleteForm}
onGenerateQr={onGenerateQr}
/>
))}
</div>
@@ -538,6 +560,7 @@ export function FormFolderTree<T extends TreeFormItem>({
onSelectForm={onSelectForm}
onEditForm={onEditForm}
onDeleteForm={onDeleteForm}
onGenerateQr={onGenerateQr}
overFolderId={overFolderId}
/>
)

View File

@@ -172,6 +172,7 @@ interface FormData {
updatedAt: string;
chatLayout?: 'default' | 'left' | 'tab';
chatEnabled?: boolean;
qrGenerationEnabled?: boolean;
systemFieldsTabId?: number | null;
defaultTabId?: number | null;
detailLayoutCode?: string | null;
@@ -511,6 +512,7 @@ const FormEditor = () => {
const [transitions, setTransitions] = useState<StatusTransition[]>([]);
const [formTabs, setFormTabs] = useState<FormTab[]>([]);
const [chatEnabled, setChatEnabled] = useState<boolean>(true);
const [qrGenerationEnabled, setQrGenerationEnabled] = useState<boolean>(false);
const [systemFieldsTabId, setSystemFieldsTabId] = useState<number | null>(null);
const [defaultTabId, setDefaultTabId] = useState<string>('all'); // 'all', 'chat', или id вкладки
const [systemTabOrder, setSystemTabOrder] = useState<string[]>(['subtasks', 'related', 'chat', 'history', 'access']);
@@ -724,7 +726,7 @@ const FormEditor = () => {
// Мутация для сохранения настроек формы
const saveFormSettingsMutation = useMutation({
mutationFn: async (settings: { name: string; chatEnabled: boolean; systemFieldsTabId: number | null; defaultTabId: number | null; titleTemplate: string | null; hiddenSystemFields: string[]; systemTabOrder?: string[] }) => {
mutationFn: async (settings: { name: string; chatEnabled: boolean; qrGenerationEnabled: boolean; systemFieldsTabId: number | null; defaultTabId: number | null; titleTemplate: string | null; hiddenSystemFields: string[]; systemTabOrder?: string[] }) => {
const res = await apiRequest('PUT', `/api/forms/${id}`, settings);
return res.json() as Promise<{
success: boolean;
@@ -1020,6 +1022,7 @@ const FormEditor = () => {
const freshName = form.name || '';
if (!isFormSettingsInitialized) {
setChatEnabled(form.chatEnabled !== false);
setQrGenerationEnabled(form.qrGenerationEnabled === true);
setSystemFieldsTabId(form.systemFieldsTabId ?? null);
setSystemTabOrder(form.systemTabOrder?.length ? form.systemTabOrder : ['subtasks', 'related', 'chat', 'history', 'access']);
setHiddenSystemFields(form.hiddenSystemFields ?? []);
@@ -2726,6 +2729,7 @@ return function Layout({ ctx }) {
saveFormSettingsMutation.mutate({
name: trimmed,
chatEnabled,
qrGenerationEnabled,
systemFieldsTabId,
defaultTabId: defaultTabIdValue,
titleTemplate: titleTemplate || null,
@@ -2936,10 +2940,10 @@ return function Layout({ ctx }) {
</div>
</Card>
{/* Настройки чата */}
{/* Настройки чата и QR */}
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium">Настройки чата</h3>
<h3 className="font-medium">Настройки чата и QR</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex items-center gap-3">
@@ -2952,6 +2956,16 @@ return function Layout({ ctx }) {
Включить чат
</label>
</div>
<div className="flex items-center gap-3">
<Switch
checked={qrGenerationEnabled}
onCheckedChange={setQrGenerationEnabled}
data-testid="switch-qr-generation-enabled"
/>
<label className="text-sm font-medium cursor-pointer" onClick={() => setQrGenerationEnabled(!qrGenerationEnabled)}>
Показывать кнопку генерации QR в реестре форм
</label>
</div>
{formTabs.length > 0 && (
<div>
<label className="text-sm font-medium">Системные поля на вкладке</label>
@@ -3036,6 +3050,7 @@ return function Layout({ ctx }) {
await saveFormSettingsMutation.mutateAsync({
name: formName.trim() || formData?.form?.name || '',
chatEnabled,
qrGenerationEnabled,
systemFieldsTabId,
defaultTabId: defaultTabIdValue,
titleTemplate: titleTemplate || null,
@@ -3304,7 +3319,7 @@ return function Layout({ ctx }) {
setOpenFieldCollapsibles(new Set());
// Преобразуем defaultTabId в число для сохранения
await saveFormSettingsMutation.mutateAsync({ name: formName.trim() || formData?.form?.name || '', chatEnabled, systemFieldsTabId, defaultTabId: defaultTabIdValue, titleTemplate: titleTemplate || null, hiddenSystemFields, systemTabOrder });
await saveFormSettingsMutation.mutateAsync({ name: formName.trim() || formData?.form?.name || '', chatEnabled, qrGenerationEnabled, systemFieldsTabId, defaultTabId: defaultTabIdValue, titleTemplate: titleTemplate || null, hiddenSystemFields, systemTabOrder });
toast({
title: 'Все настройки сохранены',

View File

@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Plus, Search, Folder, X, FileText } from 'lucide-react';
import { BatchTaskQrDialog } from '@/components/BatchTaskQrDialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { FolderTreeWithItems } from '@/components/folders/FolderTreeWithItems';
@@ -81,6 +82,8 @@ const Forms = () => {
const [activeId, setActiveId] = useState<string | number | null>(null);
const [overFolderId, setOverFolderId] = useState<number | null>(null);
const [mobileTab, setMobileTab] = useState<'all' | 'folders'>('all');
const [qrDialogOpen, setQrDialogOpen] = useState(false);
const [qrDialogFormId, setQrDialogFormId] = useState<number | null>(null);
const isMobile = useIsMobile();
const { toast } = useToast();
const [, setLocation] = useLocation();
@@ -163,6 +166,11 @@ const Forms = () => {
setLocation(`/forms/${formData.id}/edit`);
};
const handleGenerateQr = (formData: FormData) => {
setQrDialogFormId(formData.id);
setQrDialogOpen(true);
};
const handleDeleteForm = async (formData: FormData) => {
setDeletingForm(formData);
setLoadingTasksCount(true);
@@ -454,6 +462,7 @@ const Forms = () => {
onSelectForm={(form) => setLocation(`/forms/${form.id}/tasks`)}
onEditForm={handleEditForm}
onDeleteForm={handleDeleteForm}
onGenerateQr={handleGenerateQr}
overFolderId={overFolderId}
searchQuery={searchQuery}
/>
@@ -560,6 +569,12 @@ const Forms = () => {
title={editingFolder ? 'Редактировать папку' : 'Создать папку'}
folder={editingFolder}
/>
<BatchTaskQrDialog
open={qrDialogOpen}
onOpenChange={setQrDialogOpen}
formId={qrDialogFormId}
/>
</Layout>
);
};