1425 lines
54 KiB
TypeScript
1425 lines
54 KiB
TypeScript
'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>
|
||
)
|
||
}
|