feat(qr): пакетная генерация QR-кодов по форме
- Добавлена миграция и поле forms.qr_generation_enabled. - В редакторе формы (вкладка «Основное») добавлен переключатель «Показывать кнопку генерации QR в реестре форм». - В реестре форм (/forms) рядом со статусом формы появляется кнопка QR, если toggle включён. - Новый компонент BatchTaskQrDialog: генерирует QR для всех задач формы по всем qr.scan-шаблонам, позволяет скачать отдельный PNG или все сразу ZIP-архивом. - Добавлена зависимость jszip.
This commit is contained in:
337
client/src/components/BatchTaskQrDialog.tsx
Normal file
337
client/src/components/BatchTaskQrDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user