Files
apex/client/src/pages/Admin.jsx
T
lejayandCursor 6c0d7e47b8 v2.0
Co-authored-by: Cursor <[email protected]>
2026-05-28 07:20:38 +02:00

927 lines
42 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}