351 lines
11 KiB
JavaScript
351 lines
11 KiB
JavaScript
/**
|
|
* Finanz Flow Mobile - Rechnungs-Scanner App
|
|
* PWA mit Kamera-Zugriff und OCR-Integration
|
|
*/
|
|
|
|
// API Konfiguration
|
|
let API_URL = localStorage.getItem('apiUrl') || 'http://192.168.0.141/api';
|
|
|
|
// DOM Elemente
|
|
const video = document.getElementById('video');
|
|
const canvas = document.getElementById('canvas');
|
|
const preview = document.getElementById('preview');
|
|
const captureBtn = document.getElementById('capture-btn');
|
|
const retakeBtn = document.getElementById('retake-btn');
|
|
const scannerCard = document.getElementById('scanner-card');
|
|
const ocrCard = document.getElementById('ocr-card');
|
|
const loading = document.getElementById('loading');
|
|
const successMessage = document.getElementById('success-message');
|
|
|
|
// Aktueller OCR-Daten
|
|
let currentOcrData = null;
|
|
let currentImageBlob = null;
|
|
|
|
// Service Worker Registration
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.register('/sw.js')
|
|
.then(reg => console.log('SW registered:', reg))
|
|
.catch(err => console.log('SW registration failed:', err));
|
|
}
|
|
|
|
// Initialisierung
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
document.getElementById('api-url').value = API_URL;
|
|
loadRecentDocuments();
|
|
initCamera();
|
|
});
|
|
|
|
// API Config speichern
|
|
function saveApiConfig() {
|
|
API_URL = document.getElementById('api-url').value.trim();
|
|
localStorage.setItem('apiUrl', API_URL);
|
|
alert('✅ API-URL gespeichert: ' + API_URL);
|
|
}
|
|
|
|
// Kamera initialisieren
|
|
async function initCamera() {
|
|
try {
|
|
const constraints = {
|
|
video: {
|
|
facingMode: 'environment', // Rückkamera bevorzugen
|
|
width: { ideal: 1920 },
|
|
height: { ideal: 1080 }
|
|
},
|
|
audio: false
|
|
};
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
video.srcObject = stream;
|
|
} catch (err) {
|
|
console.error('Kamera-Zugriff fehlgeschlagen:', err);
|
|
// Fallback: Nur Datei-Upload zeigen
|
|
document.getElementById('camera-container').style.display = 'none';
|
|
captureBtn.style.display = 'none';
|
|
alert('⚠️ Kamera nicht verfügbar. Bitte Datei-Upload nutzen.');
|
|
}
|
|
}
|
|
|
|
// Foto aufnehmen
|
|
function capturePhoto() {
|
|
const ctx = canvas.getContext('2d');
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
ctx.drawImage(video, 0, 0);
|
|
|
|
// Canvas zu Blob konvertieren
|
|
canvas.toBlob((blob) => {
|
|
currentImageBlob = blob;
|
|
const url = URL.createObjectURL(blob);
|
|
preview.src = url;
|
|
preview.classList.remove('hidden');
|
|
video.parentElement.classList.add('hidden');
|
|
captureBtn.classList.add('hidden');
|
|
retakeBtn.classList.remove('hidden');
|
|
|
|
// OCR starten
|
|
performOCR(blob);
|
|
}, 'image/jpeg', 0.9);
|
|
}
|
|
|
|
// Neu aufnehmen
|
|
function retakePhoto() {
|
|
preview.classList.add('hidden');
|
|
preview.src = '';
|
|
video.parentElement.classList.remove('hidden');
|
|
captureBtn.classList.remove('hidden');
|
|
retakeBtn.classList.add('hidden');
|
|
ocrCard.classList.add('hidden');
|
|
currentOcrData = null;
|
|
currentImageBlob = null;
|
|
}
|
|
|
|
// Datei-Upload verarbeiten
|
|
function handleFileSelect(event) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
if (file.type === 'application/pdf') {
|
|
// PDF-Verarbeitung
|
|
currentImageBlob = file;
|
|
uploadDocument(file, 'application/pdf');
|
|
} else {
|
|
// Bild-Verarbeitung
|
|
currentImageBlob = file;
|
|
const url = URL.createObjectURL(file);
|
|
preview.src = url;
|
|
preview.classList.remove('hidden');
|
|
video.parentElement.classList.add('hidden');
|
|
captureBtn.classList.add('hidden');
|
|
retakeBtn.classList.remove('hidden');
|
|
|
|
performOCR(file);
|
|
}
|
|
}
|
|
|
|
// OCR durchführen (simuliert - Backend würde echtes OCR machen)
|
|
async function performOCR(imageBlob) {
|
|
loading.classList.add('active');
|
|
|
|
try {
|
|
// Im echten Betrieb: Bild zum Backend senden für OCR
|
|
// Simulierte OCR-Ergebnisse für Demo
|
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
|
|
// Demo-Daten (würde vom Backend kommen)
|
|
currentOcrData = {
|
|
haendler: 'ASUSTeK Computer Inc.',
|
|
datum: new Date().toISOString().split('T')[0],
|
|
brutto: '2799.00',
|
|
mwst: '446.90',
|
|
netto: '2352.10',
|
|
mwst_satz: '19',
|
|
belegnummer: 'RE-2026-' + Math.floor(Math.random() * 10000)
|
|
};
|
|
|
|
// OCR-Ergebnisse anzeigen
|
|
document.getElementById('ocr-händler').textContent = currentOcrData.haendler;
|
|
document.getElementById('ocr-datum').textContent = currentOcrData.datum;
|
|
document.getElementById('ocr-brutto').textContent = formatCurrency(currentOcrData.brutto);
|
|
document.getElementById('ocr-mwst').textContent = formatCurrency(currentOcrData.mwst);
|
|
document.getElementById('ocr-netto').textContent = formatCurrency(currentOcrData.netto);
|
|
|
|
// Kategorie vorschlagen basierend auf OCR
|
|
suggestCategory(currentOcrData);
|
|
|
|
ocrCard.classList.remove('hidden');
|
|
} catch (err) {
|
|
console.error('OCR Fehler:', err);
|
|
alert('❌ OCR fehlgeschlagen: ' + err.message);
|
|
} finally {
|
|
loading.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
// Kategorie vorschlagen
|
|
function suggestCategory(ocrData) {
|
|
const kategorieSelect = document.getElementById('kategorie');
|
|
const haendler = ocrData.haendler.toLowerCase();
|
|
|
|
// Einfache Heuristik
|
|
if (haendler.includes('asus') || haendler.includes('computer') || haendler.includes('hardware')) {
|
|
kategorieSelect.value = 'hardware';
|
|
} else if (haendler.includes('amazon') || haendler.includes('büro')) {
|
|
kategorieSelect.value = 'büro';
|
|
} else if (haendler.includes('werkzeug') || haendler.includes('bau')) {
|
|
kategorieSelect.value = 'werkzeug';
|
|
}
|
|
}
|
|
|
|
// Dokument speichern
|
|
async function saveDocument() {
|
|
if (!currentOcrData || !currentImageBlob) {
|
|
alert('⚠️ Bitte erst ein Bild aufnehmen');
|
|
return;
|
|
}
|
|
|
|
const kategorie = document.getElementById('kategorie').value;
|
|
if (!kategorie) {
|
|
alert('⚠️ Bitte Kategorie wählen');
|
|
return;
|
|
}
|
|
|
|
const notiz = document.getElementById('notiz').value;
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', currentImageBlob, 'rechnung.jpg');
|
|
formData.append('kategorie', kategorie);
|
|
formData.append('betrag', currentOcrData.brutto);
|
|
formData.append('datum', currentOcrData.datum);
|
|
formData.append('haendler', currentOcrData.haendler);
|
|
formData.append('mwst', currentOcrData.mwst);
|
|
formData.append('notiz', notiz);
|
|
|
|
try {
|
|
const response = await fetch(`${API_URL}/documents`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('HTTP ' + response.status);
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log('Gespeichert:', result);
|
|
|
|
// Erfolg anzeigen
|
|
successMessage.classList.add('active');
|
|
setTimeout(() => successMessage.classList.remove('active'), 3000);
|
|
|
|
// Reset
|
|
retakePhoto();
|
|
document.getElementById('kategorie').value = '';
|
|
document.getElementById('notiz').value = '';
|
|
|
|
// Liste aktualisieren
|
|
loadRecentDocuments();
|
|
|
|
} catch (err) {
|
|
console.error('Speichern fehlgeschlagen:', err);
|
|
// Demo-Modus: Lokal speichern
|
|
saveLocally();
|
|
}
|
|
}
|
|
|
|
// Lokales Speichern (Demo-Modus)
|
|
function saveLocally() {
|
|
const documents = JSON.parse(localStorage.getItem('documents') || '[]');
|
|
documents.unshift({
|
|
id: Date.now(),
|
|
...currentOcrData,
|
|
kategorie: document.getElementById('kategorie').value,
|
|
notiz: document.getElementById('notiz').value,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
localStorage.setItem('documents', JSON.stringify(documents.slice(0, 50)));
|
|
|
|
successMessage.classList.add('active');
|
|
setTimeout(() => successMessage.classList.remove('active'), 3000);
|
|
|
|
retakePhoto();
|
|
document.getElementById('kategorie').value = '';
|
|
document.getElementById('notiz').value = '';
|
|
|
|
loadRecentDocuments();
|
|
}
|
|
|
|
// Letzte Dokumente laden
|
|
async function loadRecentDocuments() {
|
|
// Versuche vom Backend zu laden
|
|
try {
|
|
const response = await fetch(`${API_URL}/documents?limit=5`);
|
|
if (response.ok) {
|
|
const documents = await response.json();
|
|
renderDocuments(documents);
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
console.log('Backend nicht erreichbar, nutze lokale Daten');
|
|
}
|
|
|
|
// Fallback: Lokale Daten
|
|
const documents = JSON.parse(localStorage.getItem('documents') || '[]');
|
|
renderDocuments(documents.slice(0, 5));
|
|
}
|
|
|
|
// Dokumente rendern
|
|
function renderDocuments(documents) {
|
|
const container = document.getElementById('recent-list');
|
|
|
|
if (documents.length === 0) {
|
|
container.innerHTML = '<p style="text-align: center; color: #999; padding: 20px;">Noch keine Dokumente</p>';
|
|
return;
|
|
}
|
|
|
|
const html = documents.map(doc => `
|
|
<div style="padding: 12px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center;">
|
|
<div>
|
|
<div style="font-weight: 500; color: #333;">${doc.haendler || 'Unbekannt'}</div>
|
|
<div style="font-size: 0.85rem; color: #666;">${doc.datum} • ${getKategorieLabel(doc.kategorie)}</div>
|
|
</div>
|
|
<div style="font-weight: 600; color: #667eea;">${formatCurrency(doc.brutto || doc.betrag)}</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
// Kategorie-Label
|
|
function getKategorieLabel(value) {
|
|
const labels = {
|
|
hardware: 'Hardware',
|
|
büro: 'Büro',
|
|
fahrzeug: 'Fahrzeug',
|
|
werkzeug: 'Werkzeug',
|
|
software: 'Software',
|
|
dienstleistung: 'Dienstleistung',
|
|
material: 'Material',
|
|
sonstiges: 'Sonstiges'
|
|
};
|
|
return labels[value] || value;
|
|
}
|
|
|
|
// Währung formatieren
|
|
function formatCurrency(value) {
|
|
const num = parseFloat(value);
|
|
if (isNaN(num)) return '-';
|
|
return num.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
|
|
}
|
|
|
|
// Tab-Navigation
|
|
function showTab(tab) {
|
|
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
|
|
event.currentTarget.classList.add('active');
|
|
|
|
// Hier könnte man zwischen verschiedenen Views wechseln
|
|
if (tab === 'scan') {
|
|
document.getElementById('scanner-card').classList.remove('hidden');
|
|
document.getElementById('ocr-card').classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// Touch-Gesten für schnelles Scannen
|
|
document.addEventListener('touchstart', handleTouchStart, false);
|
|
document.addEventListener('touchend', handleTouchEnd, false);
|
|
|
|
let touchStartY = 0;
|
|
|
|
function handleTouchStart(e) {
|
|
touchStartY = e.touches[0].clientY;
|
|
}
|
|
|
|
function handleTouchEnd(e) {
|
|
const touchEndY = e.changedTouches[0].clientY;
|
|
const diff = touchStartY - touchEndY;
|
|
|
|
// Nach unten wischen = Neuladen
|
|
if (diff < -100) {
|
|
loadRecentDocuments();
|
|
}
|
|
}
|