diff --git a/ticket_form/frontend/src/components/form/StepDocumentsNew.tsx b/ticket_form/frontend/src/components/form/StepDocumentsNew.tsx index 00f429ff..7518fad4 100644 --- a/ticket_form/frontend/src/components/form/StepDocumentsNew.tsx +++ b/ticket_form/frontend/src/components/form/StepDocumentsNew.tsx @@ -359,367 +359,3 @@ export default function StepDocumentsNew({ ); } - - - * StepDocumentsNew.tsx - * - * Поэкранная загрузка документов. - * Один документ на экран с возможностью пропуска. - * - * @version 1.0 - * @date 2025-11-26 - */ - -import { useState, useCallback, useEffect, useRef } from 'react'; -import { - Button, - Card, - Upload, - Progress, - Alert, - Typography, - Space, - Spin, - message, - Result -} from 'antd'; -import { - UploadOutlined, - FileTextOutlined, - ExclamationCircleOutlined, - CheckCircleOutlined, - LoadingOutlined, - InboxOutlined -} from '@ant-design/icons'; -import type { UploadFile, UploadProps } from 'antd/es/upload/interface'; - -const { Title, Text, Paragraph } = Typography; -const { Dragger } = Upload; - -// === Типы === -export interface DocumentConfig { - type: string; // Идентификатор: contract, payment, correspondence - name: string; // Название: "Договор или оферта" - critical: boolean; // Обязательный документ? - hints?: string; // Подсказка: "Скриншот или PDF договора" - accept?: string[]; // Допустимые форматы: ['pdf', 'jpg', 'png'] -} - -interface Props { - formData: any; - updateFormData: (data: any) => void; - documents: DocumentConfig[]; - currentIndex: number; - onDocumentUploaded: (docType: string, fileData: any) => void; - onDocumentSkipped: (docType: string) => void; - onAllDocumentsComplete: () => void; - onPrev: () => void; - addDebugEvent?: (type: string, status: string, message: string, data?: any) => void; -} - -// === Компонент === -export default function StepDocumentsNew({ - formData, - updateFormData, - documents, - currentIndex, - onDocumentUploaded, - onDocumentSkipped, - onAllDocumentsComplete, - onPrev, - addDebugEvent, -}: Props) { - const [fileList, setFileList] = useState([]); - const [uploading, setUploading] = useState(false); - const [uploadProgress, setUploadProgress] = useState(0); - - // Текущий документ - const currentDoc = documents[currentIndex]; - const isLastDocument = currentIndex === documents.length - 1; - const totalDocs = documents.length; - - // Сбрасываем файлы при смене документа - useEffect(() => { - setFileList([]); - setUploadProgress(0); - }, [currentIndex]); - - // === Handlers === - - const handleUpload = useCallback(async () => { - if (fileList.length === 0) { - message.error('Выберите файл для загрузки'); - return; - } - - const file = fileList[0]; - if (!file.originFileObj) { - message.error('Ошибка: файл не найден'); - return; - } - - setUploading(true); - setUploadProgress(0); - - try { - addDebugEvent?.('documents', 'info', `📤 Загрузка документа: ${currentDoc.name}`, { - document_type: currentDoc.type, - file_name: file.name, - file_size: file.size, - }); - - const formDataToSend = new FormData(); - formDataToSend.append('claim_id', formData.claim_id || ''); - formDataToSend.append('session_id', formData.session_id || ''); - formDataToSend.append('document_type', currentDoc.type); - formDataToSend.append('file', file.originFileObj, file.name); - - // Симуляция прогресса (реальный прогресс будет через XHR) - const progressInterval = setInterval(() => { - setUploadProgress(prev => Math.min(prev + 10, 90)); - }, 200); - - const response = await fetch('/api/v1/documents/upload', { - method: 'POST', - body: formDataToSend, - }); - - clearInterval(progressInterval); - setUploadProgress(100); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Ошибка загрузки: ${response.status} ${errorText}`); - } - - const result = await response.json(); - - addDebugEvent?.('documents', 'success', `✅ Документ загружен: ${currentDoc.name}`, { - document_type: currentDoc.type, - file_id: result.file_id, - }); - - message.success(`${currentDoc.name} загружен!`); - - // Сохраняем в formData - const uploadedDocs = formData.documents_uploaded || []; - uploadedDocs.push({ - type: currentDoc.type, - file_id: result.file_id, - file_name: file.name, - ocr_status: 'processing', - }); - - updateFormData({ - documents_uploaded: uploadedDocs, - current_doc_index: currentIndex + 1, - }); - - // Callback - onDocumentUploaded(currentDoc.type, result); - - // Переходим к следующему или завершаем - if (isLastDocument) { - onAllDocumentsComplete(); - } - - } catch (error) { - console.error('❌ Upload error:', error); - message.error('Ошибка загрузки файла. Попробуйте ещё раз.'); - addDebugEvent?.('documents', 'error', `❌ Ошибка загрузки: ${currentDoc.name}`, { - error: String(error), - }); - } finally { - setUploading(false); - } - }, [fileList, currentDoc, formData, updateFormData, currentIndex, isLastDocument, onDocumentUploaded, onAllDocumentsComplete, addDebugEvent]); - - const handleSkip = useCallback(() => { - if (currentDoc.critical) { - // Показываем предупреждение, но всё равно разрешаем пропустить - message.warning(`⚠️ Документ "${currentDoc.name}" важен для рассмотрения заявки`); - } - - addDebugEvent?.('documents', 'info', `⏭️ Документ пропущен: ${currentDoc.name}`, { - document_type: currentDoc.type, - was_critical: currentDoc.critical, - }); - - // Сохраняем в список пропущенных - const skippedDocs = formData.documents_skipped || []; - if (!skippedDocs.includes(currentDoc.type)) { - skippedDocs.push(currentDoc.type); - } - - updateFormData({ - documents_skipped: skippedDocs, - current_doc_index: currentIndex + 1, - }); - - // Callback - onDocumentSkipped(currentDoc.type); - - // Переходим к следующему или завершаем - if (isLastDocument) { - onAllDocumentsComplete(); - } - }, [currentDoc, formData, updateFormData, currentIndex, isLastDocument, onDocumentSkipped, onAllDocumentsComplete, addDebugEvent]); - - // === Upload Props === - const uploadProps: UploadProps = { - fileList, - onChange: ({ fileList: newFileList }) => setFileList(newFileList.slice(-1)), // Только один файл - beforeUpload: () => false, // Не загружаем автоматически - maxCount: 1, - accept: currentDoc?.accept - ? currentDoc.accept.map(ext => `.${ext}`).join(',') - : '.pdf,.jpg,.jpeg,.png,.heic,.doc,.docx', - disabled: uploading, - }; - - // === Render === - - if (!currentDoc) { - return ( - } - /> - ); - } - - return ( -
- - {/* === Прогресс === */} -
-
- - Документ {currentIndex + 1} из {totalDocs} - - - {Math.round((currentIndex / totalDocs) * 100)}% завершено - -
- -
- - {/* === Заголовок === */} -
- - <FileTextOutlined style={{ color: '#595959' }} /> - {currentDoc.name} - {currentDoc.critical && ( - <ExclamationCircleOutlined - style={{ color: '#fa8c16', fontSize: 20 }} - title="Важный документ" - /> - )} - - - {currentDoc.hints && ( - - {currentDoc.hints} - - )} -
- - {/* === Алерт для критичных документов === */} - {currentDoc.critical && ( - } - style={{ marginBottom: 24 }} - /> - )} - - {/* === Загрузка файла === */} - -

- {uploading ? ( - - ) : ( - - )} -

-

- {uploading - ? 'Загружаем документ...' - : 'Перетащите файл сюда или нажмите для выбора' - } -

-

- Поддерживаются: PDF, JPG, PNG, HEIC, DOC (до 20 МБ) -

-
- - {/* === Прогресс загрузки === */} - {uploading && ( - - )} - - {/* === Кнопки === */} - - - - - - - - - - - {/* === Уже загруженные документы === */} - {formData.documents_uploaded && formData.documents_uploaded.length > 0 && ( -
- Загруженные документы: -
    - {formData.documents_uploaded.map((doc: any, idx: number) => ( -
  • - - {documents.find(d => d.type === doc.type)?.name || doc.type} -
  • - ))} -
-
- )} -
-
- ); -} - - diff --git a/ticket_form/frontend/src/components/form/StepWaitingClaim.tsx b/ticket_form/frontend/src/components/form/StepWaitingClaim.tsx index 8e5ce976..2483fef1 100644 --- a/ticket_form/frontend/src/components/form/StepWaitingClaim.tsx +++ b/ticket_form/frontend/src/components/form/StepWaitingClaim.tsx @@ -336,344 +336,3 @@ export default function StepWaitingClaim({ ); } - - - * StepWaitingClaim.tsx - * - * Экран ожидания формирования заявления. - * Показывает прогресс: OCR → Анализ → Формирование заявления. - * Подписывается на SSE для получения claim_ready. - * - * @version 1.0 - * @date 2025-11-26 - */ - -import { useState, useEffect, useRef, useCallback } from 'react'; -import { Card, Typography, Progress, Space, Button, Spin, Result, Steps } from 'antd'; -import { - LoadingOutlined, - CheckCircleOutlined, - FileSearchOutlined, - RobotOutlined, - FileTextOutlined, - ClockCircleOutlined -} from '@ant-design/icons'; -import AiWorkingIllustration from '../../assets/ai-working.svg'; - -const { Title, Paragraph, Text } = Typography; -const { Step } = Steps; - -interface Props { - sessionId: string; - claimId?: string; - documentsCount: number; - onClaimReady: (claimData: any) => void; - onTimeout: () => void; - onError: (error: string) => void; - addDebugEvent?: (type: string, status: string, message: string, data?: any) => void; -} - -type ProcessingStep = 'ocr' | 'analysis' | 'generation' | 'ready'; - -interface ProcessingState { - currentStep: ProcessingStep; - ocrCompleted: number; - ocrTotal: number; - message: string; -} - -export default function StepWaitingClaim({ - sessionId, - claimId, - documentsCount, - onClaimReady, - onTimeout, - onError, - addDebugEvent, -}: Props) { - const eventSourceRef = useRef(null); - const timeoutRef = useRef(null); - - const [state, setState] = useState({ - currentStep: 'ocr', - ocrCompleted: 0, - ocrTotal: documentsCount, - message: 'Распознаём документы...', - }); - - const [elapsedTime, setElapsedTime] = useState(0); - const [error, setError] = useState(null); - - // Таймер для отображения времени - useEffect(() => { - const interval = setInterval(() => { - setElapsedTime(prev => prev + 1); - }, 1000); - - return () => clearInterval(interval); - }, []); - - // SSE подписка - useEffect(() => { - if (!sessionId) { - setError('Отсутствует session_id'); - return; - } - - console.log('🔌 StepWaitingClaim: подписываемся на SSE', { sessionId, claimId }); - - const eventSource = new EventSource(`/api/v1/events/${sessionId}`); - eventSourceRef.current = eventSource; - - addDebugEvent?.('waiting', 'info', '🔌 Подписка на SSE для ожидания заявления', { - session_id: sessionId, - claim_id: claimId, - }); - - // Таймаут 5 минут - timeoutRef.current = setTimeout(() => { - console.warn('⏰ Timeout ожидания заявления'); - setError('Превышено время ожидания. Попробуйте обновить страницу.'); - addDebugEvent?.('waiting', 'warning', '⏰ Таймаут ожидания заявления'); - eventSource.close(); - onTimeout(); - }, 300000); // 5 минут - - eventSource.onopen = () => { - console.log('✅ SSE соединение открыто (waiting)'); - addDebugEvent?.('waiting', 'info', '✅ SSE соединение открыто'); - }; - - eventSource.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - console.log('📥 SSE event (waiting):', data); - - const eventType = data.event_type || data.type; - - // OCR документа завершён - if (eventType === 'document_ocr_completed') { - setState(prev => ({ - ...prev, - ocrCompleted: prev.ocrCompleted + 1, - message: `Распознано ${prev.ocrCompleted + 1} из ${prev.ocrTotal} документов`, - })); - addDebugEvent?.('waiting', 'info', `📄 OCR завершён: ${data.document_type}`); - } - - // Все документы распознаны, начинаем анализ - if (eventType === 'ocr_all_completed' || eventType === 'analysis_started') { - setState(prev => ({ - ...prev, - currentStep: 'analysis', - message: 'Анализируем данные...', - })); - addDebugEvent?.('waiting', 'info', '🔍 Начат анализ данных'); - } - - // Генерация заявления - if (eventType === 'claim_generation_started') { - setState(prev => ({ - ...prev, - currentStep: 'generation', - message: 'Формируем заявление...', - })); - addDebugEvent?.('waiting', 'info', '📝 Начато формирование заявления'); - } - - // Заявление готово! - if (eventType === 'claim_ready' || eventType === 'claim_plan_ready') { - console.log('🎉 Заявление готово!', data); - - // Очищаем таймаут - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - - setState(prev => ({ - ...prev, - currentStep: 'ready', - message: 'Заявление готово!', - })); - - addDebugEvent?.('waiting', 'success', '✅ Заявление готово'); - - // Закрываем SSE - eventSource.close(); - eventSourceRef.current = null; - - // Callback с данными - setTimeout(() => { - onClaimReady(data.data || data.claim_data || data); - }, 500); - } - - // Ошибка - if (eventType === 'claim_error' || data.status === 'error') { - setError(data.message || 'Произошла ошибка при формировании заявления'); - addDebugEvent?.('waiting', 'error', `❌ Ошибка: ${data.message}`); - eventSource.close(); - onError(data.message); - } - - } catch (err) { - console.error('❌ Ошибка парсинга SSE:', err); - } - }; - - eventSource.onerror = (err) => { - console.error('❌ SSE error (waiting):', err); - // Не показываем ошибку сразу — SSE может переподключиться - }; - - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - if (eventSourceRef.current) { - eventSourceRef.current.close(); - eventSourceRef.current = null; - } - }; - }, [sessionId, claimId, onClaimReady, onTimeout, onError, addDebugEvent]); - - // Форматирование времени - const formatTime = (seconds: number) => { - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins}:${secs.toString().padStart(2, '0')}`; - }; - - // Вычисляем процент прогресса - const getProgress = (): number => { - switch (state.currentStep) { - case 'ocr': - // OCR: 0-50% - return state.ocrTotal > 0 - ? Math.round((state.ocrCompleted / state.ocrTotal) * 50) - : 25; - case 'analysis': - return 60; - case 'generation': - return 85; - case 'ready': - return 100; - default: - return 0; - } - }; - - // Индекс текущего шага для Steps - const getStepIndex = (): number => { - switch (state.currentStep) { - case 'ocr': return 0; - case 'analysis': return 1; - case 'generation': return 2; - case 'ready': return 3; - default: return 0; - } - }; - - // === Render === - - if (error) { - return ( - window.location.reload()}> - Обновить страницу - - } - /> - ); - } - - if (state.currentStep === 'ready') { - return ( - } - extra={} - /> - ); - } - - return ( -
- - {/* === Иллюстрация === */} - AI работает - - {/* === Заголовок === */} - {state.message} - - - Наш AI-ассистент обрабатывает ваши документы и формирует заявление. - Это займёт 1-2 минуты. - - - {/* === Прогресс === */} - - - {/* === Шаги обработки === */} - - 0 ? `${state.ocrCompleted}/${state.ocrTotal}` : ''} - icon={state.currentStep === 'ocr' ? : } - /> - : } - /> - : } - /> - } - /> - - - {/* === Таймер === */} - - - - Время ожидания: {formatTime(elapsedTime)} - - - - {/* === Подсказка === */} - - Не закрывайте эту страницу. Обработка происходит на сервере. - - -
- ); -} - - diff --git a/ticket_form/frontend/src/components/form/generateConfirmationFormHTML.ts b/ticket_form/frontend/src/components/form/generateConfirmationFormHTML.ts index 023f77b0..aa1fd14d 100644 --- a/ticket_form/frontend/src/components/form/generateConfirmationFormHTML.ts +++ b/ticket_form/frontend/src/components/form/generateConfirmationFormHTML.ts @@ -887,15 +887,20 @@ export function generateConfirmationFormHTML(data: any, contact_data_confirmed: } function createCheckbox(root, key, checked, labelText, required) { - var id = 'field_' + root + '_' + key + '_' + Math.random().toString(36).slice(2); + // ✅ Генерируем безопасный id (только буквы, цифры, подчёркивание, дефис) + var safeRoot = String(root || '').replace(/[^a-zA-Z0-9_-]/g, '_'); + var safeKey = String(key || '').replace(/[^a-zA-Z0-9_-]/g, '_'); + var randomPart = Math.random().toString(36).slice(2); + var id = 'field_' + safeRoot + '_' + safeKey + '_' + randomPart; var checkedAttr = checked ? ' checked' : ''; var requiredClass = required ? ' required-checkbox' : ''; // ✅ Добавляем name атрибут для правильной работы форм var nameAttr = 'name="' + esc(root) + '_' + esc(key) + '"'; - // ✅ Label правильно связан с input через for/id - var checkboxHtml = '