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ĘĆ */}
handleScanSerialNumbers(false)}
disabled={loadingOcr}
>
{loadingOcr ? `⏳ Skanowanie S/N (${ocrProgress}/${ocrTotal || '...'})` : '🔍 Skanuj Numery Seryjne (S/N) ze Zdjęć AI'}
{loadingOpt ? '⏳ Skalowanie Zdjęć...' : '📐 Optymalizuj Wagi Zdjęć (400-500 KB)'}
{loadingGen ? '⏳ Wypełnianie Szablonu...' : '📊 Generuj Arkusz "numery seryjne.xlsx"'}
{/* ─── TERMINAL / KONSOLA LOGÓW OCR NA ŻYWO ─── */}
{showOcrConsole && (
📟 Konsola Silnika OCR & Analizy Obrazu (Tesseract) {loadingOcr ? `— Postęp: ${ocrProgress}/${ocrTotal} (${progressPercent}%)` : '— Zakończono'}
{loadingOcr && (
handleScanSerialNumbers(true)}
style={{ background: 'transparent', border: 'none', color: '#f59e0b', fontSize: '0.72rem', cursor: 'pointer', textDecoration: 'underline' }}
>
🔄 Restartuj OCR
)}
setShowOcrConsole(false)}
style={{ background: 'transparent', border: 'none', color: '#94a3b8', fontSize: '0.72rem', cursor: 'pointer' }}
>
✕ Zwiń
{/* 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.):
setActiveCategoryFilter('ALL')}
style={{ fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px', border: '1px solid var(--card-border)', background: activeCategoryFilter === 'ALL' ? 'var(--primary)' : 'rgba(15,23,42,0.6)', color: '#fff', cursor: 'pointer' }}
>
Wszystkie ({groupCounts.ALL})
{groupCounts.METERS > 0 && (
setActiveCategoryFilter('METERS')}
style={{ fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px', border: '1px solid rgba(56,189,248,0.4)', background: activeCategoryFilter === 'METERS' ? '#0284c7' : 'rgba(56,189,248,0.1)', color: '#38bdf8', cursor: 'pointer' }}
>
⚡ Liczniki F&F ({groupCounts.METERS})
)}
{groupCounts.RECTIFIERS_OLD > 0 && (
setActiveCategoryFilter('RECTIFIERS_OLD')}
style={{ fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px', border: '1px solid rgba(239,68,68,0.4)', background: activeCategoryFilter === 'RECTIFIERS_OLD' ? '#dc2626' : 'rgba(239,68,68,0.1)', color: '#f87171', cursor: 'pointer' }}
>
📦 TELZAS / Emerson ({groupCounts.RECTIFIERS_OLD})
)}
{groupCounts.MODULES_NEW > 0 && (
setActiveCategoryFilter('MODULES_NEW')}
style={{ fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px', border: '1px solid rgba(34,197,94,0.4)', background: activeCategoryFilter === 'MODULES_NEW' ? '#16a34a' : 'rgba(34,197,94,0.1)', color: '#4ade80', cursor: 'pointer' }}
>
🔌 Moduły P48-3000E1 ({groupCounts.MODULES_NEW})
)}
{groupCounts.UPGRADE_KIT > 0 && (
setActiveCategoryFilter('UPGRADE_KIT')}
style={{ fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px', border: '1px solid rgba(168,85,247,0.4)', background: activeCategoryFilter === 'UPGRADE_KIT' ? '#9333ea' : 'rgba(168,85,247,0.1)', color: '#c084fc', cursor: 'pointer' }}
>
🛠️ Pakiet Modernizacyjny / Enetek / Eltek ({groupCounts.UPGRADE_KIT})
)}
Typ / Urządzenie
Model
Numer Seryjny (S/N)
Status
Akcja
{filteredDevices.map((dev, i) => {
const originalIdx = devicesList.indexOf(dev);
const groupKey = getDeviceGroupKey(dev);
return (
{getDeviceBadge(groupKey, dev)}
{dev.model}
{dev.photo_name}
{dev.serial_number}
{dev.status || 'Wymaga weryfikacji'}
startEditDevice(originalIdx)}
style={{ padding: '3px 8px', fontSize: '0.7rem', background: 'rgba(56,189,248,0.15)', color: '#38bdf8', border: '1px solid rgba(56,189,248,0.4)', borderRadius: '4px', cursor: 'pointer', marginRight: '4px' }}
>
✏️ Edytuj S/N
markDeviceAsVerified(originalIdx)}
style={{ padding: '3px 6px', fontSize: '0.7rem', background: 'rgba(34,197,94,0.15)', color: '#4ade80', border: '1px solid rgba(34,197,94,0.4)', borderRadius: '4px', cursor: 'pointer' }}
title="Zatwierdź poprawność numeru"
>
✓
);
})}
{/* Formularz szybkiej edycji wybranego urządzenia */}
{editingDeviceIdx !== null && (
✏️ Edycja Numeru Seryjnego i Modelu (Pozycja #{editingDeviceIdx + 1}):
setEditingDeviceIdx(null)}
style={{ padding: '4px 10px', fontSize: '0.72rem', background: 'transparent', color: '#94a3b8', border: '1px solid #475569', borderRadius: '4px', cursor: 'pointer' }}
>
Anuluj
💾 Zapisz Zmianę
)}
)}
{/* KROK B: WYBÓR FORMATU I GENEROWANIE */}
);
}
// ─── NOWOCZESNA NAWIGACJA W STYLU IFS FIELD SERVICE MANAGEMENT ──────────────
function IFSSidebar({ currentView, setCurrentView, isCollapsed, setIsCollapsed, user, onOpenNewProject, onOpenSites, onOpenVan, onOpenDoc, onLogout, onOpenChangePass }) {
return (
);
}
function IFSTopbar({ user, isOnline, pendingOutboxCount, onSyncOutbox, nasStatus, searchQuery, setSearchQuery, onOpenChangePass, onLogout, onSubscribePush }) {
return (
🔍
setSearchQuery(e.target.value)}
/>
{searchQuery && (
setSearchQuery('')}
style={{ background: 'transparent', border: 'none', color: '#94a3b8', cursor: 'pointer', fontSize: '0.75rem' }}
>
✕
)}
{/* Status łączności i kolejka offline */}
0 ? 'pointer' : 'default', background: isOnline ? 'rgba(16, 185, 129, 0.15)' : 'rgba(239, 68, 68, 0.2)', color: isOnline ? '#4ade80' : '#f87171', border: isOnline ? '1px solid rgba(16, 185, 129, 0.3)' : '1px solid rgba(239, 68, 68, 0.4)' }}
onClick={pendingOutboxCount > 0 ? onSyncOutbox : undefined}
title={pendingOutboxCount > 0 ? 'Kliknij, aby wymusić synchronizację' : ''}
>
{isOnline ? (pendingOutboxCount > 0 ? `Online (Sync: ${pendingOutboxCount})` : 'Online 🟢') : `Offline ⚠️ (${pendingOutboxCount})`}
{/* Status NAS UNAS Pro */}
{nasStatus.text}
🔔 768 ? 'inline' : 'none' }}>Powiadomienia
🔑
Wyloguj
);
}
function IFSKpiBar() {
return (
⚡
Google 5TB
Dysk & Cloud Backup
🤖
Gemini AI
Vision OCR 100%
📍
PWA Offline
Telemetria & GPS
🏢
UNAS Pro
SMB Direct NAS
);
}
// LOGOWANIE / REJESTRACJA
function AuthBox({ onLoginSuccess }) {
const [tab, setTab] = useState('login');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [regEmail, setRegEmail] = useState('');
const [regRole, setRegRole] = useState('WORKER');
const [alertMsg, setAlertMsg] = useState('');
const [infoMsg, setInfoMsg] = useState('');
const handleLoginSubmit = async (e) => {
e.preventDefault();
setAlertMsg('');
setInfoMsg('');
try {
const res = await apiFetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
onLoginSuccess(res.token, res.user);
} catch (err) {
setAlertMsg(err.message);
}
};
const handleRegisterSubmit = async (e) => {
e.preventDefault();
setAlertMsg('');
setInfoMsg('');
try {
const res = await apiFetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email: regEmail, password, role: regRole })
});
if (res.requiresVerification) {
setInfoMsg(res.message);
setTab('login');
} else {
onLoginSuccess(res.token, res.user);
}
} catch (err) {
setAlertMsg(err.message);
}
};
return (
⚡ IFS Field Service Management
MTPRO FSM
System Zarządzania Serwisem Terenowym & AI As-Built
setTab('login')} style={{ flex: 1, padding: '8px', fontSize: '0.85rem' }}>🔐 Logowanie
setTab('register')} style={{ flex: 1, padding: '8px', fontSize: '0.85rem' }}>📝 Rejestracja
{infoMsg && (
{infoMsg}
)}
{alertMsg && (
{alertMsg}
)}
{tab === 'login' ? (
) : (
)}
Domyślne konta startowe:
Kierownik: admin / admin123
Pracownik: pracownik / worker123
Klient: klient / client123
);
}
// ─── FAZA 3: KATALOG PASZPORTÓW OBIEKTÓW & EWIDENCJA DRZEWA SPRZĘTOWEGO ──────
function SitesDirectoryModal({ token, user, onClose, onSelectSiteForProject }) {
const [sites, setSites] = useState([]);
const [search, setSearch] = useState('');
const [selectedSite, setSelectedSite] = useState(null);
const [loading, setLoading] = useState(false);
const [showNewSiteForm, setShowNewSiteForm] = useState(false);
const [newSite, setNewSite] = useState({
code: '', name: '', operator: 'TMPL', address: '', city: '',
access_codes: '', key_safe_info: '', contact_person: '', contact_phone: '', remarks: ''
});
// Nowy stan dla dodawania sprzętu do obiektu
const [newAsset, setNewAsset] = useState({
category: 'Prostownik', manufacturer: 'Enetek', model: 'ETR+ 3048', serial_number: '', slot_number: 'Slot 1', notes: ''
});
const [addingAsset, setAddingAsset] = useState(false);
const fetchSites = async () => {
setLoading(true);
try {
const data = await apiFetch('/api/sites', {}, token);
setSites(data);
if (selectedSite) {
const updated = await apiFetch(`/api/sites/${selectedSite.id}`, {}, token);
setSelectedSite(updated);
}
} catch (e) {
showToast(e.message, 'error');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSites();
}, []);
const handleSelectSite = async (siteId) => {
try {
const details = await apiFetch(`/api/sites/${siteId}`, {}, token);
setSelectedSite(details);
} catch (e) {
showToast(e.message, 'error');
}
};
const handleCreateSite = async (e) => {
e.preventDefault();
if (!newSite.code || !newSite.name) {
showToast('Wpisz numer/kod i nazwę stacji!', 'warning');
return;
}
try {
await apiFetch('/api/sites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSite)
}, token);
showToast('✅ Paszport stacji został utworzony!', 'success');
setShowNewSiteForm(false);
setNewSite({ code: '', name: '', operator: 'TMPL', address: '', city: '', access_codes: '', key_safe_info: '', contact_person: '', contact_phone: '', remarks: '' });
fetchSites();
} catch (err) {
showToast(err.message, 'error');
}
};
const handleAddAsset = async (e) => {
e.preventDefault();
if (!selectedSite || !newAsset.model || !newAsset.serial_number) {
showToast('Wpisz model i numer seryjny urządzenia!', 'warning');
return;
}
try {
await apiFetch(`/api/sites/${selectedSite.id}/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newAsset)
}, token);
showToast(`✅ Dodano ${newAsset.model} (S/N: ${newAsset.serial_number}) do drzewa sprzętowego!`, 'success');
setNewAsset({ category: 'Prostownik', manufacturer: 'Enetek', model: 'ETR+ 3048', serial_number: '', slot_number: 'Slot 1', notes: '' });
setAddingAsset(false);
handleSelectSite(selectedSite.id);
} catch (err) {
showToast(err.message, 'error');
}
};
const filteredSites = sites.filter(s =>
s.code.toLowerCase().includes(search.toLowerCase()) ||
s.name.toLowerCase().includes(search.toLowerCase()) ||
(s.city && s.city.toLowerCase().includes(search.toLowerCase()))
);
return (
🏢 Paszportyzacja Obiektów & Ewidencja Sprzętu (GIS)
✕
setSearch(e.target.value)}
/>
{ setShowNewSiteForm(!showNewSiteForm); setSelectedSite(null); }}
>
{showNewSiteForm ? '▲ Zwiń Formularz' : '+ Nowy Paszport Obiektu'}
{showNewSiteForm && (
)}
{/* PODZIAŁ: LISTA STACJI + KARTA SZCZEGÓŁÓW / DRZEWO SPRZĘTOWE */}
{/* LISTA STACJI */}
Zarejestrowane obiekty ({filteredSites.length}):
{filteredSites.map(s => (
handleSelectSite(s.id)}
style={{
padding: '12px 14px',
borderRadius: '8px',
background: selectedSite?.id === s.id ? 'rgba(37, 99, 235, 0.25)' : 'rgba(30, 41, 59, 0.7)',
border: selectedSite?.id === s.id ? '1.5px solid var(--primary)' : '1px solid var(--card-border)',
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
>
{s.code}
{s.operator}
{s.name}
📍 {s.city || 'Brak miasta'} {s.address ? `• ${s.address}` : ''}
))}
{/* KARTA SZCZEGÓŁÓW I DRZEWO SPRZĘTOWE */}
{selectedSite && (
{selectedSite.name}
Kod ID: {selectedSite.code} | Operator: {selectedSite.operator}
setSelectedSite(null)}>✕ Zwiń
{/* PROCEDURY WEJŚCIA I KODY */}
🔐 DANE DOSTĘPOWE I PROCEDURY WEJŚCIA:
🚪 Kody zamków / bram: {selectedSite.access_codes || 'Brak danych'}
🔑 Depozytor kluczy: {selectedSite.key_safe_info || 'Brak danych'}
👤 Zarządca / Kontakt: {selectedSite.contact_person || '—'} {selectedSite.contact_phone ? `(${selectedSite.contact_phone})` : ''}
{selectedSite.remarks && (
ℹ️ {selectedSite.remarks}
)}
{/* DRZEWO SPRZĘTOWE OBIEKTU (ASSET MANAGEMENT) */}
🗄️ Drzewo Sprzętowe Obiektu ({selectedSite.assets?.length || 0} urządzeń):
setAddingAsset(!addingAsset)}
>
{addingAsset ? '▲ Anuluj' : '+ Dodaj Urządzenie'}
{addingAsset && (
setAddingAsset(false)}>Anuluj
Zapisz w Paszporcie
)}
{(!selectedSite.assets || selectedSite.assets.length === 0) ? (
Brak zarejestrowanych urządzeń na tym obiekcie.
) : (
selectedSite.assets.map(a => (
{a.model} ({a.manufacturer || a.category})
S/N: {a.serial_number} {a.slot_number ? `• ${a.slot_number}` : ''}
{a.status === 'ACTIVE' ? '🟢 Aktywny' : '🔴 Zdemontowany'}
))
)}
)}
Zamknij
);
}
// ─── FAZA 3: MAGAZYN PODRĘCZNY W AUCIE SERWISOWYM (VAN INVENTORY) ───────────
function VanInventoryModal({ token, user, onClose }) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [newItem, setNewItem] = useState({ item_name: '', category: 'Moduł', serial_number: '', quantity: 1, van_plate: 'AUTO-01' });
const [showAddForm, setShowAddForm] = useState(false);
const fetchInventory = async () => {
setLoading(true);
try {
const data = await apiFetch('/api/van-inventory', {}, token);
setItems(data);
} catch (e) {
showToast(e.message, 'error');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchInventory();
}, []);
const handleAddItem = async (e) => {
e.preventDefault();
if (!newItem.item_name) return;
try {
await apiFetch('/api/van-inventory/transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newItem)
}, token);
showToast('✅ Dodano element do magazynu auta!', 'success');
setShowAddForm(false);
setNewItem({ item_name: '', category: 'Moduł', serial_number: '', quantity: 1, van_plate: 'AUTO-01' });
fetchInventory();
} catch (err) {
showToast(err.message, 'error');
}
};
return (
🚐 Magazyn Podręczny w Samochodzie Serwisowym
✕
Stan części i podzespołów w pojeździe:
setShowAddForm(!showAddForm)}
>
{showAddForm ? '▲ Anuluj' : '+ Dodaj Część / Prostownik do Auta'}
{showAddForm && (
setShowAddForm(false)}>Anuluj
Zapisz w Aucie
)}
{items.length === 0 ? (
Brak pozycji w podręcznym magazynie samochodu.
) : (
items.map(item => (
{item.item_name}
Kategoria: {item.category} {item.serial_number ? `| S/N: ${item.serial_number}` : ''}
Auto: {item.van_plate || 'Pojazd Serwisowy'} • Technik: {item.worker_name || user.username}
📦 Na aucie ({item.quantity} szt.)
))
)}
Zamknij
);
}
// ─── CYFROWY PODPIS ODBIORCY (HTML5 Canvas Sign-on-Glass) ───────────────────
function SignatureModal({ signerName, onClose, onSave }) {
const canvasRef = React.useRef(null);
const [isDrawing, setIsDrawing] = React.useState(false);
const [hasSignature, setHasSignature] = React.useState(false);
const [name, setName] = React.useState(signerName || '');
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#0284c7';
}, []);
const getPos = (e) => {
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
return {
x: (clientX - rect.left) * (canvas.width / rect.width),
y: (clientY - rect.top) * (canvas.height / rect.height)
};
};
const startDraw = (e) => {
e.preventDefault();
setIsDrawing(true);
const pos = getPos(e);
const ctx = canvasRef.current.getContext('2d');
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
};
const draw = (e) => {
if (!isDrawing) return;
e.preventDefault();
const pos = getPos(e);
const ctx = canvasRef.current.getContext('2d');
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
setHasSignature(true);
};
const stopDraw = (e) => {
if (isDrawing) {
e.preventDefault();
setIsDrawing(false);
}
};
const clearCanvas = () => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
setHasSignature(false);
};
const handleConfirm = () => {
if (!hasSignature) {
showToast('Złóż podpis na wyznaczonym polu przed zatwierdzeniem!', 'warning');
return;
}
const canvas = canvasRef.current;
const dataUrl = canvas.toDataURL('image/png');
onSave(dataUrl, name);
};
return (
✍️ Cyfrowy Podpis Odbiorcy (Sign-on-Glass)
✕
Imię i Nazwisko Osoby Podpisującej:
setName(e.target.value)}
placeholder="np. Jan Kowalski (Inspektor Nadzoru / Klient)"
/>
Złóż podpis palcem lub rysikiem w ramce poniżej:
🗑️ Wyczyść podpis
Podpis zostanie osadzony w protokole PDF
Anuluj
💾 Zapisz Podpis Odbiorcy
);
}
// WIDOK PRACOWNIKA TERENOWEGO
function WorkerView({ token, user, onOpenNewProject, openLightbox }) {
const [projects, setProjects] = useState([]);
const [selectedProjectId, setSelectedProjectId] = useState('');
const [projectDetails, setProjectDetails] = useState(null);
const [categories, setCategories] = useState([]);
const [selectedCatId, setSelectedCatId] = useState(null);
const [photos, setPhotos] = useState([]);
const [notes, setNotes] = useState('');
const [tags, setTags] = useState('');
const [uploadStatus, setUploadStatus] = useState('');
const [wakeLock, setWakeLock] = useState(null);
const [selectedPhotoIds, setSelectedPhotoIds] = useState(new Set());
const [targetBatchCatId, setTargetBatchCatId] = useState('');
const [galleryFilter, setGalleryFilter] = useState('all');
const [photosLoading, setPhotosLoading] = useState(false);
useEffect(() => {
apiFetch('/api/categories', {}, token).then(cats => {
setCategories(cats);
if (cats.length > 0) setSelectedCatId(cats[0].id);
}).catch(console.error);
apiFetch('/api/projects', {}, token).then(setProjects).catch(console.error);
}, [token]);
const loadProject = async (pid) => {
if (!pid || typeof pid === 'object' || pid === 'undefined' || pid === 'null') {
setProjectDetails(null);
setPhotos([]);
return;
}
setSelectedProjectId(pid);
try {
const details = await apiFetch(`/api/projects/${pid}`, {}, token);
setProjectDetails(details);
fetchPhotos(pid);
} catch (e) {
showToast('Błąd ładowania: ' + e.message, 'error');
}
};
const fetchPhotos = async (pid) => {
setPhotosLoading(true);
try {
const phs = await apiFetch(`/api/photos?project_id=${pid}`, {}, token);
setPhotos(phs);
} catch (e) {
console.error(e);
} finally {
setPhotosLoading(false);
}
};
// Refy trzymające natychmiastowe aktualne wartości bez opóźnień rerenderu Reacta
const queueRef = React.useRef([]);
const isRunningRef = React.useRef(false);
const requestWakeLock = async () => {
try {
if ('wakeLock' in navigator && !wakeLock) {
const lock = await navigator.wakeLock.request('screen');
setWakeLock(lock);
}
} catch (err) {}
};
const releaseWakeLock = () => {
if (wakeLock) {
wakeLock.release().then(() => setWakeLock(null)).catch(() => {});
}
};
const handleFileUpload = async (e, catId, catName) => {
const rawFiles = e.target.files ? Array.from(e.target.files) : [];
if (rawFiles.length === 0) return;
if (!selectedProjectId) {
showToast('Wybierz najpierw zlecenie z listy powyżej!', 'warning');
return;
}
// Kompresja zdjęć przed uploadem (max 1920px, JPEG 85%)
const imgCount = rawFiles.filter(f => f.type.startsWith('image/')).length;
if (imgCount > 0) setUploadStatus(`🗜️ Kompresowanie ${imgCount} zdjęć...`);
const files = await Promise.all(rawFiles.map(f => compressImage(f)));
if (imgCount > 0) {
const before = rawFiles.reduce((s,f) => s+f.size, 0);
const after = files.reduce((s,f) => s+f.size, 0);
const pct = Math.round((1 - after/before)*100);
if (pct > 5) showToast(`Skompresowano ${imgCount} zdjęć — zaoszczędzono ${pct}% (${(( before-after)/1024/1024).toFixed(1)} MB)`, 'info');
}
// Dzielimy pliki na małe porcje po 3 zdjęcia
const CHUNK_SIZE = 3;
const newChunks = [];
for (let i = 0; i < files.length; i += CHUNK_SIZE) {
const chunkFiles = files.slice(i, i + CHUNK_SIZE);
newChunks.push({
catId,
catName,
files: chunkFiles,
chunkIndex: Math.floor(i / CHUNK_SIZE) + 1,
totalChunks: Math.ceil(files.length / CHUNK_SIZE),
notes,
tags,
projectId: selectedProjectId,
retryCount: 0
});
}
// Dodajemy nowe paczki do refu kolejki
queueRef.current.push(...newChunks);
e.target.value = ''; // Reset u wyboru w pliku
// Jeśli pętla procesora jeszcze nie działa, uruchamiamy ją
if (!isRunningRef.current) {
startQueueRunner();
}
};
const startQueueRunner = async () => {
if (isRunningRef.current) return;
isRunningRef.current = true;
await requestWakeLock();
while (queueRef.current.length > 0) {
const currentItem = queueRef.current[0];
const isRetry = (currentItem.retryCount || 0) > 0;
const retryTag = isRetry ? ` 🔄 [Ponawianie próby ${currentItem.retryCount}/3]` : '';
const progressText = currentItem.totalChunks > 1
? `⚡ Paczka ${currentItem.chunkIndex}/${currentItem.totalChunks} (${currentItem.files.length} szt.) w kategorii "${currentItem.catName}"${retryTag}... [Pozostało paczek w kolejce: ${queueRef.current.length}]`
: `⚡ Wgrywanie ${currentItem.files.length} zdjęć w kategorii "${currentItem.catName}"${retryTag}... [Pozostało paczek: ${queueRef.current.length}]`;
setUploadStatus(progressText);
const formData = new FormData();
formData.append('project_id', currentItem.projectId);
if (currentItem.catId) formData.append('category_id', currentItem.catId);
formData.append('notes', currentItem.notes || '');
formData.append('tags', currentItem.tags || '');
currentItem.files.forEach(f => formData.append('photos', f));
let isSuccess = false;
try {
const res = await fetch('/api/photos/upload', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Błąd wgrywania paczki');
fetchPhotos(currentItem.projectId);
isSuccess = true;
} catch (err) {
console.error('Błąd wysyłki paczki:', err);
}
if (isSuccess) {
// Usuwamy udaną paczkę z przodu kolejki
queueRef.current.shift();
} else {
// Obsługa błędu - ponawianie do 3 razy
const head = queueRef.current.shift();
const retries = (head.retryCount || 0) + 1;
if (retries <= 3) {
setUploadStatus(`⚠️ Chwilowy błąd w paczce "${head.catName}". Przesuwam na koniec (Próba ${retries}/3)...`);
queueRef.current.push({ ...head, retryCount: retries });
await new Promise(r => setTimeout(r, 1500)); // Krótka pauza przed ponowieniem
} else {
setUploadStatus(`❌ Nie udało się wgrać paczki 3 zdjęć w kategorii "${head.catName}" po 3 próbach.`);
await new Promise(r => setTimeout(r, 2000));
}
}
}
// Ukończono całą kolejkę
isRunningRef.current = false;
setUploadStatus('✅ Wszystkie zdjęcia z kolejki zostały wgrane!');
setTimeout(() => setUploadStatus(''), 4000);
releaseWakeLock();
};
const handleDeletePhoto = async (photoId, name) => {
if (!confirm(`Czy na pewno chcesz usunąć zdjęcie "${name}"?`)) return;
try {
await apiFetch(`/api/photos/${photoId}`, { method: 'DELETE' }, token);
fetchPhotos(selectedProjectId);
} catch (err) {
showToast(err.message, 'error');
}
};
const [projectRemarks, setProjectRemarks] = useState('');
const [completionDate, setCompletionDate] = useState(() => new Date().toISOString().split('T')[0]);
const [isListening, setIsListening] = useState(false);
const [ghostPhotoUrl, setGhostPhotoUrl] = useState(null);
const [showOcrModal, setShowOcrModal] = useState(false);
const startVoiceDictation = () => {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
showToast('Twoja przeglądarka nie wspiera dyktowania głosowego (użyj Chrome/Edge/Safari).', 'warning');
return;
}
try {
const recognition = new SpeechRecognition();
recognition.lang = 'pl-PL';
recognition.interimResults = false;
recognition.maxAlternatives = 1;
recognition.onstart = () => {
setIsListening(true);
showToast('🎙️ Słucham... Dyktuj uwagi po polsku', 'info');
};
recognition.onresult = (event) => {
const transcript = event.results[0][0].transcript;
setProjectRemarks(prev => (prev ? prev + ' ' + transcript : transcript));
showToast('✅ Rozpoznano i dodano tekst dyktowany!', 'success');
};
recognition.onerror = (event) => {
console.error('Błąd rozpoznawania mowy:', event.error);
showToast('Błąd dyktowania głosowego lub brak sygnału mikrofonu.', 'warning');
setIsListening(false);
};
recognition.onend = () => {
setIsListening(false);
};
recognition.start();
} catch (e) {
console.error(e);
setIsListening(false);
}
};
const handleConfirmDoc = async () => {
if (!selectedProjectId) return;
if (!completionDate) {
showToast('Data zakończenia prac jest wymagana!', 'warning');
return;
}
if (!confirm(`Czy na pewno chcesz zatwierdzić dokumentację dla zlecenia z datą zakończenia: ${completionDate}?`)) return;
try {
const res = await apiFetch(`/api/projects/${selectedProjectId}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ remarks: projectRemarks, completion_date: completionDate })
}, token);
showToast(res.message, 'success');
loadProject(selectedProjectId);
} catch (err) {
showToast(err.message, 'error');
}
};
const toggleSelectPhoto = (id) => {
setSelectedPhotoIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleSelectAll = (checked) => {
if (checked) {
setSelectedPhotoIds(new Set(photos.map(p => p.id)));
} else {
setSelectedPhotoIds(new Set());
}
};
const handleBatchCategoryChange = async () => {
if (selectedPhotoIds.size === 0) {
showToast('Zaznacz najpierw zdjęcia z galerii poniżej!', 'warning');
return;
}
if (!targetBatchCatId) {
showToast('Wybierz docelową kategorię z listy!', 'warning');
return;
}
try {
const res = await apiFetch('/api/photos/update-category-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
photo_ids: Array.from(selectedPhotoIds),
category_id: parseInt(targetBatchCatId)
})
}, token);
showToast(res.message, 'success');
setSelectedPhotoIds(new Set());
setTargetBatchCatId('');
fetchPhotos(selectedProjectId);
} catch (err) {
showToast(err.message, 'error');
}
};
const handleBatchDeleteWorker = async () => {
if (selectedPhotoIds.size === 0) {
showToast('Zaznacz najpierw zdjęcia do usunięcia!', 'warning');
return;
}
if (!confirm(`Czy na pewno chcesz usunąć zaznaczone zdjęcia (${selectedPhotoIds.size} szt.)?`)) return;
try {
const res = await apiFetch('/api/photos/delete-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ photo_ids: Array.from(selectedPhotoIds) })
}, token);
showToast(res.message, 'success');
setSelectedPhotoIds(new Set());
fetchPhotos(selectedProjectId);
} catch (err) {
showToast(err.message, 'error');
}
};
const [showProjectsModal, setShowProjectsModal] = useState(false);
const [statusFilter, setStatusFilter] = useState('ALL');
const [showAiAssistant, setShowAiAssistant] = useState(false);
const [showVanModal, setShowVanModal] = useState(false);
const [showSiteHistory, setShowSiteHistory] = useState(false);
const [showCostingModal, setShowCostingModal] = useState(false);
// Stany Fazy 2: BHP Checklist & Podpis Cyfrowy
const [showBhpModal, setShowBhpModal] = useState(false);
const [showSignatureModal, setShowSignatureModal] = useState(false);
const [bhpData, setBhpData] = useState({
harness_checked: true,
helmet_vest: true,
sep_valid: true,
grounding_checked: true,
grounding_resistance_ohm: '4.2',
hazard_zone_marked: true,
waste_cleaned: true
});
const [signerName, setSignerName] = useState('');
const [signatureSaved, setSignatureSaved] = useState(false);
const [isGettingGps, setIsGettingGps] = useState(false);
// Zaawansowana Telemetria GPS (Faza 2)
const handleTelemetryAction = async (action) => {
if (!selectedProjectId) return;
setIsGettingGps(true);
const executeSend = async (coords = {}) => {
try {
const res = await apiFetch(`/api/projects/${selectedProjectId}/telemetry`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, ...coords })
}, token);
showToast(res.message, 'success');
loadProject(selectedProjectId);
} catch (e) {
showToast(e.message, 'error');
} finally {
setIsGettingGps(false);
}
};
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
(pos) => {
executeSend({ lat: pos.coords.latitude, lng: pos.coords.longitude });
},
(err) => {
console.warn('GPS niedostępny lub odrzucony:', err);
executeSend({});
},
{ enableHighAccuracy: true, timeout: 6000 }
);
} else {
executeSend({});
}
};
const handleSaveChecklistAndSignature = async (sigPng, name) => {
if (!selectedProjectId) return;
try {
const res = await apiFetch(`/api/projects/${selectedProjectId}/save-checklist-signature`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
checklist: bhpData,
signature_png: sigPng,
signer_name: name || user.username
})
}, token);
showToast(res.message, 'success');
setSignatureSaved(true);
setShowSignatureModal(false);
loadProject(selectedProjectId);
} catch (e) {
showToast(e.message, 'error');
}
};
const handleWorkTimeAction = async (action) => {
handleTelemetryAction(action);
};
return (
{/* BANER GŁÓWNEJ AKCJI - GENEROWANIE DOKUMENTACJI POWYKONAWCZEJ TMPL */}
📄 Generowanie Dokumentacji Powykonawczej (TMPL)
Wybierz klienta TMPL, podaj numer obiektu (np. 51482) i kliknij Pobierz dane , aby automatycznie uzupełnić dane stacji z BTSearch.pl!
window.dispatchEvent(new CustomEvent('openDocumentationModule'))}
>
➕ NOWA DOKUMENTACJA POWYKONAWCZA
📁 Praca w Terenie & Wybór Zlecenia
setShowAiAssistant(true)}
>
🤖 Asystent AI Gemini
+ Nowe Zlecenie
{/* WYBÓR ZLECENIA (INLINE EXPANDABLE LIST) */}
setShowProjectsModal(!showProjectsModal)}
style={{
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.9), rgba(15, 23, 42, 0.95))',
padding: '16px',
borderRadius: '12px',
border: '2px dashed var(--primary)',
cursor: 'pointer',
marginBottom: '16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
transition: 'all 0.2s ease'
}}
>
📂
Kliknij, aby wybrać z listy:
{projectDetails ? `${projectDetails.name} ${projectDetails.object_number ? `(${projectDetails.object_number})` : ''}` : 'WYBIERZ ZLECENIE Z LISTY...'}
{projectDetails && (
{getProjectStatusBadge(projectDetails)}
Klient: {projectDetails.client_name}
)}
{showProjectsModal ? '▲ Zamknij Listę' : `▼ Rozwiń Listę (${projects.length})`}
{/* ROZWIJANA WIDOCZNA SEKCJA LISTY ZLECEŃ (IN-PAGE EXPANDABLE) */}
{showProjectsModal && (
📋 Wybierz Zlecenie z Listy
setShowProjectsModal(false)}>Zamknij ✖
{/* FILTRY STATUSÓW */}
setStatusFilter('ALL')}>Wszystkie ({projects.length})
setStatusFilter('NOWE')}>🟩 Nowe
setStatusFilter('CZEKA')}>🟨 Czeka
setStatusFilter('IN_PROGRESS')}>🟦 W trakcie
setStatusFilter('COMPLETED')}>⬛ Zakończone
{projects.filter(p => {
if (statusFilter === 'ALL') return true;
if (statusFilter === 'COMPLETED') return p.status === 'COMPLETED' || p.status === 'CONFIRMED' || p.work_ended_at;
if (statusFilter === 'IN_PROGRESS') return p.work_started_at || p.status === 'IN_PROGRESS' || (p.assigned_workers_json && JSON.parse(p.assigned_workers_json || '[]').length > 0);
const hrs = (Date.now() - new Date(p.created_at || Date.now()).getTime()) / (1000 * 3600);
if (statusFilter === 'NOWE') return hrs <= 48 && p.status !== 'COMPLETED' && p.status !== 'CONFIRMED';
if (statusFilter === 'CZEKA') return hrs > 48 && !p.work_started_at && p.status !== 'COMPLETED';
return true;
}).map(p => (
{ loadProject(p.id); setShowProjectsModal(false); }}
style={{
background: p.id === parseInt(selectedProjectId) ? 'rgba(37, 99, 235, 0.3)' : 'rgba(30, 41, 59, 0.8)',
padding: '12px 14px',
borderRadius: '8px',
border: p.id === parseInt(selectedProjectId) ? '1.5px solid var(--primary)' : '1px solid var(--card-border)',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
transition: 'all 0.15s ease'
}}
>
{p.name} {p.object_number ? (Obiekt: {p.object_number}) : ''}
Klient: {p.client_name} | Kod: {p.code} | Wgrywano zdjęć: {p.photo_count || 0} szt.
{p.planned_completion_date && (
📅 Planowany termin: {p.planned_completion_date}
)}
{getProjectStatusBadge(p)}
))}
)}
{projectDetails && (
{projectDetails.name} {projectDetails.object_number ? `(Obiekt: ${projectDetails.object_number})` : ''}
{getProjectStatusBadge(projectDetails)}
Klient: {projectDetails.client_name} | Kod: {projectDetails.code}
{projectDetails.planned_completion_date && (
| 📅 Planowane wykonanie: {projectDetails.planned_completion_date}
)}
{/* ADRES I BEZPOŚREDNIA NAWIGACJA GPS DLA TECHNIKA */}
{(projectDetails.location || (projectDetails.latitude && projectDetails.longitude)) && (
📍 Adres & Współrzędne Obiektu:
{projectDetails.location || 'Brak wpisanego adresu tekstowego'}
{projectDetails.latitude && projectDetails.longitude && (
(GPS: {projectDetails.latitude.toFixed(5)}, {projectDetails.longitude.toFixed(5)})
)}
)}
{/* SEKCJA TELEMETRII CZASU I STATUSÓW TERENOWYCH GPS (FAZA 2) */}
🛰️ Telemetria Terenowa & GPS:
{isGettingGps && 📡 Odczyt GPS... }
Status: {projectDetails.status || 'DRAFT'}
handleTelemetryAction('travel')}
>
🚗 W trasie
handleTelemetryAction('on_site')}
>
📍 Na obiekcie
handleTelemetryAction('start')}
>
▶️ Start prac
handleTelemetryAction('pause')}
>
⏸️ Pauza
handleTelemetryAction('stop')}
>
🏁 Zakończ
{/* DANE CZASOWE I TELEMETRIA */}
Wyjazd: {projectDetails.travel_started_at || '—'}
Przybycie: {projectDetails.on_site_at || '—'}
Start prac: {projectDetails.work_started_at || '—'}
Koniec: {projectDetails.work_ended_at || '—'}
{projectDetails.gps_start_lat && (
GPS: {projectDetails.gps_start_lat?.toFixed(4)}, {projectDetails.gps_start_lng?.toFixed(4)}
)}
{/* SEKCJA CHECKLIST BHP & CYFROWEGO PODPISU & WERYFIKACJI CZĘŚCI & HISTORII & KOSZTORYSU */}
setShowSiteHistory(true)}
>
📜 Historia Obiektu & Poprzednie Wizyty
setShowCostingModal(true)}
>
💰 Kosztorys Zlecenia (Job Costing)
setShowVanModal(true)}
>
🚐 Weryfikacja Części w Aucie (First-Time Fix)
setShowBhpModal(true)}
>
🦺 Karta Kontroli BHP & Pomiary
setShowSignatureModal(true)}
>
✍️ {projectDetails.signature_png || signatureSaved ? '✅ Podpis Klienta Złożony' : '✍️ Złóż Podpis na Ekranie (Sign-on-Glass)'}
{projectDetails.description && (
📝 Opis zlecenia / Wytyczne:
{projectDetails.description}
)}
{/* SEKCJA ZDJĘĆ I PLIKÓW WEJŚCIOWYCH ZLECENIA (WIDOCZNA CAŁY CZAS DLA PRACOWNIKA) */}
📂 Pliki referencyjne / Zdjęcia wstępne do zlecenia:
{photos.length === 0 ? (
Brak załączonych plików wejściowych.
) : (
{photos.map(p => (
openLightbox(`/api/photos/${p.id}/file?token=${token}`, p.original_name)}
/>
📥 Pobierz
))}
)}
)}
{projectDetails && (
<>
🚀 Hurtowe / Szybkie Dodawanie Zdjęć
{/* DUŻA DROPSTREFA HURTOWEGO WGRYWANIA BEZ KATEGORII */}
{/* DUŻA DROPSTREFA HURTOWEGO WGRYWANIA ZDJĘĆ */}
📸 📥
WGRAJ HURTOWO ZDJĘCIA
Zaznacz w galerii telefonu zdjęcia naraz.
handleFileUpload(e, null, 'Ogólna')} />
{/* DROPSTREFA WGRYWANIA INNYCH PLIKÓW / DOKUMENTÓW */}
📑 📂
DOŁĄCZ INNE PLIKI / DOKUMENTY
PDF, DOCX, XLSX, TXT (Trafią do katalogu "pliki")
handleFileUpload(e, null, 'pliki')} />
setShowOcrModal(true)}
>
🔍 Skanuj Tabliczkę Znamionową AI (Odczyt Model / SN)
📷 Szybkie zdjęcie bezpośrednio z określoną kategorią (opcjonalnie)
{categories.map(cat => (
setSelectedCatId(cat.id)}>
{cat.icon || '📷'} {cat.name}
handleFileUpload(e, cat.id, cat.name)} />
))}
{uploadStatus && (
{uploadStatus}
)}
📅 Data Zakończenia Prac w Terenie *
setCompletionDate(e.target.value)}
required
/>
📝 Opis dodatkowych informacji i uwag do zlecenia
{isListening ? '🎙️ Słucham...' : '🎙️ Dyktuj Głosem'}
setProjectRemarks(e.target.value)}
/>
{/* MODUŁ AI DOKUMENTACJI POWYKONAWCZEJ */}
✅ Wyślij i Zatwierdź Dokumentację
{/* MODAL CHECKLISTY BHP (FAZA 2) */}
{showBhpModal && (
🦺 Karta Kontroli Bezpieczeństwa BHP
setShowBhpModal(false)}>✕
setShowBhpModal(false)}>Anuluj
{ setShowBhpModal(false); showToast('✅ Zapisano stan checklisty BHP!', 'success'); }}>Zapisz Parametry BHP
)}
{/* MODAL CYFROWEGO PODPISU ODBIORCY SIGN-ON-GLASS (FAZA 2) */}
{showSignatureModal && (
setShowSignatureModal(false)}
onSave={handleSaveChecklistAndSignature}
/>
)}
🖼️ Galeria Zlecenia ({photos.length} zdjęć)
{/* FILTRY GALERII PO KATEGORII */}
{photos.length > 0 && (() => {
const cats = ['all', ...new Set(photos.map(p => p.category_name || 'Inne'))];
return (
{cats.map(cat => (
setGalleryFilter(cat)}
>
{cat === 'all' ? `🗂 Wszystkie (${photos.length})` : `${cat} (${photos.filter(p => (p.category_name||'Inne')===cat).length})`}
))}
);
})()}
{/* PASEK SZYBKIEJ MASOWEJ KATEGORYZACJI ZDJĘĆ */}
{photos.length > 0 && (
0}
onChange={e => toggleSelectAll(e.target.checked)}
style={{ width: '20px', height: '20px', cursor: 'pointer' }}
/>
Zaznacz wszystkie ({selectedPhotoIds.size}/{photos.length})
{selectedPhotoIds.size > 0 && (
setTargetBatchCatId(e.target.value)}
>
-- Wybierz kategorię docelową --
{categories.map(c => (
{c.icon} {c.name}
))}
🏷️ Przypisz Kategorię ({selectedPhotoIds.size})
🗑️ Usuń Wybrane ({selectedPhotoIds.size})
)}
)}
{photosLoading ? (
) : photos.length === 0 ? (
📭 Brak zdjęć. Użyj przycisku wyżej, aby dodać pierwsze zdjęcia.
) : (
photos.filter(p => galleryFilter === 'all' || (p.category_name||'Inne') === galleryFilter).map(p => (
toggleSelectPhoto(p.id)}
style={{ position: 'absolute', top: '10px', left: '10px', width: '22px', height: '22px', zIndex: 10, cursor: 'pointer' }}
/>
openLightbox(`/api/photos/${p.id}/file?token=${token}`, p.original_name)} />
{p.category_icon || '📷'} {p.category_name || 'Ogólna'}
setGhostPhotoUrl(`/api/photos/${p.id}/file?token=${token}`)}
>
👻 Ghost "Po"
{p.original_name}
{p.notes &&
{p.notes}
}
{
const img = e.currentTarget.closest('.photo-card').querySelector('.photo-thumb');
if (img) img.src = `/api/photos/${p.id}/file?token=${token}&_r=${Date.now()}`;
showToast(`🔄 Odświeżono zdjęcie #${p.id}`, 'info');
}}
>
🔄 Przeładuj
📥 Pobierz
handleDeletePhoto(p.id, p.original_name)}>
🗑️ Usuń
))
)}
{galleryFilter !== 'all' && (
Filtr aktywny: {galleryFilter} — setGalleryFilter('all')}>pokaż wszystkie
)}
{ghostPhotoUrl && (
setGhostPhotoUrl(null)}
onCapture={(file) => {
const fakeEvent = { target: { files: [file] } };
handleFileUpload(fakeEvent, null, 'Stan po...');
}}
/>
)}
{showOcrModal && (
setShowOcrModal(null)}
onAppendText={(text) => setProjectRemarks(prev => (prev ? prev + '\n' + text : text))}
/>
)}
>
)}
{/* PŁYWAJĄCY PRZYCISK MOBILNEGO ASYSTENTA AI GEMINI */}
setShowAiAssistant(true)}
title="Otwórz Asystenta Technicznego AI Gemini"
>
🤖
Asystent AI
{/* MODAL ASYSTENTA AI GEMINI W TERENIE */}
{showAiAssistant && (
setShowAiAssistant(false)}
/>
)}
{/* MODAL WERYFIKACJI CZĘŚCI W AUCIE (FIRST-TIME FIX) */}
{showVanModal && (
setShowVanModal(false)}
/>
)}
{/* MODAL HISTORII OBIEKTU, POPRZEDNICH WIZYT & PASZPORTYZACJI SPRZĘTU */}
{showSiteHistory && (
setShowSiteHistory(false)}
/>
)}
{/* MODAL AUTOMATYCZNEGO KOSZTORYSU ZLECENIA (JOB COSTING & BILLING) */}
{showCostingModal && (
setShowCostingModal(false)}
/>
)}
);
}
// WIDOK KIEROWNIKA / ADMINA
function ManagerView({ token, user, onOpenNewProject, onOpenTempPass, openLightbox }) {
const [activeTab, setActiveTabState] = useState(() => {
return localStorage.getItem('fsm_manager_active_tab') || 'projects';
});
const setActiveTab = (tab) => {
setActiveTabState(tab);
try { localStorage.setItem('fsm_manager_active_tab', tab); } catch(e){}
};
const [projects, setProjects] = useState([]);
const [selectedProjectId, setSelectedProjectId] = useState(null);
const [selectedProjectName, setSelectedProjectName] = useState('');
const [editingProject, setEditingProject] = useState(null);
const [photos, setPhotos] = useState([]);
const [selectedPhotoIds, setSelectedPhotoIds] = useState(new Set());
const [categories, setCategories] = useState([]);
const [users, setUsers] = useState([]);
const [permissions, setPermissions] = useState([]);
const [healthData, setHealthData] = useState(null);
const [emailLogs, setEmailLogs] = useState([]);
const [clients, setClients] = useState([]);
const [editingClient, setEditingClient] = useState(null);
const [clientForm, setClientForm] = useState({ id: null, name: '', code: '', nip: '', contact_person: '', email: '', phone: '', notes: '' });
const [clientPermissions, setClientPermissions] = useState([]);
const [assignUserId, setAssignUserId] = useState('');
const [assignClientName, setAssignClientName] = useState('');
const loadProjects = () => apiFetch('/api/projects', {}, token).then(setProjects).catch(console.error);
const loadClients = () => apiFetch('/api/clients', {}, token).then(setClients).catch(console.error);
const loadCategories = () => apiFetch('/api/categories', {}, token).then(setCategories).catch(console.error);
const [auditLogs, setAuditLogs] = useState([]);
const loadUsersAndPerms = () => {
apiFetch('/api/auth/users', {}, token).then(setUsers).catch(console.error);
apiFetch('/api/permissions', {}, token).then(setPermissions).catch(console.error);
apiFetch('/api/client-permissions', {}, token).then(setClientPermissions).catch(console.error);
apiFetch('/api/clients', {}, token).then(setClients).catch(console.error);
};
const [statsData, setStatsData] = useState(null);
const loadMonitoring = () => {
apiFetch('/api/health', {}, token).then(setHealthData).catch(console.error);
apiFetch('/api/health/email-logs', {}, token).then(setEmailLogs).catch(console.error);
apiFetch('/api/stats', {}, token).then(setStatsData).catch(console.error);
};
const loadAuditLogs = () => {
apiFetch('/api/audit-log', {}, token).then(setAuditLogs).catch(console.error);
};
useEffect(() => {
if (activeTab === 'projects') loadProjects();
if (activeTab === 'clients') loadClients();
if (activeTab === 'categories') loadCategories();
if (activeTab === 'users') loadUsersAndPerms();
if (activeTab === 'monitoring') loadMonitoring();
if (activeTab === 'audit') loadAuditLogs();
}, [activeTab, token]);
const handleSaveClient = async (e) => {
e.preventDefault();
try {
const res = await apiFetch('/api/clients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(clientForm)
}, token);
showToast(res.message, 'success');
setClientForm({ id: null, name: '', code: '', nip: '', contact_person: '', email: '', phone: '', notes: '' });
loadClients();
} catch (err) {
showToast(err.message, 'error');
}
};
const handleAddClientPermission = async (e) => {
e.preventDefault();
if (!assignUserId || !assignClientName) {
showToast('Wybierz użytkownika oraz klienta!', 'warning');
return;
}
try {
const res = await apiFetch('/api/client-permissions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: parseInt(assignUserId), client_name: assignClientName })
}, token);
showToast(res.message, 'success');
loadUsersAndPerms();
} catch (err) {
showToast(err.message, 'error');
}
};
const handleDeleteClientPermission = async (cpid) => {
if (!confirm('Czy na pewno chcesz odebrać temu użytkownikowi dostęp do tego klienta?')) return;
try {
const res = await apiFetch(`/api/client-permissions/${cpid}`, { method: 'DELETE' }, token);
showToast(res.message, 'success');
loadUsersAndPerms();
} catch (err) {
showToast(err.message, 'error');
}
};
const handleDeleteClient = async (cid, cname) => {
if (!confirm(`Czy na pewno chcesz usunąć klienta "${cname}" ze słownika?`)) return;
try {
const res = await apiFetch(`/api/clients/${cid}`, { method: 'DELETE' }, token);
showToast(res.message, 'success');
loadClients();
} catch (err) {
showToast(err.message, 'error');
}
};
const handleViewGallery = async (pid, pname) => {
setSelectedProjectId(pid);
setSelectedProjectName(pname);
setSelectedPhotoIds(new Set());
fetchPhotos(pid);
};
const fetchPhotos = async (pid) => {
if (!pid || typeof pid === 'object' || pid === 'undefined' || pid === 'null') {
setPhotos([]);
return;
}
try {
const phs = await apiFetch(`/api/photos?project_id=${pid}`, {}, token);
setPhotos(Array.isArray(phs) ? phs : []);
} catch (e) {
console.error(e);
}
};
const toggleSelectAll = (checked) => {
if (checked) {
setSelectedPhotoIds(new Set(photos.map(p => p.id)));
} else {
setSelectedPhotoIds(new Set());
}
};
const togglePhotoSelect = (id) => {
const next = new Set(selectedPhotoIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedPhotoIds(next);
};
const downloadSelectedZip = () => {
if (selectedPhotoIds.size === 0) {
showToast('Zaznacz co najmniej jedno zdjęcie kratką wyboru!', 'warning');
return;
}
const ids = Array.from(selectedPhotoIds).join(',');
window.location.href = `/api/photos/download-zip?project_id=${selectedProjectId}&photo_ids=${ids}&token=${token}`;
};
const handleDeletePhoto = async (photoId, name) => {
if (!confirm(`Czy na pewno chcesz usunąć zdjęcie "${name}" z serwera i NAS?`)) return;
try {
await apiFetch(`/api/photos/${photoId}`, { method: 'DELETE' }, token);
fetchPhotos(selectedProjectId);
loadProjects();
} catch (err) {
showToast(err.message, 'error');
}
};
const handleDeleteSelectedBatch = async () => {
if (selectedPhotoIds.size === 0) {
showToast('Zaznacz co najmniej jedno zdjęcie kratką wyboru do usunięcia!', 'warning');
return;
}
if (!confirm(`Czy na pewno chcesz trwale usunąć ${selectedPhotoIds.size} zaznaczonych zdjęć z dysków NAS i VPS?`)) return;
try {
const res = await apiFetch('/api/photos/delete-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ photo_ids: Array.from(selectedPhotoIds) })
}, token);
showToast(res.message, 'success');
setSelectedPhotoIds(new Set());
fetchPhotos(selectedProjectId);
loadProjects();
} catch (err) {
showToast(err.message, 'error');
}
};
const handleDeleteProject = async (pid, pname) => {
if (!confirm(`Czy na pewno chcesz usunąć całe zlecenie "${pname}" wraz ze wszystkimi zdjęciami?`)) return;
try {
await apiFetch(`/api/projects/${pid}`, { method: 'DELETE' }, token);
if (selectedProjectId === pid) setSelectedProjectId(null);
loadProjects();
} catch (err) {
showToast(err.message, 'error');
}
};
return (
setActiveTab('projects')}>📋 Zlecenia & Eksport
setActiveTab('dispatch')}>📅 Harmonogram Dyspozytora (Gantt)
setActiveTab('routes')}>🚗 Planowanie Tras & Dojazdów
setActiveTab('map')}>🗺️ Mapa Obiektów GPS
setActiveTab('clients')}>🏢 Klienci
setActiveTab('categories')}>🏷️ Kategorie Zdjęć
setActiveTab('users')}>🔐 Konta & Uprawnienia
setActiveTab('monitoring')}>🖥️ Monitoring NAS & Mail
setActiveTab('audit')}>📜 Historia Zdarzeń (Audit)
{activeTab === 'clients' && (
🏢 Słownik Klientów ({clients.length})
{/* FORMULARZ EDYCJI/DODAWCZY KLIENTA */}
{clientForm.id ? '✏️ Edycja Klienta: ' + clientForm.name : '➕ Dodaj Nowego Klienta'}
{clientForm.id ? '💾 Zapisz Zmiany' : '➕ Dodaj Klienta'}
{clientForm.id && (
setClientForm({ id: null, name: '', code: '', nip: '', contact_person: '', email: '', phone: '', notes: '' })}>
Anuluj
)}
{/* TABELA KLIENTÓW */}
Nazwa Klienta
Kod
NIP
Kontakt
E-mail
Telefon
Akcje
{clients.length === 0 ? (
Brak zarejestrowanych klientów w słowniku. Użyj formularza powyżej, aby dodać pierwszego klienta.
) : (
clients.map(c => (
{c.name}
{c.code || '-'}
{c.nip || '-'}
{c.contact_person || '-'}
{c.email || '-'}
{c.phone || '-'}
setClientForm(c)}>✏️ Edytuj
handleDeleteClient(c.id, c.name)}>🗑️ Usuń
))
)}
)}
{activeTab === 'projects' && (
<>
Wszystkie Zlecenia i Dokumentacje ({projects.length})
Nazwa Zlecenia
Obiekt
Kod
Klient
Data Zakończenia
Status
Zdjęcia
Akcje
{projects.map(p => (
{p.name}
{p.project_type === 'EMERGENCY' ? '🚨 Awaria' : p.project_type === 'SLA_INSPECTION' ? '⚡ SLA Przegląd' : p.project_type === 'STANDARD_SERVICE' ? '🛠️ Serwis' : '🔧 Montaż'}
{p.priority === 'URGENT' && 🔴 P1 Urg }
{p.object_number || '-'}
{p.code}
{p.client_name}
{p.completion_date || 'Nieokreślono'}
{p.status === 'CONFIRMED' ? 'ZATWIERDZONE' : 'W TRAKCIE'}
{p.photo_count} szt.
handleViewGallery(p.id, p.name)}>👁️ Podgląd
setEditingProject(p)}>✏️ Korekta
📄 Raport PDF
handleDeleteProject(p.id, p.name)}>🗑️
))}
{selectedProjectId && (
Dokumentacja: {selectedProjectName}
{photos.map(photo => (
{photo.category_name || 'Inne'}
{photo.original_name}
{photo.notes &&
{photo.notes}
}
Dodano przez: {photo.uploader_name || 'Worker'}
📥 Pobierz
handleDeletePhoto(photo.id, photo.original_name)}>
🗑️ Usuń
))}
)}
>
)}
{activeTab === 'categories' && (
Zarządzanie Dynamicznymi Kategoriami Zdjęć
Ikona
Nazwa Kategorii
Akcje
{categories.map(c => (
{c.icon || '📷'}
{c.name}
{
if (confirm('Usuń tę kategorię?')) {
await apiFetch(`/api/categories/${c.id}`, { method: 'DELETE' }, token);
loadCategories();
}
}}>Usuń
))}
)}
{activeTab === 'users' && (
👥 Użytkownicy i Uprawnienia Klientów
ID
Nazwa Użytkownika
E-mail
Rola
Weryfikacja E-mail
Akcje Hasła
{users.map(u => (
{u.id}
{u.username}
{u.email}
{u.role}
{(u.is_email_verified || ['admin', 'pracownik', 'klient'].includes(u.username)) ? '✅ Zweryfikowano' : '⏳ Oczekuje'}
onOpenTempPass(u.id, u.username)}>🔑 Tymczasowe hasło
{!['admin', 'pracownik', 'klient'].includes(u.username) && u.id !== user?.id && (
{
if (!confirm(`Czy na pewno chcesz usunąć konto "${u.username}"? Tej operacji nie można cofnąć.`)) return;
try {
const res = await apiFetch(`/api/users/${u.id}`, { method: 'DELETE' }, token);
showToast(res.message, 'success');
loadUsersAndPerms();
} catch(e) {
showToast(e.message, 'error');
}
}}
>🗑️ Usuń konto
)}
))}
🏢 Przypisani Klienci do Użytkowników (Dostęp do Zleceń Danych Klientów)
Wybierz Użytkownika
setAssignUserId(e.target.value)} required>
-- Wybierz użytkownika --
{users.map(u => (
{u.username} ({u.role})
))}
Wybierz Klienta ze Słownika
setAssignClientName(e.target.value)} required>
-- Wybierz klienta --
{clients.map(c => (
{c.name} {c.code ? `(${c.code})` : ''}
))}
➕ Przypisz Klienta
Użytkownik
E-mail
Przypisany Klient
Data Przypisania
Akcje
{clientPermissions.length === 0 ? (
Brak dedykowanych przypisań klientów (Użytkownicy bez przypisania nie widzą cudzych zleceń).
) : (
clientPermissions.map(cp => (
{cp.username}
{cp.email}
{cp.client_name}
{new Date(cp.created_at).toLocaleDateString('pl-PL')}
handleDeleteClientPermission(cp.id)}>
🗑️ Odebranie dostępu
))
)}
)}
{activeTab === 'monitoring' && (
🖥️ Panel Statystyk i Monitoring Systemu
{statsData && (
WSZYSTKIE ZLECENIA
{statsData.total_projects}
ŁĄCZNIE ZDJĘĆ
{statsData.total_photos}
ZSYNCHRONIZOWANE NAS
{statsData.synced_photos}
OCZEKUJĄCE / STAGING
0 ? 'var(--warning)' : 'var(--text-muted)', marginTop: '4px' }}>{statsData.pending_photos} ({statsData.staging_size_mb} MB)
)}
{healthData && (
Status Aplikacji: {healthData.status.toUpperCase()}
Dysk UNAS Pro NAS: {healthData.nas_mounted ? '✅ ZAMONTOWANY (' + healthData.nas_mount_path + ')' : '⚠️ NIEZAMONTOWANY'}
Oczekujące zdjęcia w kolejce: {healthData.staging_queue_count} szt.
Poczta SMTP: {healthData.smtp_configured ? '✅ Skonfigurowana' : 'ℹ️ Domyślne / Sendmail fallback'}
)}
Logi Wysyłki Maili (szymon@multi-tech.pro)
{
try {
const res = await apiFetch('/api/health/test-email', { method: 'POST' }, token);
showToast(res.message, res.sent_ok ? 'success' : 'warning');
} catch(e) {
showToast(e.message, 'error');
}
}}
>
✉️ Wyślij Testowy E-mail
Data
Odbiorca
Temat
Status
{emailLogs.map(l => (
{new Date(l.sent_at).toLocaleString('pl-PL')}
{l.recipient_email}
{l.subject}
{l.status}
))}
)}
{activeTab === 'dispatch' && (
)}
{activeTab === 'routes' && (
)}
{activeTab === 'map' && (
{ setActiveTab('projects'); handleViewGallery(pid, pname); }} />
)}
{activeTab === 'audit' && (
📜 Audit Log — Historia Działań i Aktywności
🔄 Odśwież
Data
Użytkownik
Akcja
Zasób
Szczegóły
{auditLogs.length === 0 ? (
Brak zarejestrowanych logów audytowych.
) : (
auditLogs.map(a => (
{new Date(a.created_at).toLocaleString('pl-PL')}
{a.username || 'System'}
{a.action}
{a.resource_type ? `${a.resource_type} #${a.resource_id || ''}` : '-'}
{a.detail || '-'}
))
)}
)}
{editingProject && (
setEditingProject(null)}
onUpdated={() => { loadProjects(); setEditingProject(null); }}
/>
)}
);
}
// WIDOK KLIENTA
function ClientView({ token, user, onOpenNewProject, openLightbox }) {
const [projects, setProjects] = useState([]);
const [selectedProjectId, setSelectedProjectId] = useState('');
const [selectedProject, setSelectedProject] = useState(null);
const [photos, setPhotos] = useState([]);
const [selectedPhotoIds, setSelectedPhotoIds] = useState(new Set());
const [showProjectsModal, setShowProjectsModal] = useState(false);
const [statusFilter, setStatusFilter] = useState('ALL');
useEffect(() => {
apiFetch('/api/projects', {}, token).then(setProjects).catch(console.error);
}, [token]);
const loadPhotos = async (pid) => {
if (!pid || typeof pid === 'object' || pid === 'undefined' || pid === 'null') {
setSelectedProjectId('');
setSelectedProject(null);
setPhotos([]);
return;
}
setSelectedProjectId(pid);
const p = projects.find(item => String(item.id) === String(pid));
setSelectedProject(p || null);
try {
const phs = await apiFetch(`/api/photos?project_id=${pid}`, {}, token);
setPhotos(Array.isArray(phs) ? phs : []);
setSelectedPhotoIds(new Set());
} catch (e) {
console.error(e);
}
};
const toggleSelectAll = (checked) => {
if (checked) setSelectedPhotoIds(new Set(photos.map(p => p.id)));
else setSelectedPhotoIds(new Set());
};
const togglePhotoSelect = (id) => {
const next = new Set(selectedPhotoIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedPhotoIds(next);
};
const downloadSelectedZip = () => {
if (selectedPhotoIds.size === 0) {
showToast('Zaznacz co najmniej jedno zdjęcie kratką wyboru!', 'warning');
return;
}
const ids = Array.from(selectedPhotoIds).join(',');
window.location.href = `/api/photos/download-zip?project_id=${selectedProjectId}&photo_ids=${ids}&token=${token}`;
};
return (
Dostępne Przydzielone Dokumentacje
➕ Zgłoś Nowe Zlecenie (Samoobsługa)
{/* WYBÓR ZLECENIA KLIENTA (INLINE EXPANDABLE) */}
setShowProjectsModal(!showProjectsModal)}
style={{
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.9), rgba(15, 23, 42, 0.95))',
padding: '16px',
borderRadius: '12px',
border: '2px dashed var(--primary)',
cursor: 'pointer',
marginBottom: '16px',
display: 'flex',
justify: 'space-between',
alignItems: 'center',
transition: 'all 0.2s ease'
}}
>
📂
Kliknij, aby wybrać zlecenie:
{selectedProject ? `${selectedProject.name} ${selectedProject.object_number ? `(${selectedProject.object_number})` : ''}` : 'WYBIERZ ZLECENIE Z LISTY...'}
{selectedProject && (
{getProjectStatusBadge(selectedProject)}
Kod: {selectedProject.code}
)}
{showProjectsModal ? '▲ Zamknij Listę' : `▼ Rozwiń Listę (${projects.length})`}
{/* ROZWIJANA WIDOCZNA SEKCJA LISTY ZLECEŃ KLIENTA */}
{showProjectsModal && (
📋 Twoje Zlecenia Serwisowe
setShowProjectsModal(false)}>Zamknij ✖
{/* FILTRY STATUSÓW */}
setStatusFilter('ALL')}>Wszystkie ({projects.length})
setStatusFilter('NOWE')}>🟩 Nowe
setStatusFilter('CZEKA')}>🟨 Czeka
setStatusFilter('IN_PROGRESS')}>🟦 W trakcie
setStatusFilter('COMPLETED')}>⬛ Zakończone
{projects.filter(p => {
if (statusFilter === 'ALL') return true;
if (statusFilter === 'COMPLETED') return p.status === 'COMPLETED' || p.status === 'CONFIRMED' || p.work_ended_at;
if (statusFilter === 'IN_PROGRESS') return p.work_started_at || p.status === 'IN_PROGRESS' || (p.assigned_workers_json && JSON.parse(p.assigned_workers_json || '[]').length > 0);
const hrs = (Date.now() - new Date(p.created_at || Date.now()).getTime()) / (1000 * 3600);
if (statusFilter === 'NOWE') return hrs <= 48 && p.status !== 'COMPLETED' && p.status !== 'CONFIRMED';
if (statusFilter === 'CZEKA') return hrs > 48 && !p.work_started_at && p.status !== 'COMPLETED';
return true;
}).map(p => (
{ loadPhotos(p.id); setShowProjectsModal(false); }}
style={{
background: p.id === parseInt(selectedProjectId) ? 'rgba(37, 99, 235, 0.3)' : 'rgba(30, 41, 59, 0.8)',
padding: '12px 14px',
borderRadius: '8px',
border: p.id === parseInt(selectedProjectId) ? '1.5px solid var(--primary)' : '1px solid var(--card-border)',
cursor: 'pointer',
display: 'flex',
justify: 'space-between',
alignItems: 'center',
transition: 'all 0.15s ease'
}}
>
{p.name} {p.object_number ? (Obiekt: {p.object_number}) : ''}
Kod zlecenia: {p.code} | Wgrywano zdjęć: {p.photo_count || 0} szt.
{getProjectStatusBadge(p)}
))}
)}
{selectedProjectId && (
0 && selectedPhotoIds.size === photos.length} onChange={e => toggleSelectAll(e.target.checked)} />
Zaznacz wszystkie ({photos.length})
📦 Pobierz Wybrane Zdjęcia ZIP ({selectedPhotoIds.size})
{photos.map(photo => (
{photo.category_name || 'Dokumentacja'}
{photo.original_name}
{photo.notes &&
{photo.notes}
}
{
const img = e.currentTarget.closest('.photo-card').querySelector('.photo-thumb');
if (img) img.src = `/api/photos/${photo.id}/file?token=${token}&_r=${Date.now()}`;
showToast(`🔄 Odświeżono zdjęcie #${photo.id}`, 'info');
}}
>
🔄 Przeładuj
📥 Pobierz
))}
)}
);
}
// MODALE
function NewProjectModal({ token, userRole, onClose, onProjectCreated }) {
const [name, setName] = useState('');
const [objectNumber, setObjectNumber] = useState('');
const [description, setDescription] = useState('');
const [clients, setClients] = useState([]);
const [workers, setWorkers] = useState([]);
const [selectedClient, setSelectedClient] = useState('');
const [customClientName, setCustomClientName] = useState('');
const [location, setLocation] = useState('');
const [coords, setCoords] = useState(null);
const [gpsLoading, setGpsLoading] = useState(false);
const [projectType, setProjectType] = useState('INSTALLATION');
const [priority, setPriority] = useState('NORMAL');
const [targetClient, setTargetClient] = useState('Towerlink');
const [customTargetClient, setCustomTargetClient] = useState('');
const [plannedCompletionDate, setPlannedCompletionDate] = useState('');
const [selectedWorkers, setSelectedWorkers] = useState([]);
const [selectedFiles, setSelectedFiles] = useState([]);
const fetchGpsLocation = () => {
if (!navigator.geolocation) {
showToast('Twoja przeglądarka nie wspiera geolokalizacji GPS.', 'warning');
return;
}
setGpsLoading(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setCoords({ lat: pos.coords.latitude, lng: pos.coords.longitude });
showToast(`📍 Pobrano pozycję GPS: ${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}`, 'success');
setGpsLoading(false);
},
(err) => {
showToast('Nie udało się pobrać pozycji GPS (brak zgody lub sygnału).', 'warning');
setGpsLoading(false);
},
{ enableHighAccuracy: true }
);
};
useEffect(() => {
apiFetch('/api/clients', {}, token).then(cls => {
setClients(cls);
if (cls.length > 0) setSelectedClient(cls[0].name);
else setSelectedClient('OTHER');
}).catch(() => setSelectedClient('OTHER'));
apiFetch('/api/auth/users', {}, token).then(usrs => {
setWorkers(usrs.filter(u => u.role === 'WORKER'));
}).catch(console.error);
}, [token]);
const handleSubmit = async (e) => {
e.preventDefault();
const finalClientName = selectedClient === 'OTHER' ? customClientName.strip ? customClientName.strip() : customClientName.trim() : selectedClient;
if (!finalClientName) {
showToast('Podaj nazwę klienta!', 'warning');
return;
}
try {
const res = await apiFetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
object_number: objectNumber,
client_name: finalClientName,
location,
latitude: coords ? coords.lat : null,
longitude: coords ? coords.lng : null,
description,
project_type: projectType,
priority,
target_client: targetClient === 'Inny' ? customTargetClient : targetClient,
assigned_workers: selectedWorkers,
planned_completion_date: plannedCompletionDate
})
}, token);
showToast(`Zlecenie "${name}" utworzone dla klienta: ${finalClientName}!`, 'success');
// Jeśli wybrano pliki wstępne / zdjęcia, wgrywamy je automatycznie do nowego zlecenia
if (selectedFiles.length > 0) {
showToast(`📤 Wgrywanie ${selectedFiles.length} plików wstępnych do zlecenia...`, 'info');
const formData = new FormData();
formData.append('project_id', res.projectId);
formData.append('notes', 'Plik wstępny / Wytyczne zlecenia');
selectedFiles.forEach(f => formData.append('photos', f));
try {
await fetch('/api/photos/upload', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
showToast('✅ Załączono pliki wstępne do zlecenia!', 'success');
} catch (e) {
console.error('Błąd wgrywania plików wstępnych:', e);
}
}
onClose();
onProjectCreated();
} catch (err) {
showToast(err.message, 'error');
}
};
return (
×
Utwórz Nowe Zlecenie / Pracę
{/* SZABLON TYPU ZLECENIA I PRIORYTET */}
Typ / Szablon Pracy *
setProjectType(e.target.value)}>
🔧 Montaż / Przebudowa
🛠️ Serwis Zwykły
⚡ Przegląd Okresowy SLA
🚨 Awaria / Serwis Pilny (P1)
Priorytet Zlecenia *
setPriority(e.target.value)}>
🟢 Normalny
🟡 Wysoki
🔴 Krytyczny P1 (SLA < 2h)
Klient Docelowy *
setTargetClient(e.target.value)}>
Towerlink
OPL
TMPL
Orange fix
!Networks
Inny... (Wpisz własnego)
{targetClient === 'Inny' && (
Nazwa Innego Klienta Docelowego *
setCustomTargetClient(e.target.value)} required />
)}
Szczegółowy Opis Zlecenia / Zakres Prac
setDescription(e.target.value)} />
Numer Obiektu *
setObjectNumber(e.target.value)} required />
{/* LISTA WYBORU KLIENTA (TYLKO DLA ADMINA / MANAGERÓW) */}
{userRole !== 'CLIENT' && (
<>
Wybierz Klienta z Listy *
setSelectedClient(e.target.value)}>
{clients.map(c => (
{c.name} {c.code ? `(${c.code})` : ''}
))}
➕ Inny... (Wpisz własnego klienta)
{selectedClient === 'OTHER' && (
Nazwa Nowego / Innego Klienta *
setCustomClientName(e.target.value)} required />
)}
>
)}
{/* PRZYPISYWANIE ZESPOŁU TECHNIKÓW (TYLKO DLA ADMINA / MANAGERÓW) */}
{userRole !== 'CLIENT' && workers.length > 0 && (
Wyznacz Zespół / Serwisantów (Wielokrotny wybór)
{workers.map(w => {
const isSel = selectedWorkers.includes(w.id);
return (
{
if (isSel) setSelectedWorkers(selectedWorkers.filter(id => id !== w.id));
else setSelectedWorkers([...selectedWorkers, w.id]);
}}>
👷 {w.username}
);
})}
)}
Lokalizacja / Adres
setLocation(e.target.value)} />
{gpsLoading ? '⏳ GPS...' : '📍 GPS'}
{coords && (
✅ Pozycja GPS zapisana: {coords.lat.toFixed(5)}, {coords.lng.toFixed(5)}
)}
{/* DOŁĄCZANIE PLIKÓW I ZDJĘĆ DOKUMENTACJI WSTĘPNEJ */}
📁 Załącz Pliki Wstępne / Zdjęcia Referencyjne (opcjonalnie)
setSelectedFiles(Array.from(e.target.files))}
/>
{selectedFiles.length > 0 && (
📎 Wybrano plików: {selectedFiles.length} szt. ({selectedFiles.map(f => f.name).join(', ')})
)}
Utwórz i Dodaj Zlecenie
);
}
function ChangePasswordModal({ token, onClose }) {
const [curr, setCurr] = useState('');
const [newP, setNewP] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
try {
const res = await apiFetch('/api/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: curr, new_password: newP })
}, token);
showToast(res.message, 'success');
onClose();
} catch (err) {
showToast(err.message, 'error');
}
};
return (
);
}
// ─── MODUŁ DYSPOZYTORNI & HARMONOGRAMU GANTTA (PODZIAŁ 50/50: ZADANIA / UŻYTKOWNICY) ────────
function DispatchTimelineBoard({ projects, token, onRefresh }) {
const [workers, setWorkers] = useState([]);
const [selectedDate, setSelectedDate] = useState(() => new Date().toISOString().split('T')[0]);
const [draggedProjectId, setDraggedProjectId] = useState(null);
const [taskFilter, setTaskFilter] = useState('ALL'); // ALL, UNASSIGNED, URGENT
const [taskSearch, setTaskSearch] = useState('');
useEffect(() => {
apiFetch('/api/auth/users', {}, token)
.then(users => setWorkers(users.filter(u => u.role === 'WORKER' || u.role === 'ADMIN')))
.catch(console.error);
}, [token]);
const handleAssignWorker = async (projectId, workerUsername) => {
try {
const p = projects.find(item => item.id === projectId);
if (!p) return;
let updatedWorkers = [workerUsername];
if (workerUsername === 'UNASSIGNED') {
updatedWorkers = [];
}
await apiFetch(`/api/projects/${projectId}/assign-workers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assigned_workers: updatedWorkers })
}, token);
showToast(workerUsername === 'UNASSIGNED' ? `Przeniesiono zlecenie #${projectId} do bufora nieprzypisanych` : `Przypisano zlecenie #${projectId} do: ${workerUsername}`, 'success');
if (onRefresh) onRefresh();
} catch (e) {
showToast(e.message, 'error');
}
};
const filteredTasks = useMemo(() => {
return projects.filter(p => {
if (p.status === 'COMPLETED' || p.status === 'CONFIRMED') return false;
let w = [];
try { w = JSON.parse(p.assigned_workers_json || '[]'); } catch(e){}
if (taskFilter === 'UNASSIGNED' && w.length > 0) return false;
if (taskFilter === 'URGENT' && p.priority !== 'URGENT') return false;
if (taskSearch.trim()) {
const q = taskSearch.toLowerCase();
const matchesName = (p.name || '').toLowerCase().includes(q);
const matchesObj = (p.object_number || '').toLowerCase().includes(q);
const matchesClient = (p.client_name || '').toLowerCase().includes(q);
if (!matchesName && !matchesObj && !matchesClient) return false;
}
return true;
});
}, [projects, taskFilter, taskSearch]);
const getWorkerProjects = (worker) => {
const workerUsername = typeof worker === 'object' ? worker.username : worker;
const workerId = typeof worker === 'object' ? worker.id : null;
return projects.filter(p => {
let w = [];
try { w = JSON.parse(p.assigned_workers_json || '[]'); } catch(e){}
return w.includes(workerUsername) ||
(workerId && (w.includes(workerId) || w.includes(String(workerId))));
});
};
return (
{/* LEWA STRONA (50%): ZADANIA I ZLECENIA */}
e.preventDefault()}
onDrop={e => {
e.preventDefault();
if (draggedProjectId) {
handleAssignWorker(draggedProjectId, 'UNASSIGNED');
setDraggedProjectId(null);
}
}}
>
📋 Zadania i Zlecenia ({filteredTasks.length})
Przeciągnij tutaj, aby cofnąć przypisanie
setTaskFilter('ALL')}
>
Wszystkie
setTaskFilter('UNASSIGNED')}
>
Nieprzypisane
setTaskFilter('URGENT')}
>
🔥 Pilne
setTaskSearch(e.target.value)}
/>
{filteredTasks.length === 0 ? (
Brak zadań spełniających kryteria
) : (
{filteredTasks.map(p => {
let assigned = [];
try { assigned = JSON.parse(p.assigned_workers_json || '[]'); } catch(e){}
return (
setDraggedProjectId(p.id)}
onDragEnd={() => setDraggedProjectId(null)}
style={{
borderLeft: p.priority === 'URGENT' ? '4px solid #dc2626' : (assigned.length > 0 ? '4px solid #0284c7' : '4px solid #94a3b8')
}}
>
{p.name}
{p.priority === 'URGENT' ? 'PILNE' : (p.priority || 'NORMAL')}
🏢 Obiekt: {p.object_number || 'Brak'}
{p.client_name || 'MT-PRO'}
Status: {getProjectStatusBadge(p)}
{assigned.length > 0 ? (
👤 {assigned.join(', ')}
) : (
⚠️ Nieprzypisane
)}
);
})}
)}
{/* PRAWA STRONA (50%): UŻYTKOWNICY / TECHNICY */}
👷 Użytkownicy / Technicy ({workers.length})
Upuść zadanie na montera, aby przypisać zlecenie
Dostępni w terenie
{workers.length === 0 ? (
Ładowanie techników...
) : (
workers.map(worker => {
const workerTasks = getWorkerProjects(worker);
return (
{worker.username.charAt(0).toUpperCase()}
{worker.username}
Rola: {worker.role}
0 ? '#e0f2fe' : '#f1f5f9', color: workerTasks.length > 0 ? '#0284c7' : '#64748b', padding: '3px 8px', borderRadius: '6px', fontWeight: 700 }}>
{workerTasks.length} {workerTasks.length === 1 ? 'zlecenie' : 'zleceń'}
{/* Drop zone dla technika */}
e.preventDefault()}
onDrop={e => {
e.preventDefault();
if (draggedProjectId) {
handleAssignWorker(draggedProjectId, worker.username);
setDraggedProjectId(null);
}
}}
>
{workerTasks.length === 0 ? (
📥 Przeciągnij i upuść zlecenie tutaj, aby przypisać do {worker.username}
) : (
workerTasks.map(t => (
setDraggedProjectId(t.id)}
onDragEnd={() => setDraggedProjectId(null)}
style={{
borderLeft: '4px solid #16a34a',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px'
}}
>
{t.name}
🏢 {t.object_number || '-'} | {t.client_name || 'MT-PRO'}
{getProjectStatusBadge(t)}
handleAssignWorker(t.id, 'UNASSIGNED')}
>
✕
))
)}
);
})
)}
);
}
function MapView({ projects, token, onSelectProject }) {
const mapRef = React.useRef(null);
const leafletMap = React.useRef(null);
React.useEffect(() => {
if (!mapRef.current) return;
if (typeof L === 'undefined') return;
if (!leafletMap.current) {
leafletMap.current = L.map(mapRef.current).setView([52.2297, 21.0122], 6); // Polska domyślnie
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(leafletMap.current);
}
const map = leafletMap.current;
const markers = [];
projects.forEach(p => {
if (p.latitude && p.longitude) {
const marker = L.marker([p.latitude, p.longitude]).addTo(map);
marker.bindPopup(`
${p.name}
Obiekt: ${p.object_number || '-'} | Klient: ${p.client_name}
Zdjecia: ${p.photo_count} szt.
`);
markers.push(marker);
}
});
if (markers.length > 0) {
const group = new L.featureGroup(markers);
map.fitBounds(group.getBounds().pad(0.2));
}
}, [projects]);
return (
🗺️ Mapa Obiektów i Zleceń (OpenStreetMap GPS)
);
}
const DEPOT_PRESETS = [
{ label: 'Warszawa', lat: 52.2297, lng: 21.0122, addr: 'Warszawa (Centrala / Magazyn Główny)' },
{ label: 'Katowice', lat: 50.2649, lng: 19.0238, addr: 'Katowice (Hub Śląsk)' },
{ label: 'Kraków', lat: 50.0647, lng: 19.9450, addr: 'Kraków (Oddział Małopolska)' },
{ label: 'Poznań', lat: 52.4064, lng: 16.9252, addr: 'Poznań (Hub Wielkopolska)' },
{ label: 'Wrocław', lat: 51.1079, lng: 17.0385, addr: 'Wrocław (Hub Dolny Śląsk)' },
{ label: 'Gdańsk', lat: 54.3520, lng: 18.6466, addr: 'Gdańsk (Hub Północ)' },
{ label: 'Łódź', lat: 51.7592, lng: 19.4560, addr: 'Łódź (Centralny Hub)' },
];
// ─── MODUŁ PLANOWANIA TRAS & OPTYMALIZACJI DOJAZDÓW (AI ROUTE PLANNER) ──────
function RoutePlannerView({ projects, token, onRefreshProjects }) {
const [workers, setWorkers] = useState([]);
const [selectedWorker, setSelectedWorker] = useState('ALL');
const [selectedDate, setSelectedDate] = useState(() => new Date().toISOString().split('T')[0]);
const [depotAddress, setDepotAddress] = useState(() => {
try {
const saved = JSON.parse(localStorage.getItem('fsm_route_depot') || '{}');
return saved.addr || 'Warszawa (Centrala / Magazyn Główny)';
} catch(e) { return 'Warszawa (Centrala / Magazyn Główny)'; }
});
const [depotLat, setDepotLat] = useState(() => {
try {
const saved = JSON.parse(localStorage.getItem('fsm_route_depot') || '{}');
return typeof saved.lat === 'number' ? saved.lat : 52.2297;
} catch(e) { return 52.2297; }
});
const [depotLng, setDepotLng] = useState(() => {
try {
const saved = JSON.parse(localStorage.getItem('fsm_route_depot') || '{}');
return typeof saved.lng === 'number' ? saved.lng : 21.0122;
} catch(e) { return 21.0122; }
});
const [editingDepot, setEditingDepot] = useState(false);
const [loadingBtSearchId, setLoadingBtSearchId] = useState(null);
const [stops, setStops] = useState([]);
const [optimized, setOptimized] = useState(false);
const saveDepot = (addr, lat, lng) => {
setDepotAddress(addr);
setDepotLat(lat);
setDepotLng(lng);
try {
localStorage.setItem('fsm_route_depot', JSON.stringify({ addr, lat, lng }));
} catch(e) {}
};
const handleGetMyGps = () => {
if (!navigator.geolocation) {
showToast('Geolokalizacja nie jest obsługiwana przez Twoją przeglądarkę.', 'error');
return;
}
showToast('Pobieranie bieżącej lokalizacji GPS...', 'info');
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = parseFloat(pos.coords.latitude.toFixed(5));
const lng = parseFloat(pos.coords.longitude.toFixed(5));
const addr = `Moja bieżąca lokalizacja GPS (${lat}, ${lng})`;
saveDepot(addr, lat, lng);
showToast('📍 Ustawiono punkt startowy wg Twojej lokalizacji GPS!', 'success');
},
(err) => {
showToast(`Nie udało się pobrać lokalizacji GPS: ${err.message}`, 'error');
},
{ enableHighAccuracy: true, timeout: 8000 }
);
};
const routeMapRef = React.useRef(null);
const routeLeaflet = React.useRef(null);
const layerGroupRef = React.useRef(null);
useEffect(() => {
apiFetch('/api/auth/users', {}, token)
.then(usrs => setWorkers(usrs.filter(u => u.role === 'WORKER' || u.role === 'ADMIN')))
.catch(console.error);
}, [token]);
// Filtrowanie zleceń wg pracownika i daty
useEffect(() => {
const list = projects.filter(p => {
if (p.status === 'COMPLETED' || p.status === 'CONFIRMED') return false;
if (!p.latitude || !p.longitude) return false;
if (selectedWorker !== 'ALL') {
let assigned = [];
try { assigned = JSON.parse(p.assigned_workers_json || '[]'); } catch(e){}
const isMatched = assigned.includes(selectedWorker) || assigned.some(id => {
const wObj = workers.find(w => String(w.id) === String(id));
return wObj && (wObj.username === selectedWorker || String(wObj.id) === String(selectedWorker));
});
if (!isMatched) return false;
}
return true;
});
setStops(list);
setOptimized(false);
}, [projects, selectedWorker, selectedDate, workers]);
// Zlecenia oczekujące na koordynaty GPS dla wybranego pracownika
const pendingGpsProjects = useMemo(() => {
return projects.filter(p => {
if (p.status === 'COMPLETED' || p.status === 'CONFIRMED') return false;
if (p.latitude && p.longitude) return false;
if (selectedWorker !== 'ALL') {
let assigned = [];
try { assigned = JSON.parse(p.assigned_workers_json || '[]'); } catch(e){}
const isMatched = assigned.includes(selectedWorker) || assigned.some(id => {
const wObj = workers.find(w => String(w.id) === String(id));
return wObj && (wObj.username === selectedWorker || String(wObj.id) === String(selectedWorker));
});
if (!isMatched) return false;
}
return true;
});
}, [projects, selectedWorker, workers]);
// 1-klikowe pobranie współrzędnych z BTSearch i natychmiastowe umieszczenie na trasie
const handleFetchBtSearchForProject = async (prj) => {
const code = (prj.object_number || prj.name || '').trim();
if (!code) {
showToast('Zlecenie nie posiada numeru obiektu ani kodu stacji.', 'warning');
return;
}
setLoadingBtSearchId(prj.id);
try {
const data = await apiFetch(`/api/btsearch/lookup?code=${encodeURIComponent(code)}`, {}, token);
let lat = data.latitude;
let lng = data.longitude;
if ((!lat || !lng) && data.gps_coordinates) {
const parts = String(data.gps_coordinates).split(',').map(s => s.trim());
if (parts.length === 2 && !isNaN(parseFloat(parts[0])) && !isNaN(parseFloat(parts[1]))) {
lat = parseFloat(parts[0]);
lng = parseFloat(parts[1]);
}
}
if (!lat || !lng) {
throw new Error('BTSearch nie zwrócił współrzędnych GPS dla tego obiektu.');
}
const loc = data.address || prj.location || 'Pobrano z BTSearch';
await apiFetch(`/api/projects/${prj.id}/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
latitude: lat,
longitude: lng,
location: loc
})
}, token);
showToast(`✅ Zaktualizowano GPS (${lat.toFixed(4)}, ${lng.toFixed(4)}) dla stacji ${code}!`, 'success');
if (onRefreshProjects) {
onRefreshProjects();
}
} catch (err) {
showToast(err.message || 'Błąd pobierania danych z BTSearch', 'error');
} finally {
setLoadingBtSearchId(null);
}
};
// Obliczenie odległości Haversine w km
const getDistanceKm = (lat1, lon1, lat2, lon2) => {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a =
Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
};
// Statystyki trasy
const routeStats = useMemo(() => {
if (stops.length === 0) return { totalKm: 0, totalTimeMin: 0, formattedTime: '0 min' };
let totalKm = 0;
let prevLat = depotLat;
let prevLng = depotLng;
stops.forEach(s => {
totalKm += getDistanceKm(prevLat, prevLng, s.latitude, s.longitude);
prevLat = s.latitude;
prevLng = s.longitude;
});
// Średnia prędkość serwisowa w trasie (55 km/h) + 45 min na realizację każdego zlecenia na obiekcie
const drivingTimeMinutes = Math.round((totalKm / 55) * 60);
const onSiteTimeMinutes = stops.length * 45;
const totalTimeMin = drivingTimeMinutes + onSiteTimeMinutes;
const hours = Math.floor(totalTimeMin / 60);
const mins = totalTimeMin % 60;
return {
totalKm: totalKm.toFixed(1),
drivingTimeMinutes,
onSiteTimeMinutes,
totalTimeMin,
formattedTime: hours > 0 ? `${hours} godz. ${mins} min.` : `${mins} min.`
};
}, [stops, depotLat, depotLng]);
// Optymalizacja trasy metodą Najbliższego Sąsiada (Nearest Neighbor TSP)
const handleOptimizeRoute = () => {
if (stops.length <= 1) {
showToast('Dodaj co najmniej 2 obiekty z GPS, aby zoptymalizować trasę!', 'info');
return;
}
const unvisited = [...stops];
const newRoute = [];
let currentLat = depotLat;
let currentLng = depotLng;
while (unvisited.length > 0) {
let nearestIdx = 0;
let minDistance = Infinity;
for (let i = 0; i < unvisited.length; i++) {
const d = getDistanceKm(currentLat, currentLng, unvisited[i].latitude, unvisited[i].longitude);
if (d < minDistance) {
minDistance = d;
nearestIdx = i;
}
}
const nextStop = unvisited.splice(nearestIdx, 1)[0];
newRoute.push(nextStop);
currentLat = nextStop.latitude;
currentLng = nextStop.longitude;
}
setStops(newRoute);
setOptimized(true);
showToast(`⚡ Trasa zoptymalizowana! Zaoszczędzono czas dojazdu.`, 'success');
};
const moveStop = (index, direction) => {
const newStops = [...stops];
const targetIdx = index + direction;
if (targetIdx < 0 || targetIdx >= newStops.length) return;
const temp = newStops[index];
newStops[index] = newStops[targetIdx];
newStops[targetIdx] = temp;
setStops(newStops);
};
// Generowanie linku Google Maps z całą wielopunktową trasą
const getGoogleMapsMultiRouteUrl = () => {
if (stops.length === 0) return '#';
const origin = `${depotLat},${depotLng}`;
const destination = `${stops[stops.length - 1].latitude},${stops[stops.length - 1].longitude}`;
const waypoints = stops.slice(0, -1).map(s => `${s.latitude},${s.longitude}`).join('|');
return `https://www.google.com/maps/dir/?api=1&origin=${origin}&destination=${destination}${waypoints ? `&waypoints=${waypoints}` : ''}&travelmode=driving`;
};
// Rysowanie mapy i linii trasy Leaflet
useEffect(() => {
if (!routeMapRef.current || typeof L === 'undefined') return;
if (!routeLeaflet.current) {
routeLeaflet.current = L.map(routeMapRef.current).setView([depotLat, depotLng], 8);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(routeLeaflet.current);
layerGroupRef.current = L.layerGroup().addTo(routeLeaflet.current);
}
const map = routeLeaflet.current;
const layerGroup = layerGroupRef.current;
layerGroup.clearLayers();
const latLngs = [[depotLat, depotLng]];
const markers = [];
// Baza / Punkt startowy
const depotIcon = L.divIcon({
className: 'custom-div-icon',
html: `🏁
`,
iconSize: [28, 28],
iconAnchor: [14, 14]
});
const depotMarker = L.marker([depotLat, depotLng], { icon: depotIcon }).addTo(layerGroup);
depotMarker.bindPopup(`🏁 Baza Startowa / Magazyn ${depotAddress}`);
markers.push(depotMarker);
// Kolejne punkty trasy
stops.forEach((s, idx) => {
latLngs.push([s.latitude, s.longitude]);
const stopIcon = L.divIcon({
className: 'custom-div-icon',
html: `${idx + 1}
`,
iconSize: [28, 28],
iconAnchor: [14, 14]
});
const marker = L.marker([s.latitude, s.longitude], { icon: stopIcon }).addTo(layerGroup);
marker.bindPopup(`
PRZYSTANEK #${idx + 1}
${s.name}
Obiekt: ${s.object_number || '-'} | ${s.location || 'GPS'}
Status: ${s.status}
`);
markers.push(marker);
});
// Rysowanie linii trasy
if (latLngs.length > 1) {
L.polyline(latLngs, {
color: '#0284c7',
weight: 4,
opacity: 0.8,
dashArray: '8, 8'
}).addTo(layerGroup);
}
if (markers.length > 0) {
const group = new L.featureGroup(markers);
map.fitBounds(group.getBounds().pad(0.25));
}
}, [stops, depotLat, depotLng]);
return (
🚗 Moduł Planowania Tras & Optymalizacji Dojazdów
Wyliczaj optymalne trasy dla techników w terenie, minimalizuj czas przejazdów i eksportuj do nawigacji GPS.
setSelectedWorker(e.target.value)}
>
👷 Wszyscy Technicy
{workers.map(w => (
👷 {w.username} ({w.role})
))}
setSelectedDate(e.target.value)}
/>
⚡ Optymalizuj Trasę (AI TSP)
{stops.length > 0 && (
🗺️ Otwórz Trasę w Google Maps
)}
{/* PODSUMOWANIE METRYK TRASY */}
📍 Przystanki: {stops.length} stacji
🛣️ Łączny Dystans: {routeStats.totalKm} km
⏱️ Szacowany Czas Trasy: {routeStats.formattedTime}
🚗 Przejazd: {routeStats.drivingTimeMinutes} min. | 🛠️ Prace: {routeStats.onSiteTimeMinutes} min.
{optimized && (
✅ Zoptymalizowano kolejność najkrótszą drogą
)}
{/* SIATKA: LISTA PRZYSTANKÓW (LEWA) + MAPA LEAFLET (PRAWA) */}
{/* LEWA STRONA: PUNKTY TRASY */}
{/* Punkt Bazy / Startu */}
🏁
Baza Startowa / Magazyn
{depotAddress} ({depotLat.toFixed(4)}, {depotLng.toFixed(4)})
📍 Mój GPS
setEditingDepot(!editingDepot)}
title="Zmień punkt startowy lub wybierz miasto"
>
{editingDepot ? 'Zamknij' : '⚙️ Zmień'}
{editingDepot && (
)}
{stops.length === 0 ? (
Brak zleceń z koordynatami GPS na trasie.
Poniżej znajdziesz zlecenia oczekujące na pobranie GPS.
) : (
stops.map((s, idx) => (
{idx + 1}
{s.name}
🏢 Obiekt: {s.object_number || 'Brak'} | {s.client_name}
📍 {s.location || `${s.latitude.toFixed(4)}, ${s.longitude.toFixed(4)}`}
moveStop(idx, -1)}
disabled={idx === 0}
title="Przesuń w górę"
>
▲
moveStop(idx, 1)}
disabled={idx === stops.length - 1}
title="Przesuń w dół"
>
▼
))
)}
{/* SEKCJA: ZLECENIA OCZEKUJĄCE NA GPS */}
{pendingGpsProjects.length > 0 && (
⚠️ Oczekujące na GPS ({pendingGpsProjects.length})
Wymaga współrzędnych
{pendingGpsProjects.map(p => (
{p.name}
Stacja: {p.object_number || 'Brak'} | {p.client_name || '-'}
handleFetchBtSearchForProject(p)}
title="Wyszukaj współrzędne stacji w bazie BTSearch i dodaj do trasy"
>
{loadingBtSearchId === p.id ? '⏳ Szukanie...' : '🔍 Pobierz GPS (BTSearch)'}
))}
)}
{/* PRAWA STRONA: MAPA LEAFLET */}
);
}
function TempPasswordModal({ token, targetUser, onClose }) {
const [tempPass, setTempPass] = useState('temp' + Math.floor(1000 + Math.random() * 9000));
const handleSubmit = async (e) => {
e.preventDefault();
try {
const res = await apiFetch(`/api/auth/users/${targetUser.id}/temp-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ temp_password: tempPass })
}, token);
showToast(res.message, 'success');
onClose();
} catch (err) {
showToast(err.message, 'error');
}
};
return (
);
}
function EditProjectModal({ token, project, getProjectStatusBadge, onClose, onUpdated }) {
const [completionDate, setCompletionDate] = useState(project.completion_date || '');
const [plannedCompletionDate, setPlannedCompletionDate] = useState(project.planned_completion_date || '');
const [remarks, setRemarks] = useState(project.remarks || '');
// Adres i Współrzędne GPS
const [location, setLocation] = useState(project.location || '');
const [latitude, setLatitude] = useState(project.latitude !== undefined && project.latitude !== null ? project.latitude : '');
const [longitude, setLongitude] = useState(project.longitude !== undefined && project.longitude !== null ? project.longitude : '');
const [objectNumber, setObjectNumber] = useState(project.object_number || '');
const [loadingLookup, setLoadingLookup] = useState(false);
// Przypisywanie techników / użytkowników
const [workers, setWorkers] = useState([]);
const [selectedWorkers, setSelectedWorkers] = useState(() => {
try {
const arr = JSON.parse(project.assigned_workers_json || '[]');
return Array.isArray(arr) ? arr : [];
} catch(e) {
return [];
}
});
useEffect(() => {
apiFetch('/api/auth/users', {}, token)
.then(usrs => {
setWorkers(usrs.filter(u => u.role === 'WORKER' || u.role === 'ADMIN'));
})
.catch(console.error);
}, [token]);
const isWorkerSelected = (w) => {
if (!w) return false;
return selectedWorkers.includes(w.id) ||
selectedWorkers.includes(String(w.id)) ||
selectedWorkers.includes(w.username);
};
const toggleWorker = (w) => {
if (isWorkerSelected(w)) {
setSelectedWorkers(selectedWorkers.filter(idOrName =>
idOrName !== w.id && idOrName !== String(w.id) && idOrName !== w.username
));
} else {
setSelectedWorkers([...selectedWorkers, w.username]);
}
};
const handleSelectDropdown = (e) => {
const val = e.target.value;
if (!val) return;
const targetWorker = workers.find(w => w.username === val || String(w.id) === val);
if (targetWorker && !isWorkerSelected(targetWorker)) {
setSelectedWorkers([...selectedWorkers, targetWorker.username]);
}
e.target.value = '';
};
// Pobieranie danych i współrzędnych GPS z BTSearch
const handleFetchBtSearch = async () => {
const code = objectNumber.trim();
if (!code) {
showToast('Wpisz numer/kod obiektu (np. 51482), aby pobrać dane!', 'warning');
return;
}
setLoadingLookup(true);
try {
const data = await apiFetch(`/api/btsearch/lookup?code=${encodeURIComponent(code)}`, {}, token);
if (data.address) setLocation(data.address);
if (data.gps_coordinates) {
const parts = data.gps_coordinates.split(',').map(s => s.trim());
if (parts.length === 2 && !isNaN(parseFloat(parts[0])) && !isNaN(parseFloat(parts[1]))) {
setLatitude(parseFloat(parts[0]));
setLongitude(parseFloat(parts[1]));
}
}
showToast(`Pobrano adres i współrzędne z BTSearch dla stacji ${code}!`, 'success');
} catch (err) {
showToast(err.message || 'Nie znaleziono danych w BTSearch', 'error');
} finally {
setLoadingLookup(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
const latVal = latitude !== '' ? parseFloat(latitude) : null;
const lngVal = longitude !== '' ? parseFloat(longitude) : null;
const res = await apiFetch(`/api/projects/${project.id}/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
completion_date: completionDate,
planned_completion_date: plannedCompletionDate,
remarks,
location,
latitude: latVal,
longitude: lngVal,
object_number: objectNumber,
assigned_workers: selectedWorkers
})
}, token);
showToast(res.message, 'success');
onUpdated();
} catch (err) {
showToast(err.message, 'error');
}
};
return (
×
✏️ Szczegóły & Korekta Zlecenia: {project.name}
{/* PODGLĄD CAŁEGO ZLECENIA (DANE I OPIS BEZ ZDJĘĆ) */}
📄 PODGLĄD DANYCH I OPISU ZLECENIA
Klient: {project.client_name || 'Brak'}
Kod Zlecenia: {project.code || '-'}
Numer Obiektu: {project.object_number || 'Brak'}
Status Zlecenia: {getProjectStatusBadge ? getProjectStatusBadge(project) : project.status}
Typ / Szablon: {project.project_type || project.order_template || 'Standardowy'}
Priorytet: {project.priority || 'NORMALNY'}
{project.created_at && (
Data Zgłoszenia: {new Date(project.created_at).toLocaleString('pl-PL')}
)}
{/* OPIS / ZAKRES PRAC */}
📝 Opis & Zakres Prac:
{project.description || project.remarks || 'Brak opisu zlecenia.'}
{/* REJESTR CZASU PRACY DLA ADMINA */}
⏱️ Rejestr Czasu Pracy:
Rozpoczęto: {project.work_started_at || 'Brak'} |
Zakończono: {project.work_ended_at || 'Brak'}
{/* SEKCJA: ADRES I WSPÓŁRZĘDNE GPS ZLECENIA */}
{/* PRZYPISYWANIE UŻYTKOWNIKÓW / SERWISANTÓW (ROZWIJANA LISTA + MULTI-SELECT) */}
👷 Przypisani Użytkownicy / Serwisanci ({selectedWorkers.length})
{/* Rozwijana lista (Dropdown) do wyboru użytkownika */}
-- Kliknij, aby wybrać i dodać użytkownika z listy --
{workers.map(w => {
const isAssigned = isWorkerSelected(w);
return (
{isAssigned ? '✓ ' : '+ '} {w.username} ({w.role === 'ADMIN' ? 'Dyspozytor/Admin' : 'Serwisant/Monter'})
);
})}
{/* Wizualne kafelki / chipsy wybranych użytkowników */}
{workers.map(w => {
const isSel = isWorkerSelected(w);
return (
toggleWorker(w)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
borderRadius: '20px',
fontSize: '0.82rem',
fontWeight: 700,
cursor: 'pointer',
border: isSel ? '1px solid #0284c7' : '1px solid #cbd5e1',
background: isSel ? '#0284c7' : '#ffffff',
color: isSel ? '#ffffff' : '#334155',
boxShadow: isSel ? '0 2px 6px rgba(2, 132, 199, 0.3)' : 'none',
transition: 'all 0.15s ease'
}}
>
{isSel ? '✅' : '➕'}
{w.username}
({w.role})
);
})}
{selectedWorkers.length === 0 && (
⚠️ Zlecenie nie ma przypisanego żadnego pracownika.
)}
📝 Uwagi i Ustalenia Admina/Zespołu
setRemarks(e.target.value)} />
💾 Zapisz Zmiany i Przypisania Zlecenia
);
}
// ─── KOMPONENT: MOBILNY ASYSTENT TECHNIKA AI GEMINI W TERENIE ────────
function AiAssistantModal({ token, currentProject, onClose }) {
const [messages, setMessages] = useState([
{
sender: 'bot',
text: 'Cześć! Jestem Twoim Asystentem Technicznym Gemini AI. Pomogę Ci w terenie z kodami błędów siłowni, instrukcjami DTR, parametrami prostowników (TELZAS, Emerson, Eltek, Enetek), licznikami energii oraz procedurami montażu i BHP. W czym mogę pomóc?'
}
]);
const [inputQuery, setInputQuery] = useState('');
const [loading, setLoading] = useState(false);
const [selectedImageB64, setSelectedImageB64] = useState(null);
const [imagePreview, setImagePreview] = useState(null);
const messagesEndRef = useRef(null);
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, loading]);
const quickPrompts = [
"Jak sprawdzić parametry modułu TELZAS PDM 48/41-2000?",
"Kody błędów i alarmy sterownika Emerson PSC 3 / PI1",
"Procedura podłączenia licznika F&F LE-03MQ (Modbus RTU)",
"Bezpieczna kolejność montażu baterii akumulatorów DC 48V",
"Jak wykonać reset modułu prostownikowego Enetek ETR+?"
];
const handleSend = async (textToSend = null) => {
const q = textToSend || inputQuery;
if (!q.trim() && !selectedImageB64) return;
const userMsg = {
sender: 'user',
text: q || 'Załączono zdjęcie do analizy',
image: imagePreview
};
setMessages(prev => [...prev, userMsg]);
setInputQuery('');
const currentImg = selectedImageB64;
setSelectedImageB64(null);
setImagePreview(null);
setLoading(true);
try {
const contextInfo = currentProject
? `Zlecenie: ${currentProject.name}, Obiekt: ${currentProject.object_number || '-'}, Klient: ${currentProject.client_name}`
: '';
const res = await apiFetch('/api/ai/technical-assistant', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: q,
image_b64: currentImg,
context_info: contextInfo
})
}, token);
setMessages(prev => [...prev, {
sender: 'bot',
text: res.response || 'Brak odpowiedzi.'
}]);
} catch (e) {
setMessages(prev => [...prev, {
sender: 'bot',
text: `⚠️ Błąd połączenia z asystentem AI: ${e.message}`
}]);
} finally {
setLoading(false);
}
};
const handleImageUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
setImagePreview(URL.createObjectURL(file));
const reader = new FileReader();
reader.onload = () => {
setSelectedImageB64(reader.result);
};
reader.readAsDataURL(file);
};
return (
{/* NAGŁÓWEK */}
🤖
Asystent Technika AI Gemini
{currentProject ? `Kontekst: ${currentProject.name} (${currentProject.object_number || '-'})` : 'Mobilny Doradca Techniczny / DTR / Kody Błędów'}
×
{/* HISTORIA CZATU */}
{messages.map((m, idx) => (
{m.sender === 'bot' ? '🤖' : '👷'}
{m.image && (
)}
{m.text}
))}
{loading && (
🤖
Analizuję dokumentację techniczną i schematy...
⏳
)}
{/* SZYBKIE PODPOWIEDZI / CHIPY */}
{quickPrompts.map((chip, idx) => (
handleSend(chip)}
>
💡 {chip}
))}
{/* PODGLĄD ZAŁĄCZONEGO ZDJĘCIA */}
{imagePreview && (
Załączono zdjęcie tabliczki / usterki
{ setImagePreview(null); setSelectedImageB64(null); }}
>
✕ Usuń
)}
{/* PASEK WPROWADZANIA WIADOMOŚCI */}
📷
setInputQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSend(); }}
disabled={loading}
/>
handleSend()}
disabled={loading || (!inputQuery.trim() && !selectedImageB64)}
>
Zapytaj 🚀
);
}
// ─── KOMPONENT: WERYFIKACJA CZĘŚCI W AUCIE PRZED WYJAZDEM (FIRST-TIME FIX) ────────
function VanPartsVerificationModal({ token, project, onClose }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [addingItemName, setAddingItemName] = useState('');
const [addingCategory, setAddingCategory] = useState('Moduł');
const [addingQty, setAddingQty] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const loadVerification = async () => {
if (!project) return;
setLoading(true);
try {
const res = await apiFetch('/api/van-inventory/verify-job-parts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: project.id })
}, token);
setData(res);
} catch (e) {
showToast(e.message, 'error');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadVerification();
}, [project, token]);
const handleAddStock = async (e) => {
e.preventDefault();
if (!addingItemName.trim()) return;
setIsSubmitting(true);
try {
await apiFetch('/api/van-inventory/transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
item_name: addingItemName,
category: addingCategory,
quantity: parseInt(addingQty) || 1,
van_plate: 'AUTO-01'
})
}, token);
showToast(`Dodano "${addingItemName}" do magazynu auta!`, 'success');
setAddingItemName('');
loadVerification();
} catch (e) {
showToast(e.message, 'error');
} finally {
setIsSubmitting(false);
}
};
return (
🚐 Weryfikacja Części w Aucie (First-Time Fix)
Zlecenie: {project?.name} (Obiekt: {project?.object_number || 'Brak'})
×
{loading ? (
⏳ Weryfikacja stanów magazynowych auta...
) : data ? (
{/* WSKAŹNIK FIRST-TIME FIX */}
{data.all_parts_ready ? '✅ Wszystkie Wymagane Części w Aucie!' : '⚠️ Brak Wymaganych Części do Wykonania Zlecenia!'}
Wskaźnik gotowości do naprawy za pierwszym razem (FTF Score)
{data.first_time_fix_score}%
{/* LISTA WYMAGANYCH POZYCJI */}
📋 Lista kontrolna części dla typu prac:
{data.items.map((item, idx) => (
{item.is_ready ? '🟢' : '🔴'} {item.name}
Wymagane minimum: {item.required_qty} szt.
W aucie: {item.in_van_qty} szt.
))}
{/* SZYBKIE DODANIE CZĘŚCI DO AUTA */}
➕ Załaduj brakującą część do auta:
setAddingItemName(e.target.value)}
required
/>
setAddingCategory(e.target.value)}
>
Moduł
Pakiet
Licznik
Osprzęt
Bezpiecznik
Sterownik
setAddingQty(e.target.value)}
required
/>
➕ Dodaj
) : null}
Zamknij
);
}
// ─── KOMPONENT: HISTORIA OBIEKTU, POPRZEDNICH WIZYT & TIMELINE SPRZĘTU ───────────
function SiteHistoryTimelineModal({ token, project, openLightbox, onClose }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('timeline'); // 'timeline' | 'assets' | 'site_info'
useEffect(() => {
if (!project) return;
setLoading(true);
apiFetch(`/api/projects/${project.id}/site-history`, {}, token)
.then(res => setData(res))
.catch(err => showToast(err.message, 'error'))
.finally(() => setLoading(false));
}, [project, token]);
return (
📜
Paszportyzacja & Historia Obiektu: {project?.object_number || project?.code || project?.name}
Zlecenie bieżące: {project?.name} • Operator: {data?.site_info?.operator || 'TMPL'}
✕ Zamknij
{/* ZAKŁADKI HISTORII */}
setActiveTab('timeline')}
>
⏱️ Oś Czasu Wizyt ({data?.history_projects?.length || 0})
setActiveTab('assets')}
>
🔌 Zainstalowane Urządzenia & Sprzęt ({data?.site_assets?.length || 0})
setActiveTab('site_info')}
>
📍 Kody Dostępu & Lokalizacja Obiektu
{/* ZAWARTOŚĆ MODALA */}
{loading ? (
⏳
Ładowanie historii wizyt i kartoteki sprzętowej...
) : !data ? (
Nie udało się pobrać historii obiektu.
) : (
<>
{/* TAB 1: TIMELINE POPRZEDNICH WIZYT */}
{activeTab === 'timeline' && (
{data.history_projects && data.history_projects.length > 0 ? (
data.history_projects.map((hp, idx) => {
const isCurrent = hp.id === data.current_project_id;
return (
{/* LINIA I PUNKT TIMELINE */}
{idx + 1}
{idx !== data.history_projects.length - 1 && (
)}
{/* KARTA WIZYTY / ZLECENIA */}
{hp.name}
{isCurrent && (
Bieżące zlecenie
)}
📅 Data: {hp.scheduled_date || (hp.created_at ? hp.created_at.substring(0, 10) : '—')} • Typ: {hp.project_type || 'Serwis'} • Technik: {hp.creator_name || 'Brak'}
{hp.status || 'DRAFT'}
{hp.description && (
Opis prac: {hp.description}
)}
{hp.remarks && (
⚠️ Uwagi serwisowe: {hp.remarks}
)}
{/* ZDJĘCIA Z TEJ WIZYTY */}
{hp.recent_photos && hp.recent_photos.length > 0 && (
📸 Zdjęcia z wizyty ({hp.photo_count || hp.recent_photos.length}):
{hp.recent_photos.map(ph => (
openLightbox && openLightbox(`/api/photos/${ph.id}/file?token=${token}`, ph.original_name)}
style={{ cursor: 'pointer', flexShrink: 0 }}
>
))}
)}
);
})
) : (
Brak zarejestrowanych wcześniejszych wizyt dla tego obiektu.
)}
)}
{/* TAB 2: ZAINSTALOWANY SPRZĘT & PASZPORTYZACJA */}
{activeTab === 'assets' && (
📦 Ewidencja Urządzeń i Podzespołów na Stacji:
{data.site_assets && data.site_assets.length > 0 ? (
Kategoria
Model / Producent
Numer Seryjny (SN)
Slot
Status
Data Montażu
{data.site_assets.map(asset => (
{asset.category}
{asset.manufacturer ? `${asset.manufacturer} ` : ''}{asset.model}
{asset.serial_number}
{asset.slot_number || '—'}
{asset.status === 'ACTIVE' ? '🟢 ZAMONTOWANE' : '🔴 ZDEMONTOWANE'}
{asset.installed_at ? asset.installed_at.substring(0, 10) : '—'}
))}
) : (
🔍
Brak wpisów w paszporcie sprzętowym dla tego obiektu. Numery seryjne zostaną zaktualizowane po zatwierdzeniu protokołu OCR.
)}
)}
{/* TAB 3: KODY DOSTĘPU & DANE OBIEKTU */}
{activeTab === 'site_info' && (
🔑 Dane Dostępowe & Klucze:
Kody do bramy/zamków:
{data.site_info?.access_codes || 'Brak danych w bazie (sprawdź zlecenie)'}
Lokalizacja sejfu na klucze (MasterLock):
{data.site_info?.key_safe_info || 'Klucze pobierane u ochrony / brak sejfu'}
Osoba kontaktowa / Administrator:
{data.site_info?.contact_person ? `${data.site_info.contact_person} (${data.site_info.contact_phone || 'brak tel'})` : 'Dyspozytura Centralna MT-PRO'}
📍 Lokalizacja & Współrzędne GPS:
Adres stacji:
{data.site_info?.address ? `${data.site_info.address}, ${data.site_info.city || ''}` : (project?.name || 'Brak dokładnego adresu')}
Współrzędne geograficzne:
{data.site_info?.latitude && data.site_info?.longitude
? `${data.site_info.latitude.toFixed(5)}°N, ${data.site_info.longitude.toFixed(5)}°E`
: 'Brak koordynatów GPS'}
{data.site_info?.latitude && data.site_info?.longitude && (
)}
)}
>
)}
Zamknij
);
}
// ─── KOMPONENT: AUTOMATYCZNE ROZLICZENIE I KOSZTORYS ZLECENIA (JOB COSTING & BILLING) ───
function JobCostingModal({ token, project, onClose }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!project) return;
setLoading(true);
apiFetch(`/api/projects/${project.id}/costing`, {}, token)
.then(res => setData(res))
.catch(err => showToast(err.message, 'error'))
.finally(() => setLoading(false));
}, [project, token]);
const handlePrintCosting = () => {
window.print();
};
return (
💰
Automatyczny Kosztorys & Rozliczenie Prac (Job Costing)
Zlecenie: {project?.name} • Klient: {data?.client_name || project?.client_name} • Obiekt: {project?.object_number || '—'}
✕ Zamknij
{loading ? (
⏳
Kalkulacja kosztorysu, stawek rbh i zużycia materiałowego...
) : !data ? (
Nie udało się skalkulować kosztorysu zlecenia.
) : (
{/* PODSUMOWANIE TELEMETRII CZASOWEJ */}
Czas Dojazdu
{data.travel_hours} h
Czas Prac Serwisowych
{data.work_hours} rbh
Suma Robocizna Netto
{data.total_labor_net.toFixed(2)} PLN
Suma Materiały Netto
{data.total_material_net.toFixed(2)} PLN
{/* TABELA POZYCJI KOSZTORYSU */}
📋 Szczegółowa Specyfikacja Pozycji (Line Items):
Lp.
Opis Pozycji / Komponentu
Typ
Ilość
Cena Jedn. Netto
Wartość Netto
{data.line_items.map((item, idx) => (
{idx + 1}
{item.item_name}
{item.category === 'MATERIAL' ? 'MATERIAŁ' : item.category === 'TRAVEL' ? 'DOJAZD' : 'ROBOCIZNA'}
{item.quantity} {item.unit}
{item.unit_price.toFixed(2)} PLN
{item.total_net.toFixed(2)} PLN
))}
{/* PODSUMOWANIE KWOTOWE RAZEM */}
Suma Netto:
{data.grand_total_net.toFixed(2)} PLN
VAT ({data.vat_rate}):
{data.vat_amount.toFixed(2)} PLN
Razem Brutto:
{data.grand_total_gross.toFixed(2)} PLN
)}
🖨️ Drukuj / Eksportuj Kosztorys PDF
Zamknij
);
}
function LightboxModal({ data, onClose }) {
return (
×
{data.caption}
);
}
function OcrScanModal({ onClose, onAppendText }) {
const [ocrLoading, setOcrLoading] = useState(false);
const [ocrProgress, setOcrProgress] = useState('');
const [ocrResult, setOcrResult] = useState('');
const [imagePreview, setImagePreview] = useState(null);
const handleImageSelect = async (e) => {
const file = e.target.files[0];
if (!file) return;
setImagePreview(URL.createObjectURL(file));
setOcrLoading(true);
setOcrProgress('Inicjalizacja silnika Tesseract AI...');
try {
if (typeof Tesseract === 'undefined') {
throw new Error('Biblioteka OCR Tesseract nie została załadowana.');
}
const worker = await Tesseract.createWorker('pol+eng', 1, {
logger: m => {
if (m.status === 'recognizing text') {
setOcrProgress(`Rozpoznawanie tekstu: ${Math.round(m.progress * 100)}%`);
}
}
});
const ret = await worker.recognize(file);
await worker.terminate();
const text = ret.data.text.trim();
setOcrResult(text);
setOcrLoading(false);
showToast('✅ Odczytano tekst z tabliczki znamionowej!', 'success');
} catch (err) {
console.error(err);
showToast('Nie udało się przeprowadzić OCR tekstu.', 'warning');
setOcrLoading(false);
}
};
const handleApply = () => {
if (ocrResult) {
onAppendText(`[ODCZYT TABLICZKI OCR]:\n${ocrResult}`);
showToast('Dodano odczytany tekst do uwag zlecenia!', 'success');
onClose();
}
};
return (
×
🔍 AI Skaner Tabliczek Znamionowych OCR
Zrób zdjęcie tabliczki znamionowej lub naklejki S/N aparaty, a AI odczyta symbole i numery seryjne.
📷 Wybierz / Zrób Zdjęcie Tabliczki
{imagePreview && (
)}
{ocrLoading && (
⏳ {ocrProgress}
)}
{ocrResult && (
✅ Wynik odczytu AI:
setOcrResult(e.target.value)} />
📝 Wklej Wynik do Uwag Zlecenia
)}
);
}
function GhostOverlayCameraModal({ ghostPhotoUrl, onCapture, onClose }) {
const videoRef = React.useRef(null);
const canvasRef = React.useRef(null);
const [opacity, setOpacity] = useState(0.4);
const [stream, setStream] = useState(null);
React.useEffect(() => {
navigator.mediaDevices?.getUserMedia({ video: { facingMode: 'environment' } })
.then(s => {
setStream(s);
if (videoRef.current) videoRef.current.srcObject = s;
})
.catch(err => {
console.error('Błąd kamery:', err);
showToast('Nie można uzyskać dostępu do kamery w urządzeniu.', 'warning');
});
return () => {
if (stream) stream.getTracks().forEach(t => t.stop());
};
}, []);
const takePhoto = () => {
if (!videoRef.current || !canvasRef.current) return;
const video = videoRef.current;
const canvas = canvasRef.current;
canvas.width = video.videoWidth || 1280;
canvas.height = video.videoHeight || 720;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (blob) {
const file = new File([blob], `ghost_after_${Date.now()}.jpg`, { type: 'image/jpeg' });
onCapture(file);
if (stream) stream.getTracks().forEach(t => t.stop());
onClose();
}
}, 'image/jpeg', 0.85);
};
return (
×
👻 Aparat Ghost Overlay (Atrzymanie Kadru Przed/Po)
{ghostPhotoUrl && (
)}
{ghostPhotoUrl && (
Przeźroczystość zdjęcia "Przed":
setOpacity(parseFloat(e.target.value))} />
{Math.round(opacity * 100)}%
)}
📸 Zrób Zdjęcie "Po" w Identycznym Kadrze
Anuluj
);
}
// ─── DEDYROWANY MODUŁ "GENEROWANIE DOKUMENTACJI" (SPECYFIKACJA TMPL) ───────────
function NewDocumentationModal({ token, onClose, onCreated }) {
const [clientName, setClientName] = useState('TMPL');
const [orderNumber, setOrderNumber] = useState('');
const [objectNumber, setObjectNumber] = useState('');
const [tmplName, setTmplName] = useState('');
const [networksName, setNetworksName] = useState('');
const [address, setAddress] = useState('');
const [gpsCoordinates, setGpsCoordinates] = useState('');
const [executionDate, setExecutionDate] = useState(new Date().toISOString().split('T')[0]);
const [orderType, setOrderType] = useState('MODERNIZACJA');
const [existingProjects, setExistingProjects] = useState([]);
const [selectedProjectId, setSelectedProjectId] = useState('');
const [loadingProjects, setLoadingProjects] = useState(false);
const [loadingLookup, setLoadingLookup] = useState(false);
const [loadingSubmit, setLoadingSubmit] = useState(false);
// Pobieranie listy wykonanych zleceń z bazy
useEffect(() => {
const fetchCompletedProjects = async () => {
setLoadingProjects(true);
try {
const res = await apiFetch('/api/projects', { method: 'GET' }, token);
setExistingProjects(res || []);
} catch (err) {
console.error(err);
} finally {
setLoadingProjects(false);
}
};
fetchCompletedProjects();
}, [token]);
// Obsługa wyboru wykonanego zlecenia z listy
const handleSelectExistingProject = (e) => {
const pid = e.target.value;
setSelectedProjectId(pid);
if (!pid) return;
const prj = existingProjects.find(p => String(p.id) === String(pid));
if (prj) {
if (prj.object_number) setObjectNumber(prj.object_number);
if (prj.client_name) setClientName(prj.client_name);
if (prj.name) setTmplName(prj.name);
showToast(`Wybrano zlecenie: ${prj.code} - ${prj.name}. Wszystkie zdjęcia i dane będą trwale powiązane z tym zleceniem!`, 'info');
}
};
// Funkcja "🔍 Pobierz dane" z BTSearch.pl dla wskazanego numeru obiektu
const handleFetchBtSearchData = async () => {
if (!objectNumber.trim()) {
showToast('Wpisz numer obiektu TMPL (np. 51482 lub 57103), aby pobrać dane!', 'error');
return;
}
setLoadingLookup(true);
try {
const data = await apiFetch(`/api/btsearch/lookup?code=${encodeURIComponent(objectNumber.trim())}`, { method: 'GET' }, token);
if (data.tmpl_name) setTmplName(data.tmpl_name);
if (data.networks_name) setNetworksName(data.networks_name);
if (data.address) setAddress(data.address);
if (data.gps_coordinates) setGpsCoordinates(data.gps_coordinates);
showToast(`Pobrano dane z BTSearch.pl dla stacji ${objectNumber}!`, 'success');
} catch (err) {
showToast(err.message || 'Nie znaleziono stacji w BTSearch.pl', 'error');
} finally {
setLoadingLookup(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!objectNumber.trim()) {
showToast('Wypełnij numer obiektu TMPL!', 'error');
return;
}
setLoadingSubmit(true);
try {
let targetProjectId = selectedProjectId;
let latVal = null;
let lngVal = null;
if (gpsCoordinates) {
const parts = gpsCoordinates.split(',').map(s => s.trim());
if (parts.length === 2 && !isNaN(parseFloat(parts[0])) && !isNaN(parseFloat(parts[1]))) {
latVal = parseFloat(parts[0]);
lngVal = parseFloat(parts[1]);
}
}
// Jeśli wybrano istniejące zlecenie, zaktualizujmy jego metrykę i dane powykonawcze
if (targetProjectId) {
await apiFetch(`/api/projects/${targetProjectId}/update-asbuilt-meta`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: clientName,
object_number: objectNumber,
order_number: orderNumber,
tmpl_name: tmplName,
networks_name: networksName,
address: address,
gps_coordinates: gpsCoordinates,
latitude: latVal,
longitude: lngVal,
execution_date: executionDate,
order_type: orderType
})
}, token);
} else {
// Utwórzmy nowe dedykowane zlecenie powykonawcze
const res = await apiFetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: `${orderType} ${tmplName || objectNumber}`,
client_name: clientName,
object_number: objectNumber,
location: address,
latitude: latVal,
longitude: lngVal,
description: `Dokumentacja Powykonawcza TMPL\nNr zamówienia: ${orderNumber}\nNazwa TMPL: ${tmplName}\nNazwa NetWorkS!: ${networksName}\nAdres: ${address}\nGPS: ${gpsCoordinates}\nTyp: ${orderType}`,
planned_completion_date: executionDate,
priority: 'NORMAL'
})
}, token);
targetProjectId = res.projectId || res.id;
}
showToast(`✅ Utworzono / Powiązano Dokumentację Powykonawczą z obiektem ${objectNumber}! System będzie trzymał się wyłącznie tego zlecenia.`, 'success');
if (onCreated) onCreated({ id: targetProjectId });
onClose();
} catch(err) {
showToast(err.message, 'error');
} finally {
setLoadingSubmit(false);
}
};
return (
📄 MODUŁ GENEROWANIA DOKUMENTACJI POWYKONAWCZEJ (TMPL)
Dedykowane Okno Pracy — Wybór Klienta, Obiektu i Metryki
✕ Zamknij Okno
{/* KROK 0: WYBÓR ZLECENIA ZE ZLECEN WYKONANYCH */}
📂 Wybierz Zlecenie z Listy Wykonanych (Opcjonalnie):
-- Tworzę nową dokumentację (lub wybierz z listy poniżej) --
{existingProjects.map(p => (
{p.code} | {p.client_name} - {p.name} (Obiekt: {p.object_number || 'Brak'}) [{p.status}]
))}
* Od momentu wyboru zlecenia system trwale zwiąże generowany protokół, zdjęcia oraz unikalny folder powykonawczy wyłącznie z tym wybranym zleceniem!
{/* 1. Wybór Klienta */}
Wybór Klienta:
setClientName(e.target.value)}>
TMPL (T-Mobile / NetWorkS!)
TELZAS
DELTA ELECTRONICS
ELTEK
Inny Klient...
{/* 2. Nr Zamówienia */}
📌 Nr. Zamówienia:
setOrderNumber(e.target.value)}
/>
{/* 3. Nr Obiektu TMPL + Przycisk Pobierz Dane */}
{/* 4. Nazwa TMPL & NetWorkS! (Pobierana Automatycznie) */}
{/* 5. Adres & GPS (Pobierane Automatycznie) */}
📍 Adres Obiektu (Miasto, ul. Ulica Numer):
setAddress(e.target.value)}
/>
{/* 6. Typ Zlecenia */}
🔧 Typ Zlecenia:
setOrderType(e.target.value)}>
MODERNIZACJA
PRZEGLĄD SLA
AWARIA / USUWANE USTERKI
MONTAŻ NOWY
✕ Zamknij Okno
{loadingSubmit ? '⏳ Zapisywanie...' : '✅ Utwórz / Powiąż Dokumentację Powykonawczą'}
);
}
// Renderowanie aplikacji React
const root = ReactDOM.createRoot(document.getElementById('react-root'));
root.render( );