belege-import/app/templates/scan.html

284 lines
10 KiB
HTML

{% extends "base.html" %}
{% set active_page = "scan" %}
{% block content %}
<div class="card">
<h2>Scan-Upload</h2>
<p class="text-muted" style="margin-bottom:1rem;">
Mehrseitige PDF hochladen. Trennseiten mit QR-Code werden automatisch erkannt und die einzelnen Dokumente gesendet.
</p>
<!-- Upload Zone -->
<div id="uploadZone" class="upload-zone" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">&#128196;</div>
<div class="upload-text">PDF hierher ziehen oder klicken zum Auswählen</div>
<div class="upload-hint">Unterstützt große Dateien (1 GB+)</div>
<input type="file" id="fileInput" accept=".pdf,application/pdf" style="display:none;">
</div>
<!-- Upload Progress -->
<div id="uploadProgress" style="display:none;">
<div class="progress-header">
<span id="uploadFilename"></span>
<span id="uploadPercent">0%</span>
</div>
<div class="progress-bar-track">
<div id="uploadBar" class="progress-bar-fill" style="width:0%"></div>
</div>
<div id="uploadStatus" class="text-muted" style="margin-top:0.3rem;font-size:0.85rem;"></div>
</div>
<!-- Processing Progress -->
<div id="processProgress" style="display:none;">
<div class="progress-header">
<span>Verarbeitung</span>
<span id="processStage"></span>
</div>
<div class="progress-bar-track">
<div id="processBar" class="progress-bar-fill progress-bar-processing" style="width:0%"></div>
</div>
<div id="processStatus" class="text-muted" style="margin-top:0.3rem;font-size:0.85rem;"></div>
</div>
<!-- Result -->
<div id="resultArea" style="display:none;"></div>
</div>
<div class="card">
<h2>Trennseiten</h2>
<p class="text-muted" style="margin-bottom:1rem;">
Trennseiten ausdrucken und zwischen die Dokumente legen, bevor der Stapel gescannt wird.
</p>
<a href="/api/separator-pdf" class="btn btn-secondary" download>Trennseiten-PDF herunterladen</a>
</div>
<script>
const CHUNK_SIZE = 10 * 1024 * 1024; // 10 MB
let currentUploadId = null;
// Drag & Drop
const zone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput');
zone.addEventListener('dragover', (e) => {
e.preventDefault();
zone.classList.add('upload-zone-active');
});
zone.addEventListener('dragleave', () => {
zone.classList.remove('upload-zone-active');
});
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('upload-zone-active');
const files = e.dataTransfer.files;
if (files.length > 0) handleFile(files[0]);
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length > 0) handleFile(fileInput.files[0]);
});
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function generateUUID() {
return crypto.randomUUID ? crypto.randomUUID() : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
async function handleFile(file) {
if (!file.name.toLowerCase().endsWith('.pdf')) {
showResult('Bitte eine PDF-Datei auswählen.', 'error');
return;
}
// Reset UI
document.getElementById('resultArea').style.display = 'none';
document.getElementById('processProgress').style.display = 'none';
zone.style.display = 'none';
const uploadId = generateUUID();
currentUploadId = uploadId;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const uploadProgress = document.getElementById('uploadProgress');
const uploadBar = document.getElementById('uploadBar');
const uploadPercent = document.getElementById('uploadPercent');
const uploadStatus = document.getElementById('uploadStatus');
const uploadFilename = document.getElementById('uploadFilename');
uploadFilename.textContent = file.name + ' (' + formatBytes(file.size) + ')';
uploadBar.style.width = '0%';
uploadPercent.textContent = '0%';
uploadStatus.textContent = 'Upload wird gestartet...';
uploadProgress.style.display = '';
// Chunked upload
try {
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('chunk_index', i);
formData.append('total_chunks', totalChunks);
formData.append('upload_id', uploadId);
formData.append('filename', file.name);
const resp = await fetch('/api/scan-upload-chunk', {
method: 'POST',
body: formData,
});
if (!resp.ok) {
const errData = await resp.json().catch(() => ({}));
throw new Error(errData.error || 'Upload fehlgeschlagen (HTTP ' + resp.status + ')');
}
const pct = Math.round(((i + 1) / totalChunks) * 100);
uploadBar.style.width = pct + '%';
uploadPercent.textContent = pct + '%';
uploadStatus.textContent = 'Chunk ' + (i + 1) + '/' + totalChunks + ' (' + formatBytes(end) + ' von ' + formatBytes(file.size) + ')';
}
uploadStatus.textContent = 'Upload abgeschlossen. Starte Verarbeitung...';
startProcessing(uploadId);
} catch (e) {
showResult('Upload-Fehler: ' + e.message, 'error');
resetUploadZone();
}
}
async function startProcessing(uploadId) {
const processProgress = document.getElementById('processProgress');
const processBar = document.getElementById('processBar');
const processStage = document.getElementById('processStage');
const processStatus = document.getElementById('processStatus');
processBar.style.width = '0%';
processStage.textContent = '';
processStatus.textContent = 'Starte Verarbeitung...';
processProgress.style.display = '';
try {
const resp = await fetch('/api/scan-process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ upload_id: uploadId }),
});
if (!resp.ok) {
const errData = await resp.json().catch(() => ({}));
throw new Error(errData.error || 'Verarbeitung konnte nicht gestartet werden');
}
// Subscribe to SSE for progress
listenProgress(uploadId);
} catch (e) {
showResult('Fehler: ' + e.message, 'error');
resetUploadZone();
}
}
function listenProgress(uploadId) {
const processBar = document.getElementById('processBar');
const processStage = document.getElementById('processStage');
const processStatus = document.getElementById('processStatus');
const evtSource = new EventSource('/api/scan-status/' + uploadId);
evtSource.addEventListener('scan', (e) => {
const data = JSON.parse(e.data);
const pct = Math.round((data.current / data.total) * 100);
processBar.style.width = pct + '%';
processStage.textContent = pct + '%';
processStatus.textContent = 'Seite ' + data.current + ' von ' + data.total + ' analysiert...';
});
evtSource.addEventListener('status', (e) => {
const data = JSON.parse(e.data);
processStatus.textContent = data.message || '';
});
evtSource.addEventListener('send', (e) => {
const data = JSON.parse(e.data);
const pct = Math.round((data.current / data.total) * 100);
processBar.style.width = pct + '%';
processStage.textContent = data.current + '/' + data.total;
processStatus.textContent = 'Dokument ' + data.current + ' von ' + data.total + ' wird gesendet...';
});
evtSource.addEventListener('done', (e) => {
evtSource.close();
const data = JSON.parse(e.data);
const result = data.result || {};
let msg = '';
if (result.error) {
msg = result.error;
showResult(msg, 'error');
} else {
msg = result.documents + ' Dokument(e) erkannt';
if (result.separator_pages > 0) {
msg += ' (' + result.separator_pages + ' Trennseite(n))';
}
msg += ', ' + result.sent + ' gesendet';
if (result.errors > 0) {
msg += ', ' + result.errors + ' Fehler';
showResult(msg, 'warning');
} else {
showResult(msg, 'success');
}
}
document.getElementById('processProgress').style.display = 'none';
resetUploadZone();
});
evtSource.addEventListener('error', (e) => {
// Check if it's an SSE error event from our backend
try {
const data = JSON.parse(e.data);
showResult('Verarbeitungsfehler: ' + (data.message || 'Unbekannter Fehler'), 'error');
} catch {
// Connection error
showResult('Verbindung zum Server verloren.', 'error');
}
evtSource.close();
document.getElementById('processProgress').style.display = 'none';
resetUploadZone();
});
}
function showResult(message, type) {
const area = document.getElementById('resultArea');
area.innerHTML = '<div class="alert alert-' + type + '">' + escapeHtml(message) + '</div>';
area.style.display = '';
document.getElementById('uploadProgress').style.display = 'none';
}
function resetUploadZone() {
zone.style.display = '';
fileInput.value = '';
}
function escapeHtml(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
</script>
{% endblock %}