1425 lines
54 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import React, { useState, useRef, useCallback, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { createWorker, PSM } from 'tesseract.js'
import Link from 'next/link'
import Cookies from 'js-cookie'
import { performOCR, validateAddress, getAddressSuggestions } from '@/lib/api'
// Schweizer Adress-Schlüsselwörter (Deutsch, Französisch und Italienisch)
const ADDRESS_KEYWORDS = [
// Deutsch
'strasse', 'str.', 'weg', 'platz', 'gasse',
'hausnummer', 'nr.', 'nummer',
'postfach', 'postleitzahl', 'plz',
'stadt', 'kanton',
'stockwerk', 'etage',
// Französisch
'rue', 'avenue', 'ave.', 'boulevard', 'blvd.',
'place', 'chemin', 'route',
'numéro', 'no.',
'boîte postale', 'code postal',
'ville', 'canton',
'étage',
// Italienisch
'via', 'viale', 'corso', 'piazza',
'numero', 'n.',
'casella postale', 'codice postale',
'città', 'cantone',
'piano',
// Generell
'ch-', 'switzerland', 'schweiz', 'suisse', 'svizzera'
]
// Eski tarayıcılar için tip tanımlamaları
declare global {
interface Navigator {
getUserMedia?: (constraints: MediaStreamConstraints,
successCallback: (stream: MediaStream) => void,
errorCallback: (error: Error) => void) => void;
webkitGetUserMedia?: (constraints: MediaStreamConstraints,
successCallback: (stream: MediaStream) => void,
errorCallback: (error: Error) => void) => void;
mozGetUserMedia?: (constraints: MediaStreamConstraints,
successCallback: (stream: MediaStream) => void,
errorCallback: (error: Error) => void) => void;
msGetUserMedia?: (constraints: MediaStreamConstraints,
successCallback: (stream: MediaStream) => void,
errorCallback: (error: Error) => void) => void;
}
interface MediaDevices {
getUserMedia(constraints: MediaStreamConstraints): Promise<MediaStream>;
}
}
// Window-Interface erweitern
declare global {
interface Window {
handleAddressSelection?: (address: string | null) => void;
}
}
interface AddressInput {
street: string
number: string
city: string
}
const CACHE_DURATION = 120 * 60 * 60 * 1000; // 120 Stunden
const RATE_LIMIT = 1000; // 1 Sekunde
let lastRequestTime = 0;
interface Coordinate {
lat: number;
lon: number;
}
interface RoutePoint {
index: number;
lat: number;
lon: number;
address: string;
}
interface OptimizationResult {
success: boolean;
data?: {
route: RoutePoint[];
distance: number;
duration: number;
};
error?: string;
}
interface CachedAddress {
data: string[];
timestamp: number;
}
interface GuideRect {
x: number;
y: number;
width: number;
height: number;
}
const addressCache = new Map<string, CachedAddress>();
function getCachedAddresses(query: string): string[] | null {
const cached = addressCache.get(query);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
return cached.data;
}
return null;
}
function cacheAddresses(query: string, addresses: string[]) {
addressCache.set(query, {
data: addresses,
timestamp: Date.now()
});
}
async function makeRequest(query: string) {
const now = Date.now();
if (now - lastRequestTime < RATE_LIMIT) {
await new Promise(resolve => setTimeout(resolve, RATE_LIMIT));
}
lastRequestTime = now;
}
// Adresse von Nominatim API suchen
async function fetchFromNominatim(query: string): Promise<string[]> {
try {
const response = await fetch(
`/api/address-suggestions?q=${encodeURIComponent(query)}`,
{
headers: {
'Content-Type': 'application/json'
}
}
);
if (!response.ok) {
// Verwende messagesData für Fehlermeldung (wird später im Komponentenkontext übergeben oder behandelt)
throw new Error('Adresssuchdienst antwortete nicht'); // Generischer Fallback
}
const results = await response.json();
return results;
} catch (error) {
console.error('Fehler bei der Adresssuche:', error);
throw error;
}
}
// Ersatzdienst mit lokalen Daten verwenden
async function searchWithFallbackService(query: string): Promise<string[]> {
// Einfache lokale Suche
const localAddresses = [
'Bahnhofstrasse 1, 8001 Zürich',
'Bundesplatz 3, 3003 Bern',
'Rue du Mont-Blanc 18, 1201 Genève',
'Via Nassa 5, 6900 Lugano'
];
return localAddresses.filter(address =>
address.toLowerCase().includes(query.toLowerCase())
);
}
// Hauptsuchfunktion
async function searchAddress(query: string): Promise<string[]> {
try {
// Zuerst Cache prüfen
const cached = getCachedAddresses(query);
if (cached) {
console.log('Aus Cache geladen:', query);
return cached;
}
// Ratenbegrenzung anwenden
await makeRequest(query);
// API-Anfrage stellen
const results = await fetchFromNominatim(query);
// Ergebnisse zwischenspeichern
cacheAddresses(query, results);
console.log('Von API geladen und im Cache gespeichert:', query);
return results;
} catch (error) {
console.error('Nominatim API Fehler:', error);
// Auf Ersatzdienst wechseln
console.log('Wechsle zu Ersatzdienst');
return await searchWithFallbackService(query);
}
}
// Update the validateWithAPI function to better handle OCR results
const validateWithAPI = async (
text: string,
setAddressSuggestions: React.Dispatch<React.SetStateAction<string[]>>,
setShowSuggestionModal: React.Dispatch<React.SetStateAction<boolean>>,
setCurrentAddress: React.Dispatch<React.SetStateAction<string>>
): Promise<string> => {
try {
console.log("[VALIDATE] Starting address validation for:", text);
// Split text into lines and clean them
const lines = text
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
// Quick check for empty text
if (lines.length === 0) {
console.log("[VALIDATE] No lines to validate");
return '';
}
console.log("[VALIDATE] Working with cleaned lines:", lines);
// Look for patterns that suggest a Swiss address:
// 1. A line with "strasse" or similar street indicator
// 2. A line with 4-digit postal code
let streetLine = '';
let postalCodeLine = '';
for (const line of lines) {
const lowerLine = line.toLowerCase();
// Check for street indicators
if (
(lowerLine.includes('strasse') || lowerLine.includes('str.') ||
lowerLine.includes('weg') || lowerLine.includes('platz')) &&
/\d+/.test(lowerLine) // Street lines typically include a number
) {
streetLine = line;
}
// Check for Swiss postal code format (4 digits followed by city name)
if (/\b\d{4}\b/.test(lowerLine)) {
postalCodeLine = line;
}
}
// If we found both components of an address, format it
if (streetLine && postalCodeLine) {
console.log("[VALIDATE] Found address components:", { streetLine, postalCodeLine });
// Filter out likely person name (usually the first line) if present
const nameLine = lines[0] !== streetLine && lines[0] !== postalCodeLine ? lines[0] : '';
// Construct properly formatted address
let formattedAddress = '';
if (nameLine) {
formattedAddress = `${nameLine}\n${streetLine}\n${postalCodeLine}`;
} else {
formattedAddress = `${streetLine}\n${postalCodeLine}`;
}
console.log("[VALIDATE] Created formatted address:", formattedAddress);
// Try validating with API
const validationResponse = await validateAddress(formattedAddress);
if (validationResponse.success && validationResponse.data) {
console.log("[VALIDATE] API validation successful:", validationResponse.data);
return validationResponse.data;
}
// If API validation fails, use our formatted address anyway
return formattedAddress;
}
// If we couldn't identify clear address components, proceed with API validation
console.log("[VALIDATE] Could not identify clear address components, calling API");
const validationResponse = await validateAddress(text);
if (validationResponse.success && validationResponse.data) {
console.log("[VALIDATE] API validation successful:", validationResponse.data);
return validationResponse.data;
}
// If API validation fails, try to get suggestions
console.log("[VALIDATE] API validation failed, getting suggestions");
const suggestionsResponse = await getAddressSuggestions(text);
if (suggestionsResponse.success && suggestionsResponse.data && suggestionsResponse.data.length > 0) {
console.log("[VALIDATE] Found suggestions:", suggestionsResponse.data);
setAddressSuggestions(suggestionsResponse.data);
setShowSuggestionModal(true);
return '';
}
// If no suggestions, return the original text formatted
console.log("[VALIDATE] No suggestions found, returning original text");
return lines.join('\n');
} catch (error) {
console.error('[VALIDATE] Validation error:', error);
return text;
}
};
// Adresse aus OCR-Text extrahieren (fokussiert auf Straße, Hausnummer, PLZ und Ort)
const extractAddress = async (text: string): Promise<string> => {
console.log('[EXTRACT] Processing OCR text:', text);
// Zeilen aufteilen, trimmen, Leerzeilen entfernen
const lines = text
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
if (lines.length === 0) return '';
// Nur nach Straße und PLZ/Ort suchen (ignoriere Namen/Anreden)
let streetLine = '';
let postalCityLine = '';
// Durchsuche jede Zeile nach relevanten Mustern
for (const line of lines) {
const lowerLine = line.toLowerCase();
// Straße mit Hausnummer erkennen
if ((/strasse|str\.|weg|platz|gasse/i.test(lowerLine)) && /\d+/.test(lowerLine)) {
streetLine = line;
console.log('[EXTRACT] Found street line:', streetLine);
continue;
}
// PLZ (4-stellig in der Schweiz) mit Ort erkennen
if (/\b\d{4}\b/.test(lowerLine)) {
postalCityLine = line;
console.log('[EXTRACT] Found postal/city line:', postalCityLine);
continue;
}
}
// Wenn keine strukturierte Adresse gefunden, nach Teilinformationen suchen
if (!streetLine || !postalCityLine) {
console.log('[EXTRACT] Searching for partial address information');
// Alle Zeilen zusammen durchsuchen
const fullText = lines.join(' ').toLowerCase();
// Straße suchen (falls noch nicht gefunden)
if (!streetLine) {
const streetMatch = fullText.match(/([a-zäöüß]+(?:strasse|str\.|weg|platz|gasse))\s+(\d+)/i);
if (streetMatch) {
streetLine = streetMatch[0];
console.log('[EXTRACT] Extracted street from full text:', streetLine);
}
}
// PLZ/Ort suchen (falls noch nicht gefunden)
if (!postalCityLine) {
const postalMatch = fullText.match(/\b(\d{4})\s+([a-zäöüß]+)\b/i);
if (postalMatch) {
postalCityLine = postalMatch[0];
console.log('[EXTRACT] Extracted postal/city from full text:', postalCityLine);
}
}
}
// Adresse formatieren
let formattedAddress = '';
if (streetLine && postalCityLine) {
// Beide Teile gefunden
formattedAddress = `${streetLine}, ${postalCityLine}`;
} else if (streetLine) {
// Nur Straße gefunden
formattedAddress = streetLine;
} else if (postalCityLine) {
// Nur PLZ/Ort gefunden
formattedAddress = postalCityLine;
} else {
// Fallback: eventuell gibt es Teile der Adresse ohne die typischen Muster
console.log('[EXTRACT] No standard address patterns found, attempting backup extraction');
// Nach Zahlen suchen, die wie Hausnummern oder PLZ aussehen
const numberMatches = [];
for (const line of lines) {
const numbers = line.match(/\b\d+\b/g);
if (numbers) {
for (const num of numbers) {
if (num.length === 4) {
// Wahrscheinlich eine PLZ
numberMatches.push({ type: 'postal', text: line });
} else if (num.length <= 3) {
// Wahrscheinlich eine Hausnummer
numberMatches.push({ type: 'house', text: line });
}
}
}
}
// Wenn Matches gefunden, diese verwenden
if (numberMatches.length > 0) {
formattedAddress = numberMatches.map(match => match.text).join(', ');
} else {
// Letzter Ausweg: OCR-Text ohne Name verwenden
// Oft sind die ersten 1-2 Zeilen Namen, die wir ignorieren wollen
if (lines.length > 2) {
formattedAddress = lines.slice(1).join(', ');
} else {
formattedAddress = lines.join(', ');
}
}
}
console.log('[EXTRACT] Final extracted address:', formattedAddress);
return formattedAddress;
};
// Schweizer Adresse formatieren (optional, wenn spezifische Formatierung benötigt wird)
const formatSwissAddress = (addressParts: string[]): string => {
// Logik zur Formatierung, z.B. Strasse, PLZ Ort
return addressParts.join(', ');
};
// Hilfsfunktion zum Zeichnen des Hilfsrahmens
const drawGuideLine = (canvasRef: React.RefObject<HTMLCanvasElement | null>): GuideRect | null => {
const canvas = canvasRef.current;
if (!canvas) {
console.log("[GUIDE] Canvas reference not available");
return null;
}
const ctx = canvas.getContext('2d');
if (!ctx) {
console.log("[GUIDE] Could not get canvas context");
return null;
}
// Nur zeichnen, wenn Canvas Dimensionen hat
if (canvas.width === 0 || canvas.height === 0) {
console.log("[GUIDE] Canvas has zero dimensions, cannot draw guide");
return null;
}
// Rahmenabmessungen berechnen - Adresse ist meist in einer Zeile
// Daher ist der Rahmen breiter als hoch
const frameWidth = canvas.width * 0.85; // 85% der Canvas-Breite
const frameHeight = canvas.height * 0.25; // 25% der Canvas-Höhe für eine Textzeile
const frameX = Math.floor((canvas.width - frameWidth) / 2);
const frameY = Math.floor((canvas.height - frameHeight) / 2);
// Rahmen in kontrastierender Farbe zeichnen
ctx.strokeStyle = '#ff0000'; // Rot (gut sichtbar auf den meisten Hintergründen)
ctx.lineWidth = 4; // Dickere Linie für bessere Sichtbarkeit
ctx.setLineDash([10, 10]); // Gestrichelte Linie für bessere Sichtbarkeit
ctx.strokeRect(frameX, frameY, frameWidth, frameHeight);
// Text-Hinweis für den Benutzer
ctx.font = 'bold 16px Arial';
ctx.fillStyle = '#ff0000';
ctx.textAlign = 'center';
ctx.fillText('Adresse hier platzieren', canvas.width / 2, frameY - 10);
// Rahmenkoordinaten zurückgeben
const guideRect = {
x: frameX,
y: frameY,
width: frameWidth,
height: frameHeight
};
console.log("[GUIDE] Guide rectangle created:", guideRect);
return guideRect;
};
// Hauptkomponente der Seite
export default function NewRoutePage() {
// Zustandsvariablen (State Hooks)
const [isManualEntry, setIsManualEntry] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [suggestedAddresses, setSuggestedAddresses] = useState<string[]>([])
const [addresses, setAddresses] = useState<string[]>([])
const [currentAddress, setCurrentAddress] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [scanStatus, setScanStatus] = useState('')
const [isOptimizing, setIsOptimizing] = useState(false)
const [showSuggestionModal, setShowSuggestionModal] = useState(false)
const [isAutoScanEnabled, setIsAutoScanEnabled] = useState(false)
const [autoScanIntervalId, setAutoScanIntervalId] = useState<NodeJS.Timeout | null>(null)
const [messagesData, setMessagesData] = useState<any>(null) // Zustand für Übersetzungen
const [isCameraViewVisible, setIsCameraViewVisible] = useState(false); // Steuert Sichtbarkeit des Video-Bereichs
const [mediaStream, setMediaStream] = useState<MediaStream | null>(null); // Hält das Stream-Objekt
// Referenzen (Refs)
const videoRef = useRef<HTMLVideoElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const workerRef = useRef<Tesseract.Worker | null>(null)
const router = useRouter()
// Effect zum Laden der Übersetzungen
useEffect(() => {
const loadMessages = async () => {
const defaultLocale = 'de'; // Standard wie in Middleware
const lang = Cookies.get('NEXT_LOCALE') || defaultLocale; // Fallback auf 'de'
try {
const messagesModule = await import(`@/messages/${lang}.json`);
setMessagesData(messagesModule.default);
} catch (error) {
console.error(`Nachrichtendatei konnte nicht geladen werden: ${lang}`, error);
// Bei Fehler Standard (Deutsch) laden
try {
const defaultMessages = await import(`@/messages/${defaultLocale}.json`);
setMessagesData(defaultMessages.default);
} catch (defaultError) {
console.error(`Standard-Nachrichtendatei (${defaultLocale}) konnte nicht geladen werden:`, defaultError);
setMessagesData({}); // Leeres Objekt, wenn nichts geladen werden kann
}
}
};
loadMessages();
}, []);
// Effect zum Initialisieren und Aufräumen des Tesseract Workers
useEffect(() => {
const initializeWorker = async () => {
try {
console.log("[WORKER] Initializing Tesseract worker...");
const worker = await createWorker({
logger: (m: any) => console.log(m)
});
await worker.loadLanguage('deu');
await worker.initialize('deu');
// Set parameters after initialization
await worker.setParameters({
tessedit_pageseg_mode: PSM.SINGLE_BLOCK, // Treat image as a single uniform block of text
// Add character whitelist for German addresses
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzÄÖÜäöüß0123456789.,- ',
});
console.log("[WORKER] Tesseract parameters set.");
workerRef.current = worker;
console.log("[WORKER] Tesseract worker initialized and assigned to ref.", workerRef.current);
} catch (err) {
console.error("[WORKER] Tesseract Worker konnte nicht initialisiert werden:", err);
setError(messagesData?.newRoute?.scanError || "Fehler beim Initialisieren des Scanners");
}
};
initializeWorker();
return () => {
// Worker beim Verlassen der Komponente beenden
console.log("[WORKER] Terminating Tesseract worker...");
workerRef.current?.terminate()
.then(() => console.log('[WORKER] Tesseract Worker beendet.'))
.catch(err => console.error('[WORKER] Fehler beim Beenden des Tesseract Workers:', err));
workerRef.current = null;
}
}, []); // Empty dependency array - run only once on mount
// Neuer useEffect zum Anhängen des Streams an das Video-Element
useEffect(() => {
const videoElement = videoRef.current;
// Funktion zum Entfernen des Event Listeners
const cleanupListener = () => {
if (videoElement) {
videoElement.removeEventListener('play', handleVideoPlay);
console.log("[EFFECT] Removed play event listener");
}
};
if (isCameraViewVisible && mediaStream && videoElement) {
console.log("[EFFECT] Attaching stream to video element");
// Wichtig: Alle alten Tracks stoppen
if (videoElement.srcObject) {
const oldStream = videoElement.srcObject as MediaStream;
oldStream.getTracks().forEach(track => {
track.stop();
console.log("[EFFECT] Stopped old track:", track.kind);
});
}
videoElement.srcObject = mediaStream;
console.log("[EFFECT] Stream attached to video element");
// Video autoplay attribute setzen und abspielen
videoElement.autoplay = true;
videoElement.muted = true; // Wichtig für mobile Browser
videoElement.playsInline = true; // Wichtig für iOS
// Timeout für Video-Start (gibt dem Browser etwas Zeit)
setTimeout(() => {
videoElement.play()
.then(() => {
console.log("[EFFECT] Video playback started successfully");
// Canvas initial einrichten
if (canvasRef.current) {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (ctx) {
// Canvas initial mit Video-Dimensionen einrichten
setTimeout(() => {
if (videoElement.videoWidth && videoElement.videoHeight) {
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
console.log(`[EFFECT] Canvas dimensions set to: ${canvas.width}x${canvas.height}`);
}
}, 500); // Kurze Verzögerung um sicherzustellen, dass Metadaten geladen sind
}
}
// Event Listener hinzufügen
videoElement.addEventListener('play', handleVideoPlay);
console.log("[EFFECT] Added play event listener");
})
.catch(err => {
console.error("[EFFECT] Video play failed:", err);
setError("Video konnte nicht abgespielt werden: " + (err.message || "Unbekannter Fehler"));
setIsCameraViewVisible(false);
setMediaStream(null);
});
}, 100);
// Cleanup-Funktion zurückgeben
return cleanupListener;
} else {
// Log why the condition failed
console.log("[EFFECT] Not attaching stream.", {
isVisible: isCameraViewVisible,
hasStream: !!mediaStream,
hasVideoElement: !!videoElement
});
// Stream und Tracks stoppen, wenn die Komponente nicht sichtbar ist
if (videoElement && videoElement.srcObject) {
const currentStream = videoElement.srcObject as MediaStream;
currentStream.getTracks().forEach(track => {
track.stop();
console.log("[EFFECT] Stopped track:", track.kind);
});
videoElement.srcObject = null;
console.log("[EFFECT] Stream stopped and detached");
}
// Listener entfernen, falls er noch existiert
cleanupListener();
}
}, [isCameraViewVisible, mediaStream]); // Abhängigkeiten: Sichtbarkeit und Stream
// Funktion zum Abrufen von Adressvorschlägen
const fetchAddressSuggestions = async (term: string) => {
if (term.length < 3) {
setSuggestedAddresses([]);
return;
}
try {
setScanStatus(messagesData?.newRoute?.searchingAddresses || 'Adressen werden gesucht...');
const results = await searchAddress(term);
setSuggestedAddresses(results);
setScanStatus('');
} catch (error) {
console.error('Fehler bei der Adresssuche:', error);
setError(messagesData?.newRoute?.searchError || 'Fehler bei der Adresssuche');
setScanStatus('');
}
};
// Callback für Video-Wiedergabe
const handleVideoPlay = () => {
// Startet das Zeichnen auf dem Canvas, wenn das Video abgespielt wird
requestAnimationFrame(updateCanvas);
};
// Funktion zum Aktualisieren des Canvas (für visuelles Feedback)
const updateCanvas = (timestamp: number) => {
if (!videoRef.current || !canvasRef.current) return;
const video = videoRef.current;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Canvas-Größe an Video anpassen
if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
}
// Videoframe auf Canvas zeichnen
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// Hilfslinie zeichnen
const guideRect = drawGuideLine(canvasRef);
// Nächsten Frame anfordern
requestAnimationFrame(updateCanvas);
};
// Funktion zum Starten des automatischen Scannens
const startAutoScan = () => {
if (autoScanIntervalId) return; // Verhindern, dass mehrere Intervalle gestartet werden
setIsAutoScanEnabled(true);
const intervalId = setInterval(() => {
scanDocument(true); // true übergeben, um im Auto-Scan-Modus zu kennzeichnen
}, 5000); // Alle 5 Sekunden scannen
setAutoScanIntervalId(intervalId);
};
// Funktion zum Stoppen des automatischen Scannens
const stopAutoScan = () => {
if (autoScanIntervalId) {
clearInterval(autoScanIntervalId);
setAutoScanIntervalId(null);
}
setIsAutoScanEnabled(false);
};
// Funktion zum Starten der Kamera (modifiziert)
const startCamera = async () => {
setLoading(true);
setError(null);
setCurrentAddress(''); // Aktuelle Adresse löschen
setMediaStream(null); // Alten Stream zurücksetzen
setIsCameraViewVisible(false); // Ansicht vorerst ausblenden
// Vorhandene Tracks stoppen, falls vorhanden
if (videoRef.current && videoRef.current.srcObject) {
const oldStream = videoRef.current.srcObject as MediaStream;
oldStream.getTracks().forEach(track => track.stop());
videoRef.current.srcObject = null;
}
try {
// Zuerst prüfen, ob Browser getUserMedia unterstützt
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.error('Browser unterstützt getUserMedia nicht!');
throw new Error('Ihr Browser unterstützt die Kamerafunktion nicht. Bitte versuchen Sie es mit einem aktuellen Browser wie Chrome, Firefox oder Safari.');
}
console.log("Requesting camera stream with environment facing mode...");
// Zuerst mit Rückkamera versuchen
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // Bevorzugt Rückkamera für Mobilgeräte
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
console.log("Stream obtained with environment camera:", stream);
setMediaStream(stream);
setIsCameraViewVisible(true);
} catch (envError) {
console.warn("Couldn't access environment camera, trying fallback to any camera:", envError);
// Fallback: Irgendeine verfügbare Kamera verwenden
const stream = await navigator.mediaDevices.getUserMedia({
video: true
});
console.log("Stream obtained with fallback camera:", stream);
setMediaStream(stream);
setIsCameraViewVisible(true);
}
} catch (err) {
console.error('Kamerafehler:', err);
if (err instanceof Error) {
// Detaillierte Fehlermeldungen basierend auf Error.name
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
setError('Kamerazugriff verweigert. Bitte erteilen Sie die Berechtigung und versuchen Sie es erneut.');
} else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
setError('Keine Kamera gefunden. Bitte stellen Sie sicher, dass eine Kamera verbunden ist.');
} else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') {
setError('Die Kamera ist möglicherweise bereits von einer anderen Anwendung in Gebrauch.');
} else if (err.name === 'OverconstrainedError') {
setError('Die angeforderten Kameraeinstellungen werden nicht unterstützt.');
} else {
setError(`Kamerafehler: ${err.message}`);
}
} else {
setError(messagesData?.newRoute?.cameraError || 'Fehler beim Starten der Kamera');
}
setMediaStream(null);
setIsCameraViewVisible(false);
} finally {
setLoading(false);
}
};
// Funktion zum Scannen des Dokuments (manuell oder automatisch)
const scanDocument = async (isAuto = false) => {
// Entry log
console.log(`[SCAN] scanDocument called. isAuto: ${isAuto}, loading: ${loading}, worker ready: ${!!workerRef.current}`);
// Check prerequisites
if (!videoRef.current || !canvasRef.current || !workerRef.current || loading) {
console.log('[SCAN] Exiting: Scanner nicht bereit oder beschäftigt.', {
hasVideo: !!videoRef.current,
hasCanvas: !!canvasRef.current,
hasWorker: !!workerRef.current,
isLoading: loading
});
if (!isAuto) {
setError('Scanner ist nicht bereit. Bitte starten Sie die Kamera und versuchen Sie es erneut.');
}
return;
}
const video = videoRef.current;
// Check video readiness
if (video.readyState < video.HAVE_METADATA || video.videoWidth === 0 || video.videoHeight === 0) {
console.log('[SCAN] Exiting: Videostream noch nicht bereit oder Dimensionen ungültig.', {
readyState: video.readyState,
width: video.videoWidth,
height: video.videoHeight
});
if (!isAuto) {
setError('Videostream nicht bereit. Bitte warten Sie einen Moment oder versuchen Sie, die Kamera neu zu starten.');
}
return;
}
console.log("[SCAN] Proceeding with scan...");
setLoading(true);
setError(null);
setScanStatus(messagesData?.newRoute?.scanning || 'Scannen...');
try {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas context nicht verfügbar');
}
// Capture video frame to canvas
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
console.log('[SCAN] Video frame captured to canvas');
// Get guide rectangle for cropping
const guideRect = drawGuideLine(canvasRef);
// Initialize variables for OCR
let imageDataUrl = '';
let rawOcrText = '';
// Process image with guide rectangle if available
if (guideRect) {
console.log("[SCAN] Processing cropped image with guide frame");
// Create temporary canvas for the cropped area
const tempCanvas = document.createElement('canvas');
tempCanvas.width = guideRect.width;
tempCanvas.height = guideRect.height;
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) {
throw new Error('Temporary canvas context not available');
}
// Apply image processing to improve OCR
tempCtx.filter = 'contrast(150%) brightness(110%) grayscale(1)';
// Copy cropped area to temp canvas
tempCtx.drawImage(
canvas,
guideRect.x, guideRect.y, guideRect.width, guideRect.height,
0, 0, guideRect.width, guideRect.height
);
tempCtx.filter = 'none';
imageDataUrl = tempCanvas.toDataURL('image/png', 1.0);
} else {
// If no guide rectangle, use the full frame
console.log("[SCAN] No guide rectangle, using full image");
imageDataUrl = canvas.toDataURL('image/png', 1.0);
}
// Perform OCR
console.log("[SCAN] Starting OCR recognition...");
const worker = workerRef.current;
const { data: { text } } = await worker.recognize(imageDataUrl);
rawOcrText = text;
console.log('[SCAN] OCR raw result:', rawOcrText);
// Exit if OCR returned empty text
if (!rawOcrText.trim()) {
throw new Error('OCR returned empty text');
}
// Pre-process the OCR text
const processedText = await extractAddress(rawOcrText);
console.log('[SCAN] Processed address:', processedText);
if (!processedText) {
throw new Error('Could not extract address from OCR result');
}
// Validate address
console.log("[SCAN] Validating extracted address...");
const validatedAddress = await validateWithAPI(
processedText,
setSuggestedAddresses,
setShowSuggestionModal,
setCurrentAddress
);
// Handle validation result
if (validatedAddress) {
console.log("[SCAN] Successfully validated address:", validatedAddress);
setCurrentAddress(validatedAddress);
setScanStatus(messagesData?.newRoute?.addressFound || 'Adresse gefunden. Bitte überprüfen und "Hinzufügen" klicken.');
// Stop auto-scan if enabled
if (isAutoScanEnabled) {
stopAutoScan();
}
} else if (showSuggestionModal) {
// If showing suggestions modal, status is handled by the modal
console.log("[SCAN] Showing address suggestions modal");
setScanStatus(messagesData?.newRoute?.chooseSuggestion || 'Bitte wählen Sie eine Adresse aus den Vorschlägen.');
} else {
// If no valid address and no suggestions, let user edit the text
console.log("[SCAN] No validated address, showing OCR text for editing");
setCurrentAddress(processedText);
setScanStatus(messagesData?.newRoute?.checkAddress || 'Adresse erkannt, bitte überprüfen und bearbeiten.');
}
} catch (error) {
console.error('[SCAN] Error during scanning:', error);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
console.log('[SCAN] Error details:', errorMessage);
// Provide user-friendly error messages
if (errorMessage.includes('empty text')) {
setError('Keine Adresse im Bild erkannt. Bitte positionieren Sie die Adresse innerhalb des markierten Bereichs.');
} else {
setError(messagesData?.newRoute?.scanError || 'Fehler beim Scannen des Dokuments');
}
setScanStatus(messagesData?.newRoute?.scanErrorStatus || 'Fehler aufgetreten');
} finally {
setLoading(false);
}
};
// Funktion zum Hinzufügen einer Adresse zur Liste
const addAddress = () => {
if (currentAddress.trim()) {
// Extract only the essential address components
const addressParts = currentAddress.split(/[\n,]/).map(part => part.trim()).filter(part => part.length > 0);
// Identify street and postal/city parts
let streetPart = '';
let postalCityPart = '';
// Look for street and postal/city in each part
for (const part of addressParts) {
const lowerPart = part.toLowerCase();
// Street with number
if (/strasse|str\.|weg|platz|gasse/i.test(lowerPart) && /\d+/.test(part)) {
streetPart = part.replace(/\s+/g, ' ').trim();
}
// Postal code with city
if (/\b\d{4}\b/.test(part)) {
postalCityPart = part.replace(/\s+/g, ' ').trim();
}
}
// If we couldn't find both parts, look in the full text
if (!streetPart || !postalCityPart) {
const fullText = addressParts.join(' ');
// Try to extract street
if (!streetPart) {
const streetMatch = fullText.match(/([a-zäöüß]+(?:strasse|str\.|weg|platz|gasse))\s+(\d+)/i);
if (streetMatch) {
streetPart = streetMatch[0].trim();
}
}
// Try to extract postal code
if (!postalCityPart) {
const postalMatch = fullText.match(/\b(\d{4})\s+([a-zäöüß]+)\b/i);
if (postalMatch) {
postalCityPart = postalMatch[0].trim();
}
}
}
// Format address with only essential parts
let formattedAddress = '';
if (streetPart && postalCityPart) {
formattedAddress = `${streetPart}, ${postalCityPart}`;
} else if (streetPart) {
formattedAddress = streetPart;
} else if (postalCityPart) {
formattedAddress = postalCityPart;
} else {
// If we couldn't extract specific components, use the original cleaned text
// But try to remove any name parts (typically in the first line)
if (addressParts.length > 1) {
// Check if first part looks like a name
const firstPart = addressParts[0].toLowerCase();
if (!/\d/.test(firstPart) && !/strasse|str\.|weg|platz|gasse/i.test(firstPart)) {
// Skip the first part which likely contains the name
formattedAddress = addressParts.slice(1).join(', ');
} else {
formattedAddress = addressParts.join(', ');
}
} else {
formattedAddress = currentAddress.trim();
}
}
// Final cleanup of formatted address
formattedAddress = formattedAddress
.replace(/,\s*,/g, ',') // Remove double commas
.replace(/\s+,/g, ',') // Remove spaces before commas
.replace(/,(\S)/g, ', $1') // Ensure space after commas
.replace(/(\d{4})\s+([A-Za-zäöüÄÖÜ]+)/g, '$1 $2') // Fix spacing in postal code + city
.replace(/l(\d+)/gi, '1$1') // Replace lowercase L with 1 when preceding digits
.replace(/l\s+(\d+)/gi, '1 $1'); // Replace lowercase L with 1 when before digits
console.log('[ADD] Adding essential address:', formattedAddress);
// Check if this address already exists
if (!addresses.includes(formattedAddress)) {
// Add to addresses list
setAddresses([...addresses, formattedAddress]);
// Reset inputs
setCurrentAddress('');
setSearchTerm('');
setSuggestedAddresses([]);
setError(null);
setScanStatus(messagesData?.newRoute?.addressAdded || 'Adresse hinzugefügt');
// Clear success message after a delay
setTimeout(() => {
if (scanStatus === messagesData?.newRoute?.addressAdded || scanStatus === 'Adresse hinzugefügt') {
setScanStatus('');
}
}, 2000);
} else {
// Address already exists in the list
setError(messagesData?.newRoute?.duplicateAddress || 'Diese Adresse ist bereits in der Liste');
setTimeout(() => setError(null), 3000);
}
}
};
// Funktion zur Optimierung und Speicherung der Route
const optimizeAndSaveRoute = async () => {
if (addresses.length < 2) {
setError(messagesData?.newRoute?.minAddressError || 'Mindestens 2 Adressen erforderlich')
return
}
try {
setIsOptimizing(true)
setScanStatus(messagesData?.newRoute?.optimizing || 'Route wird optimiert...')
setError(null)
// Route erstellen (API-Aufruf)
const createResponse = await fetch('/api/routes', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({ addresses })
});
const createResult = await createResponse.json();
if (!createResponse.ok || !createResult.success || !createResult.data) {
throw new Error(createResult.error || messagesData?.newRoute?.saveError || 'Route konnte nicht gespeichert werden');
}
const routeId = createResult.data.id;
setScanStatus(messagesData?.newRoute?.routeCreated || 'Route erstellt, wird optimiert...');
// Route optimieren (API-Aufruf)
// Hinweis: Die Optimierung erfolgt serverseitig durch den vorherigen API-Aufruf `/api/routes/optimize`,
// welcher innerhalb der `/api/routes` POST-Logik aufgerufen werden sollte ODER hier separat.
// Aktuell scheint die Optimierung implizit beim Erstellen zu erfolgen oder der Client erwartet das Ergebnis.
// Wir nehmen an, dass `createResult.data` die optimierte Route enthält oder wir auf die Route-Detailseite weiterleiten.
// Annahme: Die API gibt die optimierten Details zurück oder setzt den Status.
// Wir leiten zur Detailseite weiter, wo die optimierte Route angezeigt wird.
console.log('Route erfolgreich erstellt/optimiert. Route ID:', routeId)
setScanStatus(messagesData?.newRoute?.routeSaved || 'Route erfolgreich gespeichert');
// Zur Routen-Detailseite oder Dashboard weiterleiten
router.push(`/dashboard/routes/${routeId}`);
// Optional: Kurze Erfolgsmeldung anzeigen, bevor weitergeleitet wird
// await new Promise(resolve => setTimeout(resolve, 1500));
} catch (error) {
console.error('Routenoptimierungs-/Speicherfehler:', error)
setError(error instanceof Error ? error.message : messagesData?.newRoute?.generalError || 'Fehler während der Routenverarbeitung')
setScanStatus(messagesData?.newRoute?.scanErrorStatus || 'Fehler aufgetreten')
} finally {
setIsOptimizing(false)
}
};
// Ladezustand für Übersetzungen
if (!messagesData) {
return (
<div className="min-h-screen flex items-center justify-center">
{/* Verwende Übersetzung für Ladeanzeige */}
<p>{messagesData?.common?.loading || 'Laden...'}</p>
</div>
);
}
// Haupt-JSX der Komponente
return (
<div className="min-h-screen bg-gray-100 py-6">
{/* Optimierungs-Overlay */}
{isOptimizing && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-xl text-center">
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-indigo-500 mx-auto mb-4"></div>
<p className="text-lg font-semibold text-gray-700">{messagesData?.newRoute?.optimizing}</p>
<p className="text-sm text-gray-500 mt-2">{scanStatus}</p>
</div>
</div>
)}
{/* Adressvorschlagsmodal - neu gestaltet */}
{showSuggestionModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div className="bg-white p-6 rounded-lg shadow-xl max-w-md w-full">
<h3 className="text-lg font-bold text-gray-900 mb-4">
{messagesData?.newRoute?.addressSuggestions || 'Adressvorschläge'}
</h3>
<p className="text-sm text-gray-600 mb-4">
{messagesData?.newRoute?.chooseAddress || 'Bitte wählen Sie die korrekte Adresse aus:'}
</p>
<div className="max-h-60 overflow-y-auto mb-4">
{suggestedAddresses.map((addr, idx) => (
<button
key={idx}
onClick={() => {
setCurrentAddress(addr);
setShowSuggestionModal(false);
}}
className="w-full text-left p-3 border border-gray-300 rounded mb-2 hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-colors"
>
{addr}
</button>
))}
</div>
<div className="flex justify-between">
<button
onClick={() => setShowSuggestionModal(false)}
className="px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-100"
>
{messagesData?.common?.cancel || 'Abbrechen'}
</button>
<button
onClick={() => {
// Manuell mit dem aktuellen OCR-Text fortfahren
setShowSuggestionModal(false);
}}
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
{messagesData?.newRoute?.manualEdit || 'Manuell bearbeiten'}
</button>
</div>
</div>
</div>
)}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center mb-8">
{/* Seitentitel */}
<h1 className="text-3xl font-bold text-gray-900">{messagesData?.newRoute?.title}</h1>
{/* Zurück-Button */}
<Link
href="/dashboard"
className="bg-gray-500 text-white px-4 py-2 rounded-md hover:bg-gray-600"
>
{messagesData?.common?.back}
</Link>
</div>
<div className="mt-8 bg-white shadow rounded-lg p-6">
<div className="space-y-6">
{/* Eingabemodus-Auswahl */}
<div className="flex justify-center space-x-4 mb-6">
<button
onClick={() => setIsManualEntry(false)}
className={`px-4 py-2 rounded ${
!isManualEntry
? 'bg-indigo-600 text-white'
: 'bg-gray-200 text-gray-700'
}`}
>
{messagesData?.newRoute?.scanAddress}
</button>
<button
onClick={() => setIsManualEntry(true)}
className={`px-4 py-2 rounded ${
isManualEntry
? 'bg-indigo-600 text-white'
: 'bg-gray-200 text-gray-700'
}`}
>
{messagesData?.newRoute?.manualEntry}
</button>
</div>
{/* Kamera-Scan-Ansicht */}
{!isManualEntry ? (
<>
{/* Kamerabild & Canvas - Conditionally render this block */}
{isCameraViewVisible && (
<div className="relative border-2 border-gray-300 rounded-lg overflow-hidden aspect-video bg-black">
<video
ref={videoRef}
className="w-full h-full object-cover"
playsInline // Wichtig für mobile Geräte
muted // Stummschalten, um Autoplay zu ermöglichen
/>
<canvas
ref={canvasRef}
className="absolute top-0 left-0 w-full h-full pointer-events-none" // Canvas über Video legen
/>
{!videoRef.current?.srcObject && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50">
<p className="text-white text-center p-4">{messagesData?.newRoute?.alignAddress || 'Richten Sie die Adresse in diesem Rahmen aus'}</p>
</div>
)}
</div>
)}
{/* Statusanzeige für Scan */}
{scanStatus && (
<div className="text-center text-sm font-medium text-indigo-600 p-2 bg-indigo-50 rounded-md">
{scanStatus}
</div>
)}
{/* Kamerasteuerungsbuttons */}
<div className="flex justify-center space-x-4">
<button
onClick={startCamera}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
disabled={loading}
>
{messagesData?.common?.start}
</button>
<button
onClick={isAutoScanEnabled ? stopAutoScan : startAutoScan}
className={`${
isAutoScanEnabled ? 'bg-red-500' : 'bg-green-500'
} text-white px-4 py-2 rounded disabled:opacity-50`}
disabled={loading || !isCameraViewVisible || !mediaStream}
>
{/* Verwende Übersetzungsschlüssel */}
{isAutoScanEnabled ? messagesData?.newRoute?.autoScanStop : messagesData?.newRoute?.autoScanStart}
</button>
<button
onClick={() => {
console.log("[BUTTON] 'Adresse scannen' clicked."); // Log button click
scanDocument(false); // Manueller Scan
}}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50"
disabled={loading || !isCameraViewVisible || !mediaStream}
>
{messagesData?.newRoute?.scanAddress}
</button>
</div>
{/* Bearbeitung des gescannten Adresstextes */}
<div className="space-y-4">
<textarea
value={currentAddress}
onChange={(e) => setCurrentAddress(e.target.value)}
className="w-full p-3 border-2 border-gray-300 rounded-lg text-gray-800 font-medium placeholder-gray-500 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 transition-colors duration-200"
rows={4}
placeholder={messagesData?.newRoute?.noAddressFound}
/>
<div className="flex justify-end">
<button
onClick={addAddress}
className="px-4 py-2 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 font-medium transition-colors duration-200 disabled:opacity-50"
disabled={!currentAddress.trim()}
>
{messagesData?.common?.add}
</button>
</div>
</div>
</>
) : (
/* Manuelle Adresseingabe */
<div className="space-y-4">
<div className="relative">
<input
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
fetchAddressSuggestions(e.target.value)
}}
className="w-full p-3 border-2 border-gray-300 rounded-lg text-gray-800 font-medium placeholder-gray-500 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 transition-colors duration-200"
placeholder={messagesData?.newRoute?.searchPlaceholder}
/>
{/* Adressvorschläge */}
{suggestedAddresses.length > 0 && (
<ul className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto">
{suggestedAddresses.map((addr, index) => (
<li
key={index}
onClick={() => {
setCurrentAddress(addr); // Adresse in Textarea übernehmen
setSearchTerm(addr); // Suchbegriff aktualisieren
setSuggestedAddresses([]); // Vorschläge schließen
}}
className="px-4 py-2 cursor-pointer hover:bg-gray-100"
>
{addr}
</li>
))}
</ul>
)}
{/* Knopf zum manuellen Hinzufügen der eingegebenen Adresse */}
<div className="flex justify-end pt-3">
<button
onClick={addAddress}
className="px-4 py-2 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 font-medium transition-colors duration-200 disabled:opacity-50"
disabled={!currentAddress.trim()}
>
{messagesData?.common?.add}
</button>
</div>
</div>
</div>
)}
{/* Adressliste */}
{addresses.length > 0 && (
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">{messagesData?.newRoute?.addressList}</h3>
<ul className="space-y-3">
{addresses.map((address, index) => (
<li
key={index}
className="p-4 bg-gray-50 rounded-lg border-2 border-gray-200 flex justify-between items-center hover:border-indigo-200 transition-colors duration-200"
>
<span className="text-gray-800 font-medium">{address}</span>
<div className="flex gap-3">
{/* Bearbeiten-Knopf (könnte Adresse in Eingabefeld laden) */}
<button
onClick={() => {
setCurrentAddress(address)
setAddresses(addresses.filter((_, i) => i !== index))
setIsManualEntry(true); // Wechsle zur manuellen Eingabe für Bearbeitung
}}
className="text-indigo-600 hover:text-indigo-800 font-medium transition-colors duration-200"
>
{messagesData?.common?.edit}
</button>
{/* Löschen-Knopf */}
<button
onClick={() =>
setAddresses(addresses.filter((_, i) => i !== index))
}
className="text-red-600 hover:text-red-800 font-medium transition-colors duration-200"
>
{messagesData?.common?.delete}
</button>
</div>
</li>
))}
</ul>
</div>
)}
{/* Fehlermeldung */}
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{/* Route Optimieren/Speichern Knopf */}
<div className="flex justify-end">
<button
onClick={optimizeAndSaveRoute}
className="px-6 py-3 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={loading || addresses.length < 2 || isOptimizing}
>
{isOptimizing ? messagesData?.newRoute?.optimizing : messagesData?.newRoute?.saveRoute} {/* Geändert zu saveRoute, da Optimierung Teil des API-Calls ist */}
</button>
</div>
</div>
</div>
</div>
</div>
)
}