+720
-34
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user