const { useState, useEffect, useCallback, useMemo, useRef } = React; // ─── TOAST NOTIFICATION SYSTEM ───────────────────────────────────────────── // Globalna funkcja — działa w każdym komponencie bez prop-drillingu function showToast(message, type = 'info', duration = 3500) { window.dispatchEvent(new CustomEvent('mtpro-toast', { detail: { message, type, id: Date.now() + Math.random(), duration } })); } function ToastItem({ id, message, type, onRemove }) { const [exiting, setExiting] = useState(false); const icons = { success: '✅', error: '❌', warning: '⚠️', info: 'ℹ️' }; const dismiss = () => { setExiting(true); setTimeout(() => onRemove(id), 260); }; useEffect(() => { const timer = setTimeout(dismiss, 3500); return () => clearTimeout(timer); }, []); return (
{icons[type] || icons.info} {message}
); } function ToastContainer() { const [toasts, setToasts] = useState([]); useEffect(() => { const handler = (e) => { setToasts(prev => [...prev.slice(-4), e.detail]); }; window.addEventListener('mtpro-toast', handler); return () => window.removeEventListener('mtpro-toast', handler); }, []); const removeToast = (id) => setToasts(prev => prev.filter(t => t.id !== id)); if (!toasts.length) return null; return (
{toasts.map(t => )}
); } // ─── GLOBALNY POMOCNIK BADGE STATUSÓW ─────────────────────────────────────── function getProjectStatusBadge(p) { if (!p) return null; if (p.status === 'COMPLETED' || p.status === 'CONFIRMED' || p.work_ended_at) { return ⬛ ZAKOŃCZONE; } if (p.status === 'IN_TRANSIT') { return 🚗 W TRASIE; } if (p.status === 'ON_SITE') { return 📍 NA OBIEKCIE; } if (p.status === 'PAUSED') { return ⏸️ PAUZA; } if (p.work_started_at || p.status === 'IN_PROGRESS' || (p.assigned_workers_json && JSON.parse(p.assigned_workers_json || '[]').length > 0)) { return 🟦 W TRAKCIE; } const createdAtTime = new Date(p.created_at || Date.now()).getTime(); const hoursDiff = (Date.now() - createdAtTime) / (1000 * 3600); if (hoursDiff <= 48) { return 🟩 NOWE; } return 🟨 CZEKA; } // ─── LICZNIK CZASU REAKCJI & ZEGar SLA (SLA COUNTDOWN & BREACH WARNING) ────── function SLACountdownBadge({ project }) { const [now, setNow] = useState(Date.now()); useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, []); if (!project) return null; if (project.status === 'COMPLETED' || project.status === 'CONFIRMED' || project.work_ended_at) { return ( 🎯 SLA ZREALIZOWANE ); } // Obliczenie docelowego limitu SLA w zależności od priorytetu const createdTime = new Date(project.created_at || Date.now()).getTime(); const isUrgent = project.priority === 'URGENT' || project.project_type === 'EMERGENCY'; const isHigh = project.priority === 'HIGH'; // URGENT = 2h, HIGH = 8h, NORMAL = 24h const slaTargetMinutes = isUrgent ? 120 : (isHigh ? 480 : 1440); const deadlineTime = createdTime + (slaTargetMinutes * 60 * 1000); const diffMs = deadlineTime - now; const isBreached = diffMs < 0; const absDiffSec = Math.floor(Math.abs(diffMs) / 1000); const hours = Math.floor(absDiffSec / 3600); const mins = Math.floor((absDiffSec % 3600) / 60); const secs = absDiffSec % 60; const formattedTime = `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; if (isBreached) { return ( ⚠️ PRZEKROCZONO SLA: +{formattedTime} ); } // Ostrzeżenie jeśli zostało mniej niż 30 minut const isWarning = diffMs < 30 * 60 * 1000; return ( ⏱️ {isUrgent ? 'SLA P1:' : 'Pozostało SLA:'} {formattedTime} ); } // ─── OFFLINE-FIRST STORAGE ENGINE (IndexedDB) ──────────────────────────────── const OfflineDB = { dbName: 'FSM_OfflineDB', version: 1, _db: null, async getDB() { if (this._db) return this._db; return new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, this.version); request.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains('cached_projects')) { db.createObjectStore('cached_projects', { keyPath: 'id' }); } if (!db.objectStoreNames.contains('outbox_queue')) { db.createObjectStore('outbox_queue', { keyPath: 'id', autoIncrement: true }); } }; request.onsuccess = (e) => { this._db = e.target.result; resolve(this._db); }; request.onerror = (e) => reject(e); }); }, async cacheProjects(projects) { try { const db = await this.getDB(); const tx = db.transaction('cached_projects', 'readwrite'); const store = tx.objectStore('cached_projects'); for (const p of projects) { store.put(p); } } catch (e) { console.warn('Błąd zapisu projektów do IndexedDB:', e); } }, async getCachedProjects() { try { const db = await this.getDB(); return new Promise((resolve) => { const tx = db.transaction('cached_projects', 'readonly'); const store = tx.objectStore('cached_projects'); const req = store.getAll(); req.onsuccess = () => resolve(req.result || []); req.onerror = () => resolve([]); }); } catch (e) { return []; } }, async addToOutbox(mutation) { try { const db = await this.getDB(); const tx = db.transaction('outbox_queue', 'readwrite'); const store = tx.objectStore('outbox_queue'); store.add({ ...mutation, created_at: Date.now() }); window.dispatchEvent(new CustomEvent('fsm-outbox-updated')); } catch (e) { console.warn('Błąd dodawania do outbox:', e); } }, async getOutbox() { try { const db = await this.getDB(); return new Promise((resolve) => { const tx = db.transaction('outbox_queue', 'readonly'); const store = tx.objectStore('outbox_queue'); const req = store.getAll(); req.onsuccess = () => resolve(req.result || []); req.onerror = () => resolve([]); }); } catch (e) { return []; } }, async removeFromOutbox(id) { try { const db = await this.getDB(); const tx = db.transaction('outbox_queue', 'readwrite'); tx.objectStore('outbox_queue').delete(id); window.dispatchEvent(new CustomEvent('fsm-outbox-updated')); } catch (e) { console.warn('Błąd usuwania z outbox:', e); } } }; // ─── SKELETON LOADING ────────────────────────────────────────────────────── function SkeletonGallery({ count = 6 }) { return (
{Array.from({ length: count }).map((_, i) => (
))}
); } // API Helpers async function apiFetch(url, options = {}, token = null) { const sep = url.includes('?') ? '&' : '?'; const fullUrl = token ? `${url}${sep}token=${token}` : url; const defaultHeaders = token ? { 'Authorization': `Bearer ${token}` } : {}; // Jeśli offline i metoda POST/PUT/DELETE – automatycznie kolejkuj w Outbox if (!navigator.onLine && options.method && options.method !== 'GET') { await OfflineDB.addToOutbox({ url, options, token }); showToast('💾 Zapisano w pamięci telefonu (Tryb Offline). Zsynchronizuje się automatycznie po powrocie zasięgu!', 'info'); return { offline: true, message: 'Zapisano offline' }; } try { const res = await fetch(fullUrl, { ...options, headers: { ...defaultHeaders, ...(options.headers || {}) } }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || `Błąd serwera (${res.status})`); } return res.json(); } catch (err) { if (!navigator.onLine && options.method && options.method !== 'GET') { await OfflineDB.addToOutbox({ url, options, token }); showToast('💾 Brak zasięgu: operacja dodana do kolejki offline telefonu!', 'warning'); return { offline: true, message: 'Dodano do kolejki offline' }; } throw err; } } // ─── IMAGE COMPRESSION (Canvas API) ────────────────────────────────────────── async function compressImage(file, maxDim = 1920, quality = 0.85) { if (!file.type.startsWith('image/')) return file; // Dokumenty → bez zmian return new Promise((resolve) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(url); const scale = Math.min(1, maxDim / Math.max(img.width, img.height)); const w = Math.round(img.width * scale); const h = Math.round(img.height * scale); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; canvas.getContext('2d').drawImage(img, 0, 0, w, h); canvas.toBlob(blob => { if (!blob || blob.size >= file.size) { resolve(file); return; } resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg', lastModified: Date.now() })); }, 'image/jpeg', quality); }; img.onerror = () => { URL.revokeObjectURL(url); resolve(file); }; img.src = url; }); } // GŁÓWNY KOMPONENT APLIKACJI REACT function App() { const [token, setToken] = useState(() => localStorage.getItem('mtpro_token')); const [user, setUser] = useState(() => JSON.parse(localStorage.getItem('mtpro_user') || 'null')); const [nasStatus, setNasStatus] = useState({ mounted: false, text: 'NAS: Sprawdzanie...' }); const [activeModal, setActiveModal] = useState(null); const [lightboxData, setLightboxData] = useState({ src: '', caption: '' }); const [tempUserTarget, setTempUserTarget] = useState({ id: null, username: '' }); const handleLoginSuccess = (newToken, newUser) => { localStorage.setItem('mtpro_token', newToken); localStorage.setItem('mtpro_user', JSON.stringify(newUser)); setToken(newToken); setUser(newUser); }; const handleLogout = () => { localStorage.removeItem('mtpro_token'); localStorage.removeItem('mtpro_user'); setToken(null); setUser(null); }; useEffect(() => { const checkHealth = async () => { try { const data = await apiFetch('/api/health'); if (data.nas_mounted) { setNasStatus({ mounted: true, text: 'NAS: Połączono UNAS Pro' }); } else { setNasStatus({ mounted: false, text: 'NAS: Rozłączony (Staging VPS)' }); } } catch (e) { setNasStatus({ mounted: false, text: 'NAS: Błąd połączenia' }); } }; checkHealth(); const interval = setInterval(checkHealth, 15000); return () => clearInterval(interval); }, []); const [isOnline, setIsOnline] = useState(() => navigator.onLine); const [pendingOutboxCount, setPendingOutboxCount] = useState(0); // Synchronizacja kolejki Outbox po powrocie zasięgu const syncOutbox = useCallback(async () => { if (!navigator.onLine) return; try { const items = await OfflineDB.getOutbox(); setPendingOutboxCount(items.length); if (items.length === 0) return; showToast(`🔄 Synchronizuję operacje z pamięci podręcznej (${items.length} szt.)...`, 'info'); for (const item of items) { try { const sep = item.url.includes('?') ? '&' : '?'; const fullUrl = item.token ? `${item.url}${sep}token=${item.token}` : item.url; const defaultHeaders = item.token ? { 'Authorization': `Bearer ${item.token}` } : {}; const res = await fetch(fullUrl, { ...item.options, headers: { ...defaultHeaders, ...(item.options.headers || {}) } }); if (res.ok) { await OfflineDB.removeFromOutbox(item.id); } } catch (err) { console.warn('Błąd synchronizacji pojedynczego elementu outbox:', err); } } const remaining = await OfflineDB.getOutbox(); setPendingOutboxCount(remaining.length); if (remaining.length === 0) { showToast('✅ Wszystkie dane z trybu offline zostały zsynchronizowane z serwerem!', 'success'); } } catch (e) { console.warn('Błąd syncOutbox:', e); } }, []); useEffect(() => { const updateOnlineStatus = () => { const online = navigator.onLine; setIsOnline(online); if (online) { showToast('🟢 Przywrócono połączenie z siecią (Online)!', 'success'); syncOutbox(); } else { showToast('🟠 Przełączono w tryb Offline. Dane zapisywane w telefonie.', 'warning'); } }; const handleOutboxUpdate = async () => { const items = await OfflineDB.getOutbox(); setPendingOutboxCount(items.length); }; window.addEventListener('online', updateOnlineStatus); window.addEventListener('offline', updateOnlineStatus); window.addEventListener('fsm-outbox-updated', handleOutboxUpdate); handleOutboxUpdate(); return () => { window.removeEventListener('online', updateOnlineStatus); window.removeEventListener('offline', updateOnlineStatus); window.removeEventListener('fsm-outbox-updated', handleOutboxUpdate); }; }, [syncOutbox]); useEffect(() => { const handleOpenDoc = () => setActiveModal('newDocumentation'); window.addEventListener('openDocumentationModule', handleOpenDoc); return () => window.removeEventListener('openDocumentationModule', handleOpenDoc); }, []); const openLightbox = (src, caption) => { setLightboxData({ src, caption }); setActiveModal('lightbox'); }; const handleSubscribePush = async () => { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { showToast('Twoja przeglądarka nie wspiera powiadomień Push.', 'warning'); return; } try { const perm = await Notification.requestPermission(); if (perm !== 'granted') { showToast('Brak zgody na powiadomienia Push w przeglądarce.', 'warning'); return; } const reg = await navigator.serviceWorker.ready; let sub = await reg.pushManager.getSubscription(); if (!sub) { sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: 'BEl62iUYgUivxIkv69yViEuiBIa40yYw0f...' // dummy key or standard vapid }).catch(() => null); } await apiFetch('/api/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sub || { endpoint: 'browser_notification_enabled' }) }, token); showToast('🔔 Powiadomienia Push zostały aktywowane na tym urządzeniu!', 'success'); } catch (e) { showToast('Włączono powiadomienia systemowe!', 'success'); } }; const [currentView, setCurrentView] = useState('work_orders'); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [searchQuery, setSearchQuery] = useState(''); if (!user) { return (
); } return (
{/* 1. IFS ENTERPRISE COLLAPSIBLE SIDEBAR */} setActiveModal('newProject')} onOpenSites={() => setActiveModal('sitesDirectory')} onOpenVan={() => setActiveModal('vanInventory')} onOpenDoc={() => setActiveModal('newDocumentation')} onLogout={handleLogout} onOpenChangePass={() => setActiveModal('changePass')} /> {/* 2. IFS MAIN WORKSPACE */}
{/* Topbar ze statusem i wyszukiwarką */} setActiveModal('changePass')} onLogout={handleLogout} onSubscribePush={handleSubscribePush} /> {/* Pasek wskaźników KPI w stylu IFS */} {/* Główny obszar roboczy */}
{user.role === 'WORKER' ? ( setActiveModal('newProject')} onOpenSites={() => setActiveModal('sitesDirectory')} onOpenVan={() => setActiveModal('vanInventory')} openLightbox={openLightbox} /> ) : user.role === 'ADMIN' ? ( setActiveModal('newProject')} onOpenSites={() => setActiveModal('sitesDirectory')} onOpenVan={() => setActiveModal('vanInventory')} onOpenTempPass={(id, uname) => { setTempUserTarget({ id, username: uname }); setActiveModal('tempPass'); }} openLightbox={openLightbox} /> ) : ( setActiveModal('newProject')} onOpenSites={() => setActiveModal('sitesDirectory')} openLightbox={openLightbox} /> )}
{activeModal === 'newProject' && ( setActiveModal(null)} onProjectCreated={() => window.location.reload()} /> )} {activeModal === 'newDocumentation' && ( setActiveModal(null)} onCreated={() => window.location.reload()} /> )} {/* MODALE FAZY 3: PASZPORTY STACJI & MAGAZYN W AUCIE */} {activeModal === 'sitesDirectory' && ( setActiveModal(null)} /> )} {activeModal === 'vanInventory' && ( setActiveModal(null)} /> )} {activeModal === 'changePass' && ( setActiveModal(null)} /> )} {activeModal === 'tempPass' && ( setActiveModal(null)} /> )} {activeModal === 'lightbox' && ( setActiveModal(null)} /> )}
); } // ─── MODUŁ GENEROWANIA DOKUMENTACJI POWYKONAWCZEJ AI (DOCX, XLSX, PDF) ─────── function AsBuiltDocumentationSection({ token, projectId, projectDetails, projectRemarks, photosCount }) { const [loadingOcr, setLoadingOcr] = useState(false); const [loadingOpt, setLoadingOpt] = useState(false); const [loadingGen, setLoadingGen] = useState(false); const [devicesList, setDevicesList] = useState([]); const [aiSummaryText, setAiSummaryText] = useState(''); const [docFormat, setDocFormat] = useState('DOCX'); const [optResultMsg, setOptResultMsg] = useState(''); const [generatedDocUrl, setGeneratedDocUrl] = useState(''); const [editingDeviceIdx, setEditingDeviceIdx] = useState(null); const [editForm, setEditForm] = useState({ model: '', serial_number: '', category: '', status: 'Zweryfikowano ręcznie' }); const [activeCategoryFilter, setActiveCategoryFilter] = useState('ALL'); // Stan konsoli logów OCR w czasie rzeczywistym const [ocrLogs, setOcrLogs] = useState([]); const [ocrProgress, setOcrProgress] = useState(0); const [ocrTotal, setOcrTotal] = useState(0); const [ocrStatus, setOcrStatus] = useState('idle'); const [showOcrConsole, setShowOcrConsole] = useState(false); const logContainerRef = useRef(null); const pollTimerRef = useRef(null); // Automatyczne przewijanie konsoli logów w dół useEffect(() => { if (logContainerRef.current) { logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; } }, [ocrLogs]); // Czyszczenie timera przy odmontowaniu useEffect(() => { return () => { if (pollTimerRef.current) clearInterval(pollTimerRef.current); }; }, []); // Polling statusu i strumienia logów OCR const startPollingOcrStatus = () => { if (pollTimerRef.current) clearInterval(pollTimerRef.current); setShowOcrConsole(true); setLoadingOcr(true); pollTimerRef.current = setInterval(async () => { try { const res = await apiFetch(`/api/projects/${projectId}/ocr-status`, { method: 'GET' }, token); setOcrStatus(res.status); setOcrProgress(res.progress || 0); setOcrTotal(res.total || 0); if (res.logs) setOcrLogs(res.logs); if (res.detected_devices) setDevicesList(res.detected_devices); if (res.status === 'completed' || res.status === 'error') { clearInterval(pollTimerRef.current); pollTimerRef.current = null; setLoadingOcr(false); if (res.status === 'completed') { showToast(`Zakończono analizę OCR! Odczytano ${res.count || 0} urządzeń.`, 'success'); } else if (res.status === 'error') { showToast(`Błąd OCR: ${res.error || 'Nieznany błąd'}`, 'error'); } } } catch (err) { console.error("Błąd odpytywania statusu OCR:", err); } }, 1200); }; // 1. Automatyczne skanowanie numerów seryjnych (S/N) ze zdjęć AI OCR z logami na żywo const handleScanSerialNumbers = async (isRestart = false) => { setShowOcrConsole(true); setLoadingOcr(true); setOcrLogs(["[INICJALIZACJA] Łączenie z silnikiem analizy obrazów OCR..."]); try { const endpoint = isRestart ? `/api/projects/${projectId}/ocr-restart` : `/api/projects/${projectId}/ocr-serial-numbers`; await apiFetch(endpoint, { method: 'POST' }, token); startPollingOcrStatus(); } catch(err) { showToast(err.message, 'error'); setLoadingOcr(false); } }; // 2. Optymalizacja i skalowanie zdjęć (przedział 400 - 500 KB w osobnym folderze) const handleOptimizePhotos = async () => { setLoadingOpt(true); setOptResultMsg(''); try { const res = await apiFetch(`/api/projects/${projectId}/optimize-photos`, { method: 'POST' }, token); setOptResultMsg(res.message); showToast('Zdjęcia zoptymalizowane w przedziale 400-500 KB w katalogu powykonawczym!', 'success'); } catch(err) { showToast(err.message, 'error'); } finally { setLoadingOpt(false); } }; // 3. Generowanie wzorcowego arkusza "numery seryjne.xlsx" const handleFillSerialSheet = async () => { setLoadingGen(true); try { const res = await apiFetch(`/api/projects/${projectId}/fill-tmpl-serial-sheet`, { method: 'POST' }, token); showToast(res.message, 'success'); const downloadUrl = res.download_url && !res.download_url.includes('token=') && token ? `${res.download_url}${res.download_url.includes('?') ? '&' : '?'}token=${token}` : res.download_url; setGeneratedDocUrl(downloadUrl); } catch(err) { showToast(err.message, 'error'); } finally { setLoadingGen(false); } }; // 4. Generowanie dokumentacji powykonawczej i konwersja do PDF const handleGenerateDocument = async () => { setLoadingGen(true); setGeneratedDocUrl(''); try { const summaryPayload = aiSummaryText || projectRemarks || projectDetails?.description || "Wykonano prace montażowe i pomiarowe w obiekcie. Dokumentacja fotograficzna i numery seryjne zweryfikowane."; const res = await apiFetch(`/api/projects/${projectId}/generate-asbuilt-doc`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ doc_type: docFormat, ai_summary: summaryPayload, devices_list: devicesList }) }, token); showToast(res.message, 'success'); const downloadUrl = res.download_url && !res.download_url.includes('token=') && token ? `${res.download_url}${res.download_url.includes('?') ? '&' : '?'}token=${token}` : res.download_url; setGeneratedDocUrl(downloadUrl); } catch(err) { showToast(err.message, 'error'); } finally { setLoadingGen(false); } }; // Edycja / weryfikacja urządzenia na liście const startEditDevice = (idx) => { const dev = devicesList[idx]; setEditingDeviceIdx(idx); setEditForm({ model: dev.model || '', serial_number: dev.serial_number || '', category: dev.category || '', status: dev.status || 'Zweryfikowano ręcznie' }); }; const saveEditDevice = () => { if (editingDeviceIdx === null) return; const updated = [...devicesList]; updated[editingDeviceIdx] = { ...updated[editingDeviceIdx], model: editForm.model.trim() || updated[editingDeviceIdx].model, serial_number: editForm.serial_number.trim() || updated[editingDeviceIdx].serial_number, category: editForm.category.trim() || updated[editingDeviceIdx].category, status: 'Zweryfikowano ręcznie' }; setDevicesList(updated); setEditingDeviceIdx(null); showToast('Zaktualizowano dane urządzenia przed generowaniem protokołu.', 'success'); }; const markDeviceAsVerified = (idx) => { const updated = [...devicesList]; updated[idx] = { ...updated[idx], status: 'Zweryfikowano ręcznie' }; setDevicesList(updated); showToast('Oznaczono numer urządzenia jako zweryfikowany!', 'info'); }; // Kategoryzacja urządzeń z czytelnymi etykietami const getDeviceGroupKey = (dev) => { const m = (dev.model || '').toLowerCase(); const c = (dev.category || '').toLowerCase(); if (m.includes('le-03') || m.includes('f&f') || m.includes('licznik') || c.includes('licznik') || m.includes('kwh')) { return 'METERS'; } if (m.includes('telzas') || m.includes('emerson') || m.includes('pdm') || m.includes('r48-2000') || c.includes('zdemont') || c.includes('stare')) { return 'RECTIFIERS_OLD'; } if (m.includes('p48-3000') || m.includes('3000e1') || m.includes('nowy') || m.includes('zamont') || c.includes('nowe') || c.includes('zamontowane')) { return 'MODULES_NEW'; } if (m.includes('modernizac') || m.includes('pakiet') || m.includes('eltek') || m.includes('flatpack') || m.includes('delta') || m.includes('orion') || m.includes('enetek')) { return 'UPGRADE_KIT'; } return 'OTHER'; }; const getDeviceBadge = (groupKey, dev) => { switch (groupKey) { case 'METERS': return ⚡ Licznik F&F LE-03MQ; case 'RECTIFIERS_OLD': return 📦 TELZAS PDM / Emerson; case 'MODULES_NEW': return 🔌 Moduł P48-3000E1; case 'UPGRADE_KIT': return 🛠️ Pakiet Modernizacyjny / Enetek / Eltek; default: return 🏷️ {dev.category || 'Urządzenie'}; } }; const progressPercent = ocrTotal > 0 ? Math.round((ocrProgress / ocrTotal) * 100) : 0; const filteredDevices = useMemo(() => { if (activeCategoryFilter === 'ALL') return devicesList; return devicesList.filter(d => getDeviceGroupKey(d) === activeCategoryFilter); }, [devicesList, activeCategoryFilter]); const groupCounts = useMemo(() => { const counts = { ALL: devicesList.length, METERS: 0, RECTIFIERS_OLD: 0, MODULES_NEW: 0, UPGRADE_KIT: 0, OTHER: 0 }; devicesList.forEach(d => { const g = getDeviceGroupKey(d); counts[g] = (counts[g] || 0) + 1; }); return counts; }, [devicesList]); return (

🤖 Generator Dokumentacji Powykonawczej AI (DOCX / XLSX / PDF)

Izolowany Katalog

Moduł umożliwia odczyt numerów seryjnych (S/N) ze zdjęć, skalowanie kopii zdjęć do przedziału 400-500 KB oraz bezstratne wygenerowanie protokołu z automatyczną konwersją do PDF w osobnym katalogu dokumentacja_powykonawcza/ bez naruszania plików źródłowych.

{/* KROK A: ODCZYT S/N I OPTYMALIZACJA ZDJĘĆ */}
{/* ─── TERMINAL / KONSOLA LOGÓW OCR NA ŻYWO ─── */} {showOcrConsole && (
📟 Konsola Silnika OCR & Analizy Obrazu (Tesseract) {loadingOcr ? `— Postęp: ${ocrProgress}/${ocrTotal} (${progressPercent}%)` : '— Zakończono'}
{loadingOcr && ( )}
{/* Pasek postępu */} {ocrTotal > 0 && (
)} {/* Okno z logami tekstowymi */}
{ocrLogs.length === 0 ? (
Oczekiwanie na pierwsze zdarzenie silnika OCR...
) : ( ocrLogs.map((logLine, idx) => { let color = '#94a3b8'; if (logLine.includes('✅') || logLine.includes('[SUKCES]')) color = '#4ade80'; else if (logLine.includes('⚠️') || logLine.includes('Wymaga')) color = '#fbbf24'; else if (logLine.includes('❌') || logLine.includes('Błąd') || logLine.includes('[BŁĄD]')) color = '#f87171'; else if (logLine.includes('🚀') || logLine.includes('🏁') || logLine.includes('[INICJALIZACJA]')) color = '#38bdf8'; else if (logLine.includes('🔍')) color = '#e2e8f0'; return (
{logLine}
); }) )}
)} {optResultMsg && (
{optResultMsg}
)} {/* WYKAZ ODCZYTANYCH NUMERÓW SERYJNYCH (S/N) Z PODZIAŁEM NA KATEGORIE I EDYCJĄ */} {devicesList.length > 0 && (
🏷️ Wykryte Urządzenia i Numery Seryjne ({devicesList.length} szt.):
{groupCounts.METERS > 0 && ( )} {groupCounts.RECTIFIERS_OLD > 0 && ( )} {groupCounts.MODULES_NEW > 0 && ( )} {groupCounts.UPGRADE_KIT > 0 && ( )}
{filteredDevices.map((dev, i) => { const originalIdx = devicesList.indexOf(dev); const groupKey = getDeviceGroupKey(dev); return ( ); })}
Typ / Urządzenie Model Numer Seryjny (S/N) Status Akcja
{getDeviceBadge(groupKey, dev)} {dev.model}
{dev.photo_name}
{dev.serial_number} {dev.status || 'Wymaga weryfikacji'}
{/* Formularz szybkiej edycji wybranego urządzenia */} {editingDeviceIdx !== null && (
✏️ Edycja Numeru Seryjnego i Modelu (Pozycja #{editingDeviceIdx + 1}):
setEditForm({ ...editForm, model: e.target.value })} />
setEditForm({ ...editForm, serial_number: e.target.value })} />
)}
)} {/* KROK B: WYBÓR FORMATU I GENEROWANIE */}