import { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { fetchOrders, fetchSyncLogs, getFormexSyncStreamUrl, getHaefeleSyncStreamUrl, fetchHaefeleMaterials, fetchOrderSupplierVouchers, fetchOrderPlanExports, generateOrderPlanExports, fetchOrderProductionDetails, getOrderPlanExportDownloadUrl, getOrderPlanExportViewUrl, deleteOrderPlanExport } from '../api/client'; import Icon from '../components/ui/Icon'; import styles from './Admin.module.css'; export default function Admin() { const { isAdmin, logout } = useAuth(); const navigate = useNavigate(); const [orders, setOrders] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [expandedOrderId, setExpandedOrderId] = useState(null); const [supplierVouchersByOrder, setSupplierVouchersByOrder] = useState({}); const [voucherLoadingByOrder, setVoucherLoadingByOrder] = useState({}); const [voucherErrorByOrder, setVoucherErrorByOrder] = useState({}); const [planExportsByOrder, setPlanExportsByOrder] = useState({}); const [planExportsLoadingByOrder, setPlanExportsLoadingByOrder] = useState({}); const [planExportsErrorByOrder, setPlanExportsErrorByOrder] = useState({}); const [planExportsGeneratingByOrder, setPlanExportsGeneratingByOrder] = useState({}); const [planExportDeletingById, setPlanExportDeletingById] = useState({}); const [productionDetailsByOrder, setProductionDetailsByOrder] = useState({}); const [productionLoadingByOrder, setProductionLoadingByOrder] = useState({}); const [productionErrorByOrder, setProductionErrorByOrder] = useState({}); const [productionVisibleByOrder, setProductionVisibleByOrder] = useState({}); // Formex sync state const [syncLogs, setSyncLogs] = useState([]); const [syncing, setSyncing] = useState(false); const [syncResult, setSyncResult] = useState(null); // SSE live progress state const [liveMessages, setLiveMessages] = useState([]); const [progress, setProgress] = useState({ current: 0, total: 0, collection: '' }); const [forceOverride, setForceOverride] = useState(false); const logsEndRef = useRef(null); // Häfele sync state const [haefeleUrlsInput, setHaefeleUrlsInput] = useState(''); const [haefeleSyncing, setHaefeleSyncing] = useState(false); const [haefeleSyncResult, setHaefeleSyncResult] = useState(null); const [haefeleLiveMessages, setHaefeleLiveMessages] = useState([]); const [haefeleProgress, setHaefeleProgress] = useState({ current: 0, total: 0, collection: '' }); const [haefeleSearch, setHaefeleSearch] = useState(''); const [haefeleMaterials, setHaefeleMaterials] = useState([]); const [haefeleLoading, setHaefeleLoading] = useState(false); const [haefeleError, setHaefeleError] = useState(''); const haefeleLogsEndRef = useRef(null); useEffect(() => { if (!isAdmin) { navigate('/login?next=/admin', { replace: true }); return; } // Fetch orders and sync logs in parallel Promise.all([fetchOrders(), fetchSyncLogs(10), fetchHaefeleMaterials({ limit: 50 })]) .then(([ordersData, logsData, haefeleData]) => { setOrders(ordersData); setSyncLogs(logsData); setHaefeleMaterials(haefeleData?.materials || []); }) .catch((err) => setError(err.message)) .finally(() => setLoading(false)); }, [isAdmin, navigate]); // Auto-scroll to bottom of live logs useEffect(() => { if (logsEndRef.current) { logsEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [liveMessages]); useEffect(() => { if (haefeleLogsEndRef.current) { haefeleLogsEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [haefeleLiveMessages]); useEffect(() => { const saved = window.localStorage.getItem('admin.haefele.urls'); if (saved) setHaefeleUrlsInput(saved); }, []); const handleLogout = () => { logout(); navigate('/', { replace: true }); }; const handleFormexSync = () => { setSyncing(true); setSyncResult(null); setLiveMessages([]); setProgress({ current: 0, total: 0, collection: '' }); const eventSource = new EventSource(getFormexSyncStreamUrl(forceOverride)); eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.type === 'log') { const msg = data.message; const lower = msg.toLowerCase(); const kind = lower.includes('erreur') || lower.includes('error') || lower.includes('failed') || lower.includes('échou') ? 'error' : lower.includes('warning') || lower.includes('non connecté') || lower.includes('pas de prix') ? 'warn' : lower.includes('succès') || lower.includes('connecté') || lower.includes('synchronis') ? 'ok' : 'info'; setLiveMessages(prev => [...prev, { msg, kind }]); } else if (data.type === 'progress') { setProgress({ current: data.current, total: data.total, collection: data.collection || '' }); } else if (data.type === 'complete') { setSyncResult(data.result); setSyncing(false); eventSource.close(); fetchSyncLogs(10).then(setSyncLogs).catch(() => {}); } else if (data.type === 'error') { setLiveMessages(prev => [...prev, { msg: `ERREUR: ${data.error}`, kind: 'error' }]); setSyncResult({ success: false, error: data.error }); setSyncing(false); eventSource.close(); } } catch (e) { console.error('SSE parse error:', e); } }; eventSource.onerror = () => { setLiveMessages(prev => [...prev, { msg: 'Connexion SSE perdue', kind: 'error' }]); setSyncResult({ success: false, error: 'Connexion perdue' }); setSyncing(false); eventSource.close(); }; }; const handleClearLogs = () => { setLiveMessages([]); setSyncResult(null); setProgress({ current: 0, total: 0, collection: '' }); }; const parseHaefeleUrls = () => [...new Set( haefeleUrlsInput .split(/[\n,;]+/g) .map((v) => v.trim()) .filter((v) => v && v.includes('haefele.ch')) )]; const persistHaefeleUrls = (value) => { setHaefeleUrlsInput(value); window.localStorage.setItem('admin.haefele.urls', value); }; const refreshHaefeleMaterials = async (search = haefeleSearch) => { setHaefeleLoading(true); setHaefeleError(''); try { const data = await fetchHaefeleMaterials({ search, limit: 100 }); setHaefeleMaterials(data?.materials || []); } catch (err) { setHaefeleError(err.message || 'Erreur de chargement Häfele'); } finally { setHaefeleLoading(false); } }; const handleHaefeleSync = () => { const urls = parseHaefeleUrls(); if (urls.length === 0) { setHaefeleSyncResult({ success: false, error: 'Ajoutez au moins une URL Häfele valide.' }); return; } setHaefeleSyncing(true); setHaefeleSyncResult(null); setHaefeleLiveMessages([]); setHaefeleProgress({ current: 0, total: urls.length, collection: 'Hafele' }); persistHaefeleUrls(urls.join('\n')); const eventSource = new EventSource(getHaefeleSyncStreamUrl(urls)); eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.type === 'log') { const msg = data.message; const lower = msg.toLowerCase(); const kind = lower.includes('erreur') || lower.includes('error') || lower.includes('failed') || lower.includes('✗') ? 'error' : lower.includes('warning') || lower.includes('non connecté') || lower.includes('pas de prix') ? 'warn' : lower.includes('✓') || lower.includes('succès') || lower.includes('connecté') || lower.includes('synchronis') ? 'ok' : 'info'; setHaefeleLiveMessages((prev) => [...prev, { msg, kind }]); } else if (data.type === 'progress') { setHaefeleProgress({ current: data.current, total: data.total, collection: data.collection || 'Hafele' }); } else if (data.type === 'complete') { setHaefeleSyncResult(data.result); setHaefeleSyncing(false); eventSource.close(); fetchSyncLogs(10).then(setSyncLogs).catch(() => {}); refreshHaefeleMaterials().catch(() => {}); } else if (data.type === 'error') { setHaefeleLiveMessages((prev) => [...prev, { msg: `ERREUR: ${data.error}`, kind: 'error' }]); setHaefeleSyncResult({ success: false, error: data.error }); setHaefeleSyncing(false); eventSource.close(); fetchSyncLogs(10).then(setSyncLogs).catch(() => {}); refreshHaefeleMaterials().catch(() => {}); } } catch (e) { console.error('Hafele SSE parse error:', e); } }; eventSource.onerror = () => { setHaefeleLiveMessages((prev) => [...prev, { msg: 'Connexion SSE Häfele perdue', kind: 'error' }]); setHaefeleSyncResult({ success: false, error: 'Connexion perdue' }); setHaefeleSyncing(false); eventSource.close(); fetchSyncLogs(10).then(setSyncLogs).catch(() => {}); refreshHaefeleMaterials().catch(() => {}); }; }; const handleClearHaefeleLogs = () => { setHaefeleLiveMessages([]); setHaefeleSyncResult(null); setHaefeleProgress({ current: 0, total: 0, collection: '' }); }; const progressPercent = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0; const haefeleProgressPercent = haefeleProgress.total > 0 ? Math.round((haefeleProgress.current / haefeleProgress.total) * 100) : 0; const handleToggleSupplierDetails = async (orderId) => { if (expandedOrderId === orderId) { setExpandedOrderId(null); return; } setExpandedOrderId(orderId); const shouldLoadVouchers = !supplierVouchersByOrder[orderId] && !voucherLoadingByOrder[orderId]; const shouldLoadPlanExports = !planExportsByOrder[orderId] && !planExportsLoadingByOrder[orderId]; if (!shouldLoadVouchers && !shouldLoadPlanExports) return; if (shouldLoadVouchers) { setVoucherErrorByOrder(prev => ({ ...prev, [orderId]: '' })); setVoucherLoadingByOrder(prev => ({ ...prev, [orderId]: true })); } if (shouldLoadPlanExports) { setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: '' })); setPlanExportsLoadingByOrder(prev => ({ ...prev, [orderId]: true })); } await Promise.all([ shouldLoadVouchers ? fetchOrderSupplierVouchers(orderId) .then((data) => setSupplierVouchersByOrder(prev => ({ ...prev, [orderId]: data }))) .catch((err) => setVoucherErrorByOrder(prev => ({ ...prev, [orderId]: err.message || 'Erreur de chargement' }))) .finally(() => setVoucherLoadingByOrder(prev => ({ ...prev, [orderId]: false }))) : Promise.resolve(), shouldLoadPlanExports ? fetchOrderPlanExports(orderId) .then((data) => setPlanExportsByOrder(prev => ({ ...prev, [orderId]: data }))) .catch((err) => setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: err.message || 'Erreur de chargement' }))) .finally(() => setPlanExportsLoadingByOrder(prev => ({ ...prev, [orderId]: false }))) : Promise.resolve() ]); }; const handleGeneratePlanExports = async (orderId) => { setPlanExportsGeneratingByOrder(prev => ({ ...prev, [orderId]: true })); setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: '' })); try { await generateOrderPlanExports(orderId); const data = await fetchOrderPlanExports(orderId); setPlanExportsByOrder(prev => ({ ...prev, [orderId]: data })); } catch (err) { setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: err.message || 'Erreur de génération' })); } finally { setPlanExportsGeneratingByOrder(prev => ({ ...prev, [orderId]: false })); } }; const handleToggleProductionDetails = async (orderId) => { const isVisible = !!productionVisibleByOrder[orderId]; if (isVisible) { setProductionVisibleByOrder(prev => ({ ...prev, [orderId]: false })); return; } setProductionVisibleByOrder(prev => ({ ...prev, [orderId]: true })); if (productionDetailsByOrder[orderId] || productionLoadingByOrder[orderId]) return; setProductionErrorByOrder(prev => ({ ...prev, [orderId]: '' })); setProductionLoadingByOrder(prev => ({ ...prev, [orderId]: true })); try { const data = await fetchOrderProductionDetails(orderId); setProductionDetailsByOrder(prev => ({ ...prev, [orderId]: data })); } catch (err) { setProductionErrorByOrder(prev => ({ ...prev, [orderId]: err.message || 'Erreur de chargement du dossier' })); } finally { setProductionLoadingByOrder(prev => ({ ...prev, [orderId]: false })); } }; const handleDeletePlanExport = async (orderId, exportId) => { const confirmed = window.confirm('Supprimer cet export ?'); if (!confirmed) return; setPlanExportDeletingById(prev => ({ ...prev, [exportId]: true })); setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: '' })); try { await deleteOrderPlanExport(orderId, exportId); const data = await fetchOrderPlanExports(orderId); setPlanExportsByOrder(prev => ({ ...prev, [orderId]: data })); } catch (err) { setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: err.message || 'Erreur de suppression' })); } finally { setPlanExportDeletingById(prev => ({ ...prev, [exportId]: false })); } }; if (!isAdmin) return null; return (

Administration

{loading &&

Chargement…

} {error &&

{error}

} {/* Formex Sync Section */} {!loading && !error && (

Synchronisation Formex

{syncResult && !syncing && ( {syncResult.success ? `${syncResult.itemsCount} produits synchronisés (${syncResult.duration}s)` : `Erreur: ${syncResult.error}`} )}
{/* Progress bar and live logs — stays visible after sync ends */} {(syncing || liveMessages.length > 0) && (
{progress.collection || (syncing ? 'Initialisation…' : 'Terminé')} {syncing ? `${progressPercent}%` : (syncResult?.success ? '✓ Succès' : '✗ Échec')}
{progress.total > 0 && `${progress.current} / ${progress.total} collections`}
Journal ({liveMessages.length} lignes) {!syncing && ( )}
{liveMessages.map(({ msg, kind }, idx) => (
{msg}
))}
)} {syncLogs.length > 0 && !syncing && liveMessages.length === 0 && (

Historique des synchronisations

{syncLogs.map((log) => ( ))}
Date Source Statut Produits Erreur
{log.created_at ? new Date(log.created_at).toLocaleString('fr-CH') : '—'} {log.source} {log.status === 'success' ? 'Succès' : 'Erreur'} {log.items_count ?? '—'} {log.error || '—'}
)}
)} {!loading && !error && (

Synchronisation Häfele (URLs ciblées)

Collez une URL par ligne (ou séparées par virgule). Seules les URL Häfele seront traitées.