Initial commit - Stand 26.04.2026
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mobile-frontend:
|
||||
image: nginx:alpine
|
||||
container_name: buchhaltung-mobile
|
||||
ports:
|
||||
- "8081:80" # Oder 443 für HTTPS später
|
||||
volumes:
|
||||
- ./public:/usr/share/nginx/html:ro
|
||||
- ./nginx-config.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
restart: unless-stopped
|
||||
# Für HTTPS später:
|
||||
# environment:
|
||||
# - VIRTUAL_HOST=mobile.taeger-it.de
|
||||
# - LETSENCRYPT_HOST=mobile.taeger-it.de
|
||||
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location ~* (manifest\.json|sw\.js)$ {
|
||||
add_header Cache-Control "no-cache";
|
||||
expires -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#667eea">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>Finanz Flow Mobile</title>
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/icon-192.png">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.9;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 15px;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 15px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.camera-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-input, .form-select {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-input:focus, .form-select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.amount-input {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ocr-result {
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.ocr-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.ocr-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ocr-label {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ocr-value {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 10px 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 15px;
|
||||
color: #999;
|
||||
text-decoration: none;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.loading.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #e0e0e0;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 10px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.success-message {
|
||||
display: none;
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.success-message.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.api-config {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.api-config input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>📱 Finanz Flow Mobile</h1>
|
||||
<p>Rechnungen scannen & verbuchen</p>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- API Config -->
|
||||
<div class="card api-config" id="api-config">
|
||||
<div class="card-title">⚙️ API Einstellungen</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Backend URL</label>
|
||||
<input type="text" id="api-url" placeholder="https://buchhaltung.deine-domain.de/api" value="http://192.168.0.141/api">
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick="saveApiConfig()">
|
||||
💾 Speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scanner -->
|
||||
<div class="card" id="scanner-card">
|
||||
<div class="card-title">📷 Rechnung scannen</div>
|
||||
|
||||
<div class="camera-container" id="camera-container">
|
||||
<video id="video" autoplay playsinline></video>
|
||||
<canvas id="canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Analysiere Rechnung...</p>
|
||||
</div>
|
||||
|
||||
<img id="preview" class="preview-image hidden">
|
||||
|
||||
<button class="btn btn-primary" id="capture-btn" onclick="capturePhoto()">
|
||||
📸 Foto aufnehmen
|
||||
</button>
|
||||
|
||||
<button class="btn btn-secondary hidden" id="retake-btn" onclick="retakePhoto()">
|
||||
🔄 Neu aufnehmen
|
||||
</button>
|
||||
|
||||
<input type="file" id="file-input" accept="image/*,application/pdf" class="hidden" onchange="handleFileSelect(event)">
|
||||
|
||||
<button class="btn btn-secondary" onclick="document.getElementById('file-input').click()" style="margin-top: 10px;">
|
||||
📁 Aus Dateien wählen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- OCR Results -->
|
||||
<div class="card hidden" id="ocr-card">
|
||||
<div class="card-title">📋 Erkannte Daten</div>
|
||||
|
||||
<div class="ocr-result" id="ocr-result">
|
||||
<div class="ocr-row">
|
||||
<span class="ocr-label">Händler:</span>
|
||||
<span class="ocr-value" id="ocr-händler">-</span>
|
||||
</div>
|
||||
<div class="ocr-row">
|
||||
<span class="ocr-label">Datum:</span>
|
||||
<span class="ocr-value" id="ocr-datum">-</span>
|
||||
</div>
|
||||
<div class="ocr-row">
|
||||
<span class="ocr-label">Betrag brutto:</span>
|
||||
<span class="ocr-value" id="ocr-brutto">-</span>
|
||||
</div>
|
||||
<div class="ocr-row">
|
||||
<span class="ocr-label">MwSt (19%):</span>
|
||||
<span class="ocr-value" id="ocr-mwst">-</span>
|
||||
</div>
|
||||
<div class="ocr-row">
|
||||
<span class="ocr-label">Betrag netto:</span>
|
||||
<span class="ocr-value" id="ocr-netto">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Kategorie</label>
|
||||
<select class="form-select" id="kategorie">
|
||||
<option value="">Bitte wählen...</option>
|
||||
<option value="hardware">💻 Hardware / IT</option>
|
||||
<option value="büro">🖨️ Büroausstattung</option>
|
||||
<option value="fahrzeug">🚗 Fahrzeugkosten</option>
|
||||
<option value="werkzeug">🔧 Werkzeug</option>
|
||||
<option value="software">💿 Software / Lizenzen</option>
|
||||
<option value="dienstleistung">🛠️ Dienstleistungen</option>
|
||||
<option value="material">📦 Material</option>
|
||||
<option value="sonstiges">📋 Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Notiz (optional)</label>
|
||||
<input type="text" class="form-input" id="notiz" placeholder="z.B. ASUS ROG Strix Halo X2 für KI-Entwicklung">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" onclick="saveDocument()">
|
||||
💾 In Buchhaltung speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Success -->
|
||||
<div class="success-message" id="success-message">
|
||||
✅ Rechnung erfolgreich verbucht!
|
||||
</div>
|
||||
|
||||
<!-- Recent Documents -->
|
||||
<div class="card" id="recent-card">
|
||||
<div class="card-title">📑 Letzte Dokumente</div>
|
||||
<div id="recent-list">
|
||||
<p style="text-align: center; color: #999; padding: 20px;">Noch keine Dokumente</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation -->
|
||||
<nav class="bottom-nav">
|
||||
<a href="#" class="nav-item active" onclick="showTab('scan')">
|
||||
<span class="nav-icon">📷</span>
|
||||
<span>Scannen</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" onclick="showTab('list')">
|
||||
<span class="nav-icon">📋</span>
|
||||
<span>Dokumente</span>
|
||||
</a>
|
||||
<a href="#" class="nav-item" onclick="showTab('stats')">
|
||||
<span class="nav-icon">📊</span>
|
||||
<span>Übersicht</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Finanz Flow Mobile",
|
||||
"short_name": "FinanzFlow",
|
||||
"description": "Rechnungen scannen und verbuchen",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#667eea",
|
||||
"theme_color": "#667eea",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["business", "finance"],
|
||||
"lang": "de"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Service Worker für Finanz Flow Mobile PWA
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'finanz-flow-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/app.js',
|
||||
'/manifest.json',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png'
|
||||
];
|
||||
|
||||
// Install event - Cache wichtige Dateien
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
console.log('Cache geöffnet');
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate event - Alte Caches löschen
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
console.log('Alter Cache löschen:', cacheName);
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch event - Cache-first Strategie
|
||||
self.addEventListener('fetch', event => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => {
|
||||
// Cache hit - return response
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return fetch(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user