927 lines
42 KiB
React
927 lines
42 KiB
React
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 (
|
||
<div className={styles.page}>
|
||
<div className={styles.header}>
|
||
<h1>Administration</h1>
|
||
<button type="button" onClick={handleLogout} className={styles.logout}>
|
||
<Icon name="logout" size={15} />
|
||
Déconnexion
|
||
</button>
|
||
</div>
|
||
{loading && <p className={styles.muted}>Chargement…</p>}
|
||
{error && <p className={styles.error}>{error}</p>}
|
||
|
||
{/* Formex Sync Section */}
|
||
{!loading && !error && (
|
||
<section className={styles.section}>
|
||
<h2>Synchronisation Formex</h2>
|
||
<div className={styles.syncActions}>
|
||
<button
|
||
type="button"
|
||
onClick={handleFormexSync}
|
||
disabled={syncing}
|
||
className={styles.syncBtn}
|
||
>
|
||
<Icon name="sync" size={15} />
|
||
{syncing ? 'Synchronisation en cours…' : 'Synchroniser Formex'}
|
||
</button>
|
||
<label className={styles.forceLabel}>
|
||
<input
|
||
type="checkbox"
|
||
checked={forceOverride}
|
||
onChange={e => setForceOverride(e.target.checked)}
|
||
disabled={syncing}
|
||
/>
|
||
Forcer la resynchronisation complète
|
||
</label>
|
||
{syncResult && !syncing && (
|
||
<span className={syncResult.success ? styles.success : styles.error}>
|
||
{syncResult.success
|
||
? `${syncResult.itemsCount} produits synchronisés (${syncResult.duration}s)`
|
||
: `Erreur: ${syncResult.error}`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Progress bar and live logs — stays visible after sync ends */}
|
||
{(syncing || liveMessages.length > 0) && (
|
||
<div className={styles.liveSync}>
|
||
<div className={styles.progressWrap}>
|
||
<div className={styles.progressInfo}>
|
||
<span>{progress.collection || (syncing ? 'Initialisation…' : 'Terminé')}</span>
|
||
<span>{syncing ? `${progressPercent}%` : (syncResult?.success ? '✓ Succès' : '✗ Échec')}</span>
|
||
</div>
|
||
<div className={styles.progressBar}>
|
||
<div
|
||
className={`${styles.progressFill} ${!syncing && syncResult ? (syncResult.success ? styles.progressDone : styles.progressError) : ''}`}
|
||
style={{ width: `${syncing ? progressPercent : 100}%` }}
|
||
/>
|
||
</div>
|
||
<div className={styles.progressStats}>
|
||
{progress.total > 0 && `${progress.current} / ${progress.total} collections`}
|
||
</div>
|
||
</div>
|
||
<div className={styles.liveLogsPanel}>
|
||
<div className={styles.liveLogsHeader}>
|
||
<span>Journal ({liveMessages.length} lignes)</span>
|
||
{!syncing && (
|
||
<button type="button" onClick={handleClearLogs} className={styles.clearBtn}>
|
||
Effacer
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className={styles.liveLogsContent}>
|
||
{liveMessages.map(({ msg, kind }, idx) => (
|
||
<div key={idx} className={`${styles.logLine} ${styles[`logLine_${kind}`]}`}>{msg}</div>
|
||
))}
|
||
<div ref={logsEndRef} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{syncLogs.length > 0 && !syncing && liveMessages.length === 0 && (
|
||
<div className={styles.logsWrap}>
|
||
<h3>Historique des synchronisations</h3>
|
||
<table className={styles.logsTable}>
|
||
<thead>
|
||
<tr>
|
||
<th>Date</th>
|
||
<th>Source</th>
|
||
<th>Statut</th>
|
||
<th>Produits</th>
|
||
<th>Erreur</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{syncLogs.map((log) => (
|
||
<tr key={log.id}>
|
||
<td>{log.created_at ? new Date(log.created_at).toLocaleString('fr-CH') : '—'}</td>
|
||
<td>{log.source}</td>
|
||
<td className={log.status === 'success' ? styles.success : styles.error}>
|
||
{log.status === 'success' ? 'Succès' : 'Erreur'}
|
||
</td>
|
||
<td>{log.items_count ?? '—'}</td>
|
||
<td>{log.error || '—'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{!loading && !error && (
|
||
<section className={styles.section}>
|
||
<h2>Synchronisation Häfele (URLs ciblées)</h2>
|
||
<p className={styles.muted}>
|
||
Collez une URL par ligne (ou séparées par virgule). Seules les URL Häfele seront traitées.
|
||
</p>
|
||
<div className={styles.haefeleInputWrap}>
|
||
<textarea
|
||
className={styles.urlTextarea}
|
||
value={haefeleUrlsInput}
|
||
onChange={(e) => persistHaefeleUrls(e.target.value)}
|
||
placeholder={`https://www.haefele.ch/fr/produkt/.../P-01319387/\nhttps://www.haefele.ch/fr/produkt/.../P-xxxxxxx/`}
|
||
rows={6}
|
||
disabled={haefeleSyncing}
|
||
/>
|
||
<div className={styles.syncActions}>
|
||
<button
|
||
type="button"
|
||
onClick={handleHaefeleSync}
|
||
disabled={haefeleSyncing}
|
||
className={styles.syncBtn}
|
||
>
|
||
<Icon name="sync" size={15} />
|
||
{haefeleSyncing ? 'Synchronisation Häfele en cours…' : 'Synchroniser URLs Häfele'}
|
||
</button>
|
||
{haefeleSyncResult && !haefeleSyncing && (
|
||
<span className={haefeleSyncResult.success ? styles.success : styles.error}>
|
||
{haefeleSyncResult.success
|
||
? `${haefeleSyncResult.itemsCount} produits synchronisés (${haefeleSyncResult.duration}s)`
|
||
: `Erreur: ${haefeleSyncResult.error}`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{(haefeleSyncing || haefeleLiveMessages.length > 0) && (
|
||
<div className={styles.liveSync}>
|
||
<div className={styles.progressWrap}>
|
||
<div className={styles.progressInfo}>
|
||
<span>{haefeleProgress.collection || (haefeleSyncing ? 'Initialisation…' : 'Terminé')}</span>
|
||
<span>
|
||
{haefeleSyncing
|
||
? `${haefeleProgressPercent}%`
|
||
: (haefeleSyncResult?.success ? '✓ Succès' : '✗ Échec')}
|
||
</span>
|
||
</div>
|
||
<div className={styles.progressBar}>
|
||
<div
|
||
className={`${styles.progressFill} ${!haefeleSyncing && haefeleSyncResult ? (haefeleSyncResult.success ? styles.progressDone : styles.progressError) : ''}`}
|
||
style={{ width: `${haefeleSyncing ? haefeleProgressPercent : 100}%` }}
|
||
/>
|
||
</div>
|
||
<div className={styles.progressStats}>
|
||
{haefeleProgress.total > 0 && `${haefeleProgress.current} / ${haefeleProgress.total} URLs`}
|
||
</div>
|
||
</div>
|
||
<div className={styles.liveLogsPanel}>
|
||
<div className={styles.liveLogsHeader}>
|
||
<span>Journal Häfele ({haefeleLiveMessages.length} lignes)</span>
|
||
{!haefeleSyncing && (
|
||
<button type="button" onClick={handleClearHaefeleLogs} className={styles.clearBtn}>
|
||
Effacer
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className={styles.liveLogsContent}>
|
||
{haefeleLiveMessages.map(({ msg, kind }, idx) => (
|
||
<div key={idx} className={`${styles.logLine} ${styles[`logLine_${kind}`]}`}>{msg}</div>
|
||
))}
|
||
<div ref={haefeleLogsEndRef} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className={styles.haefeleListHeader}>
|
||
<h3>Articles Häfele récupérés</h3>
|
||
<div className={styles.haefeleListActions}>
|
||
<input
|
||
type="text"
|
||
className={styles.searchInput}
|
||
value={haefeleSearch}
|
||
onChange={(e) => setHaefeleSearch(e.target.value)}
|
||
placeholder="Rechercher (nom, code, URL)"
|
||
/>
|
||
<button
|
||
type="button"
|
||
className={styles.detailsBtn}
|
||
onClick={() => refreshHaefeleMaterials()}
|
||
disabled={haefeleLoading}
|
||
>
|
||
<Icon name="sync" size={14} />
|
||
{haefeleLoading ? 'Chargement…' : 'Actualiser'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{haefeleError && <p className={styles.error}>{haefeleError}</p>}
|
||
<div className={styles.tableWrap}>
|
||
<table className={styles.logsTable}>
|
||
<thead>
|
||
<tr>
|
||
<th>Code</th>
|
||
<th>Nom</th>
|
||
<th>Prix</th>
|
||
<th>Épaisseur</th>
|
||
<th>Dernière sync</th>
|
||
<th>URL</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{haefeleMaterials.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={6} className={styles.empty}>
|
||
{haefeleLoading ? 'Chargement…' : 'Aucun article Häfele synchronisé.'}
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
haefeleMaterials.map((item) => (
|
||
<tr key={item.id}>
|
||
<td>{item.formex_code || '—'}</td>
|
||
<td>{item.name || '—'}</td>
|
||
<td>{item.price_chf != null ? `${Number(item.price_chf).toFixed(2)} CHF` : '—'}</td>
|
||
<td>{item.thickness_mm != null ? `${item.thickness_mm} mm` : '—'}</td>
|
||
<td>{item.last_synced_at ? new Date(item.last_synced_at).toLocaleString('fr-CH') : '—'}</td>
|
||
<td>
|
||
{item.formex_url ? (
|
||
<a href={item.formex_url} target="_blank" rel="noreferrer" className={styles.downloadLink}>
|
||
Voir page
|
||
</a>
|
||
) : '—'}
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Orders Section */}
|
||
{!loading && !error && (
|
||
<section className={styles.section}>
|
||
<h2>Commandes</h2>
|
||
<div className={styles.tableWrap}>
|
||
<table className={styles.table}>
|
||
<thead>
|
||
<tr>
|
||
<th>ID</th>
|
||
<th>Date</th>
|
||
<th>Total CHF</th>
|
||
<th>Nom</th>
|
||
<th>Email</th>
|
||
<th>Téléphone</th>
|
||
<th>Remarques</th>
|
||
<th>Fournisseurs</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{orders.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={8} className={styles.empty}>
|
||
Aucune commande.
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
orders.flatMap((o) => {
|
||
const isExpanded = expandedOrderId === o.id;
|
||
const vouchersData = supplierVouchersByOrder[o.id];
|
||
const supplierRows = vouchersData?.suppliers || [];
|
||
const isLoadingVouchers = !!voucherLoadingByOrder[o.id];
|
||
const vouchersError = voucherErrorByOrder[o.id];
|
||
const planExports = planExportsByOrder[o.id] || [];
|
||
const isLoadingPlanExports = !!planExportsLoadingByOrder[o.id];
|
||
const planExportsError = planExportsErrorByOrder[o.id];
|
||
const isGeneratingPlanExports = !!planExportsGeneratingByOrder[o.id];
|
||
const productionDetails = productionDetailsByOrder[o.id];
|
||
const isProductionLoading = !!productionLoadingByOrder[o.id];
|
||
const productionError = productionErrorByOrder[o.id];
|
||
const isProductionVisible = !!productionVisibleByOrder[o.id];
|
||
|
||
const summaryRow = (
|
||
<tr key={o.id}>
|
||
<td>{o.id}</td>
|
||
<td>{o.created_at ? new Date(o.created_at).toLocaleString('fr-CH') : '—'}</td>
|
||
<td>{o.total_chf != null ? Number(o.total_chf).toLocaleString('fr-CH') : '—'}</td>
|
||
<td>{o.name || '—'}</td>
|
||
<td>{o.email || '—'}</td>
|
||
<td>{o.phone || '—'}</td>
|
||
<td>{o.notes || '—'}</td>
|
||
<td>
|
||
<button
|
||
type="button"
|
||
className={styles.detailsBtn}
|
||
onClick={() => handleToggleSupplierDetails(o.id)}
|
||
>
|
||
<Icon name="eye" size={14} />
|
||
{isExpanded ? 'Masquer' : 'Voir éléments'}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
|
||
if (!isExpanded) return [summaryRow];
|
||
|
||
return [
|
||
summaryRow,
|
||
<tr key={`details-${o.id}`} className={styles.detailsRow}>
|
||
<td colSpan={8}>
|
||
<div className={styles.supplierDetails}>
|
||
<div className={styles.detailsSection}>
|
||
<h3 className={styles.detailsSectionTitle}>Exports plans / débitage</h3>
|
||
<div className={styles.planExportActions}>
|
||
<button
|
||
type="button"
|
||
className={styles.generateBtn}
|
||
onClick={() => handleGeneratePlanExports(o.id)}
|
||
disabled={isGeneratingPlanExports}
|
||
>
|
||
<Icon name="file" size={14} />
|
||
{isGeneratingPlanExports ? 'Génération…' : 'Générer les exports'}
|
||
</button>
|
||
</div>
|
||
{isLoadingPlanExports && (
|
||
<p className={styles.muted}>Chargement des exports enregistrés…</p>
|
||
)}
|
||
{!isLoadingPlanExports && planExportsError && (
|
||
<p className={styles.error}>{planExportsError}</p>
|
||
)}
|
||
{!isLoadingPlanExports && !planExportsError && planExports.length === 0 && (
|
||
<p className={styles.muted}>Aucun export enregistré pour cette commande.</p>
|
||
)}
|
||
{!isLoadingPlanExports && !planExportsError && planExports.length > 0 && (
|
||
<table className={styles.planExportsTable}>
|
||
<thead>
|
||
<tr>
|
||
<th>Date</th>
|
||
<th>Type</th>
|
||
<th>Fichier</th>
|
||
<th>Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{planExports.map((item) => (
|
||
<tr key={item.id}>
|
||
<td>{item.created_at ? new Date(item.created_at).toLocaleString('fr-CH') : '—'}</td>
|
||
<td>{item.export_type || '—'}</td>
|
||
<td>{item.file_name || '—'}</td>
|
||
<td>
|
||
<a
|
||
href={getOrderPlanExportViewUrl(o.id, item.id)}
|
||
className={styles.downloadLink}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
<Icon name="eye" size={13} />
|
||
Voir
|
||
</a>
|
||
{' · '}
|
||
<a
|
||
href={getOrderPlanExportDownloadUrl(o.id, item.id)}
|
||
className={styles.downloadLink}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
<Icon name="download" size={13} />
|
||
Télécharger
|
||
</a>
|
||
{' · '}
|
||
<button
|
||
type="button"
|
||
className={styles.deleteLink}
|
||
disabled={!!planExportDeletingById[item.id]}
|
||
onClick={() => handleDeletePlanExport(o.id, item.id)}
|
||
>
|
||
<Icon name="trash" size={13} />
|
||
{planExportDeletingById[item.id] ? 'Suppression…' : 'Supprimer'}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
|
||
<div className={styles.detailsSection}>
|
||
<h3 className={styles.detailsSectionTitle}>Dossier technique complet</h3>
|
||
<div className={styles.planExportActions}>
|
||
<button
|
||
type="button"
|
||
className={styles.generateBtn}
|
||
onClick={() => handleToggleProductionDetails(o.id)}
|
||
>
|
||
<Icon name="blueprint" size={14} />
|
||
{isProductionVisible ? 'Masquer le dossier' : 'Afficher le dossier'}
|
||
</button>
|
||
</div>
|
||
|
||
{isProductionVisible && isProductionLoading && (
|
||
<p className={styles.muted}>Génération du dossier technique…</p>
|
||
)}
|
||
{isProductionVisible && !isProductionLoading && productionError && (
|
||
<p className={styles.error}>{productionError}</p>
|
||
)}
|
||
{isProductionVisible && !isProductionLoading && !productionError && productionDetails && (
|
||
<div className={styles.productionWrap}>
|
||
<div className={styles.productionSummary}>
|
||
<span>{productionDetails.config.widthMm} × {productionDetails.config.heightMm} × {productionDetails.config.depthMm} mm</span>
|
||
<span>•</span>
|
||
<span>{productionDetails.config.installationType === 'niche' ? 'Encastrée' : 'Libre'}</span>
|
||
<span>•</span>
|
||
<span>{productionDetails.config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</span>
|
||
<span>•</span>
|
||
<span>{productionDetails.config.moduleCount} modules</span>
|
||
</div>
|
||
|
||
<div className={styles.planGrid}>
|
||
<div className={styles.planCard}>
|
||
<h4>Vue de face</h4>
|
||
<div dangerouslySetInnerHTML={{ __html: productionDetails.plans?.assembly || '' }} />
|
||
</div>
|
||
<div className={styles.planCard}>
|
||
<h4>Vue de côté</h4>
|
||
<div dangerouslySetInnerHTML={{ __html: productionDetails.plans?.side || '' }} />
|
||
</div>
|
||
<div className={styles.planCard}>
|
||
<h4>Vue de dessus</h4>
|
||
<div dangerouslySetInnerHTML={{ __html: productionDetails.plans?.top || '' }} />
|
||
</div>
|
||
<div className={styles.planCard}>
|
||
<h4>Détail usinage</h4>
|
||
<div dangerouslySetInnerHTML={{ __html: productionDetails.plans?.detail || '' }} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.productionTables}>
|
||
<h4>Liste de découpage</h4>
|
||
<table className={styles.planExportsTable}>
|
||
<thead>
|
||
<tr>
|
||
<th>Pièce</th>
|
||
<th>L × H × Ép.</th>
|
||
<th>Qté</th>
|
||
<th>Finition</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(productionDetails.cuttingList?.pieces || []).map((piece) => (
|
||
<tr key={piece.id}>
|
||
<td>{piece.description}</td>
|
||
<td>{piece.widthMm} × {piece.heightMm} × {piece.thicknessMm} mm</td>
|
||
<td>{piece.quantity}</td>
|
||
<td>{piece.finishLabel || '—'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className={styles.productionTables}>
|
||
<h4>Colisage</h4>
|
||
<table className={styles.planExportsTable}>
|
||
<thead>
|
||
<tr>
|
||
<th>Colis</th>
|
||
<th>Niveau</th>
|
||
<th>Poids</th>
|
||
<th>Pièces</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(productionDetails.packing?.boxes || []).map((box) => (
|
||
<tr key={box.id}>
|
||
<td>{box.id}</td>
|
||
<td>{box.level}</td>
|
||
<td>{Number(box.totalWeightKg || 0).toFixed(1)} kg</td>
|
||
<td>{box.pieces?.length || 0}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className={styles.detailsSection}>
|
||
<h3 className={styles.detailsSectionTitle}>Éléments envoyés fournisseurs</h3>
|
||
{isLoadingVouchers && (
|
||
<p className={styles.muted}>Chargement des éléments fournisseurs…</p>
|
||
)}
|
||
{!isLoadingVouchers && vouchersError && (
|
||
<p className={styles.error}>{vouchersError}</p>
|
||
)}
|
||
{!isLoadingVouchers && !vouchersError && supplierRows.length === 0 && (
|
||
<p className={styles.muted}>Aucun élément fournisseur résolu pour cette commande.</p>
|
||
)}
|
||
{!isLoadingVouchers && !vouchersError && supplierRows.length > 0 && (
|
||
<div className={styles.supplierGrid}>
|
||
{supplierRows.map((supplier) => (
|
||
<div key={supplier.supplierId} className={styles.supplierCard}>
|
||
<div className={styles.supplierHeader}>
|
||
<strong>{supplier.supplierName}</strong>
|
||
<span>{supplier.supplierEmail}</span>
|
||
</div>
|
||
<table className={styles.supplierItemsTable}>
|
||
<thead>
|
||
<tr>
|
||
<th>Code</th>
|
||
<th>Désignation</th>
|
||
<th>Qté</th>
|
||
<th>PU</th>
|
||
<th>Total</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{supplier.items.map((item, idx) => (
|
||
<tr key={`${supplier.supplierId}-${idx}`}>
|
||
<td>{item.code || '—'}</td>
|
||
<td>{item.name || '—'}</td>
|
||
<td>{item.qty}</td>
|
||
<td>{item.unitPrice != null ? `${Number(item.unitPrice).toFixed(2)} CHF` : '—'}</td>
|
||
<td>
|
||
{item.unitPrice != null
|
||
? `${(Number(item.unitPrice) * Number(item.qty || 0)).toFixed(2)} CHF`
|
||
: '—'}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr>
|
||
<td colSpan={4}>Sous-total</td>
|
||
<td>{Number(supplier.subtotal || 0).toFixed(2)} CHF</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
];
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|