Co-authored-by: Cursor <[email protected]>
This commit is contained in:
lejay
2026-05-28 07:20:38 +02:00
co-authored by Cursor
parent 6923c319a1
commit 6c0d7e47b8
81 changed files with 21780 additions and 2986 deletions
+720 -34
View File
@@ -1,7 +1,21 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { fetchOrders, fetchSyncLogs, getFormexSyncStreamUrl } from '../api/client';
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() {
@@ -10,6 +24,19 @@ export default function Admin() {
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([]);
@@ -19,18 +46,32 @@ export default function Admin() {
// 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)])
.then(([ordersData, logsData]) => {
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));
@@ -43,6 +84,17 @@ export default function Admin() {
}
}, [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 });
@@ -54,14 +106,23 @@ export default function Admin() {
setLiveMessages([]);
setProgress({ current: 0, total: 0, collection: '' });
const eventSource = new EventSource(getFormexSyncStreamUrl());
const eventSource = new EventSource(getFormexSyncStreamUrl(forceOverride));
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'log') {
setLiveMessages(prev => [...prev, data.message]);
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,
@@ -72,9 +133,9 @@ export default function Admin() {
setSyncResult(data.result);
setSyncing(false);
eventSource.close();
// Refresh sync logs
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();
@@ -85,17 +146,211 @@ export default function Admin() {
};
eventSource.onerror = () => {
if (syncing) {
setSyncResult({ success: false, error: 'Connexion perdue' });
setSyncing(false);
}
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;
@@ -104,6 +359,7 @@ export default function Admin() {
<div className={styles.header}>
<h1>Administration</h1>
<button type="button" onClick={handleLogout} className={styles.logout}>
<Icon name="logout" size={15} />
Déconnexion
</button>
</div>
@@ -121,8 +377,18 @@ export default function Admin() {
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
@@ -132,28 +398,36 @@ export default function Admin() {
)}
</div>
{/* Progress bar and live logs during sync */}
{syncing && (
{/* 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 || 'Initialisation…'}</span>
<span>{progressPercent}%</span>
<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}
style={{ width: `${progressPercent}%` }}
<div
className={`${styles.progressFill} ${!syncing && syncResult ? (syncResult.success ? styles.progressDone : styles.progressError) : ''}`}
style={{ width: `${syncing ? progressPercent : 100}%` }}
/>
</div>
<div className={styles.progressStats}>
{progress.current} / {progress.total} collections
{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, idx) => (
<div key={idx} className={styles.logLine}>{msg}</div>
{liveMessages.map(({ msg, kind }, idx) => (
<div key={idx} className={`${styles.logLine} ${styles[`logLine_${kind}`]}`}>{msg}</div>
))}
<div ref={logsEndRef} />
</div>
@@ -161,7 +435,7 @@ export default function Admin() {
</div>
)}
{syncLogs.length > 0 && !syncing && (
{syncLogs.length > 0 && !syncing && liveMessages.length === 0 && (
<div className={styles.logsWrap}>
<h3>Historique des synchronisations</h3>
<table className={styles.logsTable}>
@@ -193,6 +467,146 @@ export default function Admin() {
</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}>
@@ -208,27 +622,299 @@ export default function Admin() {
<th>Email</th>
<th>Téléphone</th>
<th>Remarques</th>
<th>Fournisseurs</th>
</tr>
</thead>
<tbody>
{orders.length === 0 ? (
<tr>
<td colSpan={7} className={styles.empty}>
<td colSpan={8} className={styles.empty}>
Aucune commande.
</td>
</tr>
) : (
orders.map((o) => (
<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>
</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>