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
+1 -11
View File
@@ -48,7 +48,6 @@
"version": "7.28.6",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -300,7 +299,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -903,7 +901,6 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz",
"integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.26.7",
@@ -1397,7 +1394,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1422,7 +1418,6 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz",
"integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
@@ -1537,7 +1532,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2013,7 +2007,6 @@
"node_modules/react": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -2036,7 +2029,6 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -2262,8 +2254,7 @@
"version": "0.169.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz",
"integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/three-mesh-bvh": {
"version": "0.7.8",
@@ -2422,7 +2413,6 @@
"version": "5.4.21",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
+2
View File
@@ -7,6 +7,7 @@ import Configurator from './pages/Configurator';
import Login from './pages/Login';
import Admin from './pages/Admin';
import Materials from './pages/Materials';
import Settings from './pages/Settings';
export default function App() {
return (
@@ -19,6 +20,7 @@ export default function App() {
<Route path="/configurateur" element={<ProtectedRoute><Configurator /></ProtectedRoute>} />
<Route path="/materiaux" element={<ProtectedRoute><Materials /></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute><Admin /></ProtectedRoute>} />
<Route path="/parametres" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
</Routes>
</Layout>
</AuthProvider>
+169 -3
View File
@@ -30,6 +30,83 @@ export async function fetchSettings() {
return res.json();
}
export async function updateSettings(data) {
const res = await fetch(`${API_BASE}/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// Engineering rules CRUD
export async function fetchEngineeringRules() {
const res = await fetch(`${API_BASE}/engineering-rules`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function createEngineeringRule(data) {
const res = await fetch(`${API_BASE}/engineering-rules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function updateEngineeringRule(id, data) {
const res = await fetch(`${API_BASE}/engineering-rules/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function deleteEngineeringRule(id) {
const res = await fetch(`${API_BASE}/engineering-rules/${id}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// Suppliers CRUD
export async function fetchSuppliers() {
const res = await fetch(`${API_BASE}/suppliers`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function createSupplier(data) {
const res = await fetch(`${API_BASE}/suppliers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function updateSupplier(id, data) {
const res = await fetch(`${API_BASE}/suppliers/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function deleteSupplier(id) {
const res = await fetch(`${API_BASE}/suppliers/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
}
export async function calculatePrice(config) {
const res = await fetch(`${API_BASE}/price`, {
method: 'POST',
@@ -46,6 +123,57 @@ export async function fetchOrders() {
return res.json();
}
export async function fetchOrderSupplierVouchers(orderId) {
const res = await fetch(`${API_BASE}/orders/${orderId}/supplier-vouchers`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchOrderPlanExports(orderId) {
const res = await fetch(`${API_BASE}/orders/${orderId}/plan-exports`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function saveOrderPlanExport(orderId, payload) {
const res = await fetch(`${API_BASE}/orders/${orderId}/plan-exports`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function generateOrderPlanExports(orderId) {
const res = await fetch(`${API_BASE}/orders/${orderId}/plan-exports/generate`, {
method: 'POST'
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchOrderProductionDetails(orderId) {
const res = await fetch(`${API_BASE}/orders/${orderId}/production-details`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export function getOrderPlanExportDownloadUrl(orderId, exportId) {
return `${API_BASE}/orders/${orderId}/plan-exports/${exportId}/download`;
}
export function getOrderPlanExportViewUrl(orderId, exportId) {
return `${API_BASE}/orders/${orderId}/plan-exports/${exportId}/download?disposition=inline`;
}
export async function deleteOrderPlanExport(orderId, exportId) {
const res = await fetch(`${API_BASE}/orders/${orderId}/plan-exports/${exportId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error(await res.text());
}
export async function submitOrder(order) {
const res = await fetch(`${API_BASE}/orders`, {
method: 'POST',
@@ -70,8 +198,27 @@ export async function triggerFormexSync() {
/**
* Get the SSE stream URL for real-time sync progress
*/
export function getFormexSyncStreamUrl() {
return `${API_BASE}/sync/formex/stream`;
export function getFormexSyncStreamUrl(force = false) {
const url = `${API_BASE}/sync/formex/stream`;
return force ? `${url}?force=1` : url;
}
export function getHaefeleSyncStreamUrl(urls = []) {
const cleaned = (urls || []).map((v) => (typeof v === 'string' ? v.trim() : '')).filter(Boolean);
const params = new URLSearchParams();
for (const url of cleaned) params.append('url', url);
const query = params.toString();
return query ? `${API_BASE}/sync/haefele/stream?${query}` : `${API_BASE}/sync/haefele/stream`;
}
export async function triggerHaefeleSync(payload) {
const res = await fetch(`${API_BASE}/sync/haefele`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload || {})
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchSyncLogs(limit = 20) {
@@ -86,13 +233,26 @@ export async function fetchSyncStatus() {
return res.json();
}
export async function fetchHaefeleMaterials({ search = '', active = null, page = 1, limit = 25 } = {}) {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (active !== null) params.set('active', active ? '1' : '0');
params.set('page', String(page));
params.set('limit', String(limit));
const res = await fetch(`${API_BASE}/haefele/materials?${params.toString()}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// Formex Materials CRUD
export async function fetchFormexMaterials({ search = '', collection = '', category = '', active = null, page = 1, limit = 25 } = {}) {
export async function fetchFormexMaterials({ search = '', collection = '', category = '', supplier = '', active = null, page = 1, limit = 25 } = {}) {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (collection) params.set('collection', collection);
if (category) params.set('category', category);
if (supplier) params.set('supplier', supplier);
if (active !== null) params.set('active', active ? '1' : '0');
params.set('page', String(page));
params.set('limit', String(limit));
@@ -102,6 +262,12 @@ export async function fetchFormexMaterials({ search = '', collection = '', categ
return res.json();
}
export async function fetchMaterialSuppliers() {
const res = await fetch(`${API_BASE}/formex/suppliers`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchFormexMaterial(id) {
const res = await fetch(`${API_BASE}/formex/materials/${id}`);
if (!res.ok) throw new Error(await res.text());
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
.wrap {
position: relative;
width: 100%;
height: 360px;
border-radius: 8px;
overflow: hidden;
background: #f8fafc;
touch-action: none;
}
.axisBar {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
z-index: 2;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
pointer-events: auto;
}
.axisLabel {
font-size: 0.65rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-right: 2px;
}
.axisBtn {
padding: 4px 8px;
border: 1px solid #cbd5e1;
border-radius: 5px;
background: rgba(255, 255, 255, 0.92);
color: #334155;
font-size: 0.72rem;
font-weight: 600;
cursor: pointer;
font-variant-numeric: tabular-nums;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.axisBtn:hover {
background: #fff;
border-color: #94a3b8;
color: #0f172a;
}
.axisBtn:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 1px;
}
+202 -63
View File
@@ -7,6 +7,33 @@ import styles from './ColorPickerModal.module.css';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const PRIORITY_STORAGE_KEY = 'apex-ai-rec-priorities';
const DEFAULT_PRIORITIES = { price: 3, quality: 3, durability: 3, aesthetics: 3 };
function loadStoredPriorities() {
try {
const raw = localStorage.getItem(PRIORITY_STORAGE_KEY);
if (!raw) return { ...DEFAULT_PRIORITIES };
const p = JSON.parse(raw);
return {
price: Math.min(5, Math.max(1, Number(p.price) || DEFAULT_PRIORITIES.price)),
quality: Math.min(5, Math.max(1, Number(p.quality) || DEFAULT_PRIORITIES.quality)),
durability: Math.min(5, Math.max(1, Number(p.durability) || DEFAULT_PRIORITIES.durability)),
aesthetics: Math.min(5, Math.max(1, Number(p.aesthetics) || DEFAULT_PRIORITIES.aesthetics))
};
} catch {
return { ...DEFAULT_PRIORITIES };
}
}
const PRIORITY_LABELS = {
price: 'Prix & rapport qualité-prix',
quality: 'Qualité & finition',
durability: 'Durabilité & entretien',
aesthetics: 'Esthétique & harmonie'
};
const ELEMENT_LABELS = {
plintheBas: 'Plinthe bas',
fileur: 'Fileur',
@@ -42,8 +69,17 @@ export default function ColorPickerModal({
const [hoveredFinish, setHoveredFinish] = useState(null);
const [aiRecs, setAiRecs] = useState([]);
const [aiLoading, setAiLoading] = useState(false);
const [priorities, setPriorities] = useState(loadStoredPriorities);
const tooltipRef = useRef(null);
useEffect(() => {
try {
localStorage.setItem(PRIORITY_STORAGE_KEY, JSON.stringify(priorities));
} catch {
/* ignore */
}
}, [priorities]);
useEffect(() => {
if (isOpen) {
setSelectedFinish(currentFinish);
@@ -51,42 +87,69 @@ export default function ColorPickerModal({
setSelectedCollection('all');
setHoveredFinish(null);
setAiRecs([]);
// Fetch AI recommendations
if (config && finishes.length > 0) {
setAiLoading(true);
const controller = new AbortController();
fetch(`${API_BASE}/api/ai/recommend-materials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config: {
width: config.totalWidth,
height: config.height,
depth: config.depth,
columns: config.modules?.map(m => ({
type: m.type || m.moduleCode,
width: m.width
})),
materialName: config.finishLabel || null,
doorType: config.doorType
},
finishes: finishes.map(f => ({ id: f.id, name: f.name || f.label, collection: f.collection, code: f.code }))
}),
signal: controller.signal
})
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.recommendations) {
setAiRecs(data.recommendations);
}
})
.catch(() => {})
.finally(() => setAiLoading(false));
return () => controller.abort();
}
}
}, [isOpen, currentFinish]);
useEffect(() => {
if (!isOpen) {
setAiLoading(false);
return undefined;
}
if (!config || finishes.length === 0) {
return undefined;
}
setAiLoading(true);
const controller = new AbortController();
const t = setTimeout(() => {
fetch(`${API_BASE}/api/ai/recommend-materials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
priorities,
config: {
width: config.totalWidth,
height: config.height,
depth: config.depth,
installationType: config.installationType,
columns: config.modules?.map(m => ({
type: m.type || m.moduleCode,
width: m.width
})),
materialName: config.finishLabel || null,
doorType: config.doorType
},
finishes: finishes.map(f => ({
id: f.id,
name: f.name || f.label,
collection: f.collection,
code: f.code,
price_chf: f.price_chf != null ? Number(f.price_chf) : undefined
}))
}),
signal: controller.signal
})
.then(r => (r.ok ? r.json() : null))
.then(data => {
if (data?.recommendations) {
setAiRecs(data.recommendations);
}
})
.catch(() => {})
.finally(() => {
if (!controller.signal.aborted) setAiLoading(false);
});
}, 320);
return () => {
clearTimeout(t);
controller.abort();
};
}, [isOpen, currentFinish, priorities, config, finishes]);
const setPriority = useCallback((key, value) => {
const n = Math.min(5, Math.max(1, Number(value) || 3));
setPriorities(prev => ({ ...prev, [key]: n }));
}, []);
const collections = useMemo(() => {
const cols = [...new Set(finishes.map(f => f.collection).filter(Boolean))];
return cols.sort();
@@ -169,12 +232,41 @@ export default function ColorPickerModal({
{/* Swatch grid */}
<div className={styles.swatchArea}>
{/* AI Recommendations */}
{(aiRecs.length > 0 || aiLoading) && (
{config && finishes.length > 0 && (
<div className={styles.priorityPanel}>
<div className={styles.priorityTitleRow}>
<span className={styles.aiBadge}>IA</span>
<span className={styles.priorityTitle}>Priorit&eacute;s de recommandation</span>
</div>
<p className={styles.priorityHint}>
1 = peu important, 5 = tr&egrave;s important. L&apos;IA pond&egrave;re prix, qualit&eacute;, durabilit&eacute; et style
et explique chaque suggestion en fonction de vos r&eacute;glages.
</p>
<div className={styles.priorityGrid}>
{(['price', 'quality', 'durability', 'aesthetics']).map((key) => (
<label key={key} className={styles.priorityRow}>
<span className={styles.priorityLabel}>{PRIORITY_LABELS[key]}</span>
<input
type="range"
className={styles.priorityRange}
min={1}
max={5}
step={1}
value={priorities[key]}
onChange={(e) => setPriority(key, e.target.value)}
/>
<span className={styles.priorityValue}>{priorities[key]}</span>
</label>
))}
</div>
</div>
)}
{(aiRecs.length > 0 || aiLoading) && config && finishes.length > 0 && (
<div className={styles.aiSection}>
<div className={styles.aiHeader}>
<span className={styles.aiBadge}>IA</span>
<span className={styles.aiLabel}>Recommandations</span>
<span className={styles.aiLabel}>Suggestions (justifi&eacute;es selon vos priorit&eacute;s)</span>
</div>
{aiLoading ? (
<div className={styles.aiShimmer}>
@@ -184,28 +276,37 @@ export default function ColorPickerModal({
</div>
) : (
<div className={styles.aiSwatches}>
{aiRecs.map(rec => {
{aiRecs.map((rec) => {
const finish = finishes.find(f => f.id === rec.finishId);
if (!finish) return null;
const isSelected = selectedFinish?.id === finish.id;
const summary = rec.summary || rec.reason || '';
const detail = {
summary,
criteria: rec.criteria || {}
};
return (
<button
key={`ai-${finish.id}`}
type="button"
className={`${styles.swatch} ${isSelected ? styles.swatchSelected : ''} ${styles.aiSwatch}`}
onClick={() => setSelectedFinish(finish)}
onMouseEnter={() => setHoveredFinish({ ...finish, aiReason: rec.reason })}
onMouseLeave={() => setHoveredFinish(null)}
title={rec.reason}
>
{finish.image_url ? (
<img src={finish.image_url} alt="" className={styles.swatchImg} loading="lazy" />
) : (
<div className={styles.swatchColor} style={{ backgroundColor: finish.hexColor || '#ccc' }} />
)}
{isSelected && <span className={styles.swatchCheck}>✓</span>}
<span className={styles.aiSwatchBadge}>★</span>
</button>
<div key={`ai-${finish.id}`} className={styles.aiSwatchCard}>
<button
type="button"
className={`${styles.swatch} ${isSelected ? styles.swatchSelected : ''} ${styles.aiSwatch}`}
onClick={() => setSelectedFinish(finish)}
onMouseEnter={() => setHoveredFinish({ ...finish, aiDetail: detail })}
onMouseLeave={() => setHoveredFinish(null)}
title={summary}
>
{finish.image_url ? (
<img src={finish.image_url} alt="" className={styles.swatchImg} loading="lazy" />
) : (
<div className={styles.swatchColor} style={{ backgroundColor: finish.hexColor || '#ccc' }} />
)}
{isSelected && <span className={styles.swatchCheck}>&check;</span>}
<span className={styles.aiSwatchBadge}>&#9733;</span>
</button>
{summary ? (
<p className={styles.aiCardSummary}>{summary}</p>
) : null}
</div>
);
})}
</div>
@@ -253,17 +354,55 @@ export default function ColorPickerModal({
{/* Hover detail strip -- shows info of hovered swatch */}
{hoveredFinish && (
<div className={styles.hoverDetail} ref={tooltipRef}>
{hoveredFinish.image_url && (
<img src={hoveredFinish.image_url} alt="" className={styles.hoverImg} />
)}
<div className={styles.hoverInfo}>
<strong>{hoveredFinish.name || hoveredFinish.label}</strong>
{hoveredFinish.code && <span className={styles.hoverCode}>{hoveredFinish.code}</span>}
{hoveredFinish.collection && <span className={styles.hoverColl}>{hoveredFinish.collection}</span>}
{hoveredFinish.price_chf != null && (
<span className={styles.hoverPrice}>{hoveredFinish.price_chf.toFixed(2)} CHF</span>
<div className={styles.hoverDetailTop}>
{hoveredFinish.image_url && (
<img src={hoveredFinish.image_url} alt="" className={styles.hoverImg} />
)}
<div className={styles.hoverInfo}>
<strong>{hoveredFinish.name || hoveredFinish.label}</strong>
{hoveredFinish.code && <span className={styles.hoverCode}>{hoveredFinish.code}</span>}
{hoveredFinish.collection && <span className={styles.hoverColl}>{hoveredFinish.collection}</span>}
{hoveredFinish.price_chf != null && (
<span className={styles.hoverPrice}>{hoveredFinish.price_chf.toFixed(2)} CHF</span>
)}
</div>
</div>
{hoveredFinish.aiDetail?.summary && (
<div className={styles.aiExplain}>
<p className={styles.aiExplainLead}>{hoveredFinish.aiDetail.summary}</p>
{(hoveredFinish.aiDetail.criteria?.price ||
hoveredFinish.aiDetail.criteria?.quality ||
hoveredFinish.aiDetail.criteria?.durability ||
hoveredFinish.aiDetail.criteria?.aesthetics) && (
<dl className={styles.aiExplainDl}>
{hoveredFinish.aiDetail.criteria.price ? (
<>
<dt>Prix</dt>
<dd>{hoveredFinish.aiDetail.criteria.price}</dd>
</>
) : null}
{hoveredFinish.aiDetail.criteria.quality ? (
<>
<dt>Qualit&eacute;</dt>
<dd>{hoveredFinish.aiDetail.criteria.quality}</dd>
</>
) : null}
{hoveredFinish.aiDetail.criteria.durability ? (
<>
<dt>Durabilit&eacute;</dt>
<dd>{hoveredFinish.aiDetail.criteria.durability}</dd>
</>
) : null}
{hoveredFinish.aiDetail.criteria.aesthetics ? (
<>
<dt>Style</dt>
<dd>{hoveredFinish.aiDetail.criteria.aesthetics}</dd>
</>
) : null}
</dl>
)}
</div>
)}
</div>
)}
</div>
@@ -220,8 +220,9 @@
/* --- Hover detail strip (bottom of swatch area) --- */
.hoverDetail {
display: flex;
align-items: center;
gap: 10px;
flex-direction: column;
align-items: stretch;
gap: 0;
padding: 8px 10px;
margin-top: 8px;
background: var(--bg-subtle);
@@ -230,6 +231,12 @@
flex-shrink: 0;
}
.hoverDetailTop {
display: flex;
align-items: center;
gap: 10px;
}
.hoverImg {
width: 40px;
height: 40px;
@@ -411,9 +418,11 @@
}
}
/* AI Recommendations section */
/* AI Recommendations section — compact strip (swatches use fixed size, not grid) */
.aiSection {
padding: 8px 12px;
align-self: flex-start;
max-width: 100%;
padding: 6px 10px;
margin-bottom: 8px;
background: linear-gradient(135deg, #f8f7ff 0%, #eef2ff 100%);
border-radius: 8px;
@@ -446,12 +455,17 @@
.aiSwatches {
display: flex;
gap: 6px;
gap: 10px;
flex-wrap: wrap;
align-items: flex-start;
}
/* Override .swatch { width: 100% } — in flex layout that made each rec span the full row */
.aiSwatch {
position: relative;
width: 40px;
max-width: 40px;
flex: 0 0 40px;
border-color: rgba(79, 70, 229, 0.3) !important;
}
@@ -477,8 +491,8 @@
}
.aiShimmerDot {
width: 48px;
height: 48px;
width: 40px;
height: 40px;
border-radius: 6px;
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
background-size: 200% 100%;
@@ -489,3 +503,126 @@
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* IA — priorités utilisateur */
.priorityPanel {
padding: 8px 10px 10px;
margin-bottom: 8px;
background: #fafafa;
border-radius: 8px;
border: 1px solid var(--border-light);
align-self: flex-start;
width: 100%;
max-width: 100%;
}
.priorityTitleRow {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.priorityTitle {
font-size: 0.78rem;
font-weight: 600;
color: var(--text);
}
.priorityHint {
font-size: 0.7rem;
color: var(--text-muted);
margin: 0 0 8px;
line-height: 1.35;
}
.priorityGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 14px;
}
@media (max-width: 720px) {
.priorityGrid {
grid-template-columns: 1fr;
}
}
.priorityRow {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(60px, 1fr) 22px;
align-items: center;
gap: 6px;
font-size: 0.72rem;
color: var(--text);
}
.priorityLabel {
min-width: 0;
}
.priorityRange {
width: 100%;
min-width: 0;
accent-color: var(--action, #4f46e5);
}
.priorityValue {
font-weight: 600;
font-size: 0.75rem;
color: var(--action, #4f46e5);
text-align: right;
}
.aiSwatchCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
max-width: 152px;
}
.aiCardSummary {
margin: 0;
font-size: 0.65rem;
line-height: 1.3;
color: var(--text-muted);
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.aiExplain {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-light);
}
.aiExplainLead {
margin: 0 0 8px;
font-size: 0.78rem;
color: var(--text);
line-height: 1.4;
}
.aiExplainDl {
margin: 0;
display: grid;
grid-template-columns: minmax(52px, auto) 1fr;
gap: 4px 10px;
font-size: 0.72rem;
}
.aiExplainDl dt {
margin: 0;
font-weight: 600;
color: var(--text-muted);
}
.aiExplainDl dd {
margin: 0;
color: var(--text);
line-height: 1.35;
}
+570 -23
View File
@@ -2,10 +2,334 @@
* CuttingListTable - Display cutting list in a table format
* Shows pieces grouped by finish with dimensions and quantities
*/
import { useMemo } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import {
CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM,
CARCASS_FASTENERS,
CONNECTORS,
tourillonFaceDrillDepthMm
} from '@shared/manufacturing.js';
import Cnc3DViewer, { getCncPartsForPiece } from './Cnc3DViewer';
import styles from './CuttingListTable.module.css';
export default function CuttingListTable({ cuttingList, onExport }) {
const CNC_MODAL_STORAGE_KEY = 'apex.cnc3d.modal';
/** Must match server `generatePiecePlanSvg` in cuttingEngine.js (multi-view row layout). */
const PIECE_PLAN_LAYOUT = {
margin: 28,
viewW: 330,
viewH: 220,
panelGap: 24,
yA: 42
};
function getPieceDepthMm(piece, cuttingListConfigDepthMm) {
const t = piece?.elementType;
const width = piece?.widthMm;
const height = piece?.heightMm;
// For readability in the cutting list, "Profondeur" is the dimension along wardrobe depth.
// Depending on the panel type, this maps to either width or height.
const depthIsWidth = new Set(['cotes', 'cotesDoublage', 'fileur', 'habillageFinitionGauche', 'habillageFinitionDroite']);
const depthIsHeight = new Set(['dessus', 'fond', 'habillageFinitionDessus']);
if (depthIsWidth.has(t)) return width ?? null;
if (depthIsHeight.has(t)) return height ?? null;
const configDepth = Number(cuttingListConfigDepthMm);
if (Number.isFinite(configDepth) && configDepth > 0) {
if (Number(width) === configDepth) return width;
if (Number(height) === configDepth) return height;
}
return null;
}
/** Nombre d'exemplaires MPR pour une ligne (aligné sur le serveur). */
function countMprInstancesForPiece(piece) {
const qty = Math.max(1, Math.round(Number(piece?.quantity) || 1));
if (
piece?.elementType === 'cotes' &&
qty === 2 &&
piece.id === 'piece-cotes' &&
String(piece.description || '').includes('gauche et droit')
) {
return 2;
}
return qty;
}
function getPiecePlanPanelPercents(svgEl) {
let vbW = PIECE_PLAN_LAYOUT.margin + 3 * PIECE_PLAN_LAYOUT.viewW + 2 * PIECE_PLAN_LAYOUT.panelGap + PIECE_PLAN_LAYOUT.margin;
let vbH = 478;
if (svgEl) {
if (svgEl.viewBox && svgEl.viewBox.baseVal.width > 0) {
vbW = svgEl.viewBox.baseVal.width;
vbH = svgEl.viewBox.baseVal.height;
} else if (svgEl.width && svgEl.width.baseVal) {
vbW = svgEl.width.baseVal.value;
vbH = svgEl.height?.baseVal?.value || vbH;
}
}
const { margin, viewW, panelGap, yA, viewH } = PIECE_PLAN_LAYOUT;
const xA = margin;
const xB = xA + viewW + panelGap;
const xC = xB + viewW + panelGap;
return {
vbW,
vbH,
panels: [
{ leftPct: (xA / vbW) * 100, widthPct: (viewW / vbW) * 100, topPct: (yA / vbH) * 100, heightPct: (viewH / vbH) * 100 },
{ leftPct: (xB / vbW) * 100, widthPct: (viewW / vbW) * 100, topPct: (yA / vbH) * 100, heightPct: (viewH / vbH) * 100 },
{ leftPct: (xC / vbW) * 100, widthPct: (viewW / vbW) * 100, topPct: (yA / vbH) * 100, heightPct: (viewH / vbH) * 100 }
]
};
}
export default function CuttingListTable({ cuttingList, onExportMprPiece, cncProgram = '', planRestoreKey = '' }) {
const [previewPiece, setPreviewPiece] = useState(null);
const [cncModal, setCncModal] = useState(null); // { piece, segments, instanceIndex }
const cncRestoreDone = useRef(false);
const [lightbox, setLightbox] = useState(null); // { views, index, title }
const piecePlanHostRef = useRef(null);
const [planPanelLayout, setPlanPanelLayout] = useState(() => getPiecePlanPanelPercents(null));
const [mprInstanceByPieceId, setMprInstanceByPieceId] = useState({});
const [mprExportingPieceId, setMprExportingPieceId] = useState(null);
function openPiecePlan(piece) {
if (!piece?.piecePlanSvg) return;
setPreviewPiece(piece);
}
function openCnc3d(piece) {
const segments = getCncPartsForPiece(cuttingList, piece, cncProgram);
if (!segments.length) return;
setCncModal({ piece, segments, instanceIndex: 0 });
}
async function handleExportMprRow(piece) {
if (!onExportMprPiece || !piece?.id) return;
const instChoice = mprInstanceByPieceId[piece.id] ?? 'all';
const instanceIndex = instChoice === 'all' ? null : Number(instChoice);
setMprExportingPieceId(piece.id);
try {
await onExportMprPiece(piece, instanceIndex);
} finally {
setMprExportingPieceId(null);
}
}
function closeCncModal() {
setCncModal(null);
try {
sessionStorage.removeItem(CNC_MODAL_STORAGE_KEY);
} catch {
// ignore
}
}
useEffect(() => {
if (!planRestoreKey || !cuttingList?.pieces?.length || !cncProgram) return;
if (cncRestoreDone.current) return;
cncRestoreDone.current = true;
try {
const raw = sessionStorage.getItem(CNC_MODAL_STORAGE_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (data.configKey !== planRestoreKey) {
sessionStorage.removeItem(CNC_MODAL_STORAGE_KEY);
return;
}
const piece = cuttingList.pieces.find((p) => p.id === data.pieceId);
if (!piece) return;
const segments = getCncPartsForPiece(cuttingList, piece, cncProgram);
if (!segments.length) return;
const idx = Math.min(Math.max(0, Number(data.instanceIndex) || 0), segments.length - 1);
setCncModal({ piece, segments, instanceIndex: idx });
} catch {
// ignore
}
}, [cuttingList, cncProgram, planRestoreKey]);
useEffect(() => {
if (!planRestoreKey || !cncModal) return;
try {
sessionStorage.setItem(
CNC_MODAL_STORAGE_KEY,
JSON.stringify({
pieceId: cncModal.piece.id,
instanceIndex: cncModal.instanceIndex,
configKey: planRestoreKey
})
);
} catch {
// ignore
}
}, [cncModal, planRestoreKey]);
function getPieceMaterialColor(piece) {
const explicit =
piece?.hexColor ||
piece?.colorHex ||
piece?.finishHexColor ||
piece?.finishColor ||
piece?.finish?.hexColor ||
piece?.finish?.colorHex;
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
const key = String(piece?.finishLabel || piece?.finishId || piece?.elementType || 'material');
let hash = 0;
for (let i = 0; i < key.length; i++) hash = ((hash << 5) - hash) + key.charCodeAt(i);
const palette = ['#c4a574', '#b89a6a', '#a98a5e', '#d4c4a4', '#b09060', '#8b7355'];
return palette[Math.abs(hash) % palette.length];
}
function buildManufacturingRows(piece) {
const m = piece?.machining || {};
const rows = [
['Dimensions finies', `${piece.widthMm} x ${piece.heightMm} x ${piece.thicknessMm} mm`],
['Dimensions brutes CNC', `${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm`],
['Chants', `${piece.edgeBanding?.sides || 0} côté(s) · épaisseur ${piece.edgeBanding?.thickness || 0} mm`]
];
if (m.system32) {
rows.push([
'System 32',
`Ø${m.system32.holeDiameter} · ${m.system32.throughHole ? 'traversant' : `prof. ${m.system32.holeDepth || 12} mm`} · pas ${m.system32.pitch || 32} mm · rangées ${m.system32.rows || 2}`
]);
}
if (m.hingeCups) {
rows.push([
'Cuvettes charnières',
`Ø${m.hingeCups.diameter} · prof. ${m.hingeCups.cupDepth} mm · positions ${m.hingeCups.positionsFromTop?.map(v => `${Math.round(v)}mm`).join(', ') || '-'}`
]);
}
if (m.carcassFasteners?.positionsAlongJointMm?.length) {
const cf = m.carcassFasteners;
const axes = (cf.positionsAlongJointMm || []).map((v) => `${Math.round(v)}`).join(', ');
const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset;
const nF = cf.positionsAlongJointMm.length;
const pat =
nF >= 3
? '3 ferrures/jonction (50 / milieu / 50)'
: nF === 2
? `2 ferrures/jonction (50/50, sans milieu si arête ≤ ${CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM} mm)`
: `${nF} ferrure(s)/jonction`;
rows.push([
'Ferrures corps (jonctions côtés/séparateurs)',
`${cf.jointCount || 1} jonctions · ${pat} · centres ${axes} mm · perçage ferrure ${cf.ferrureDepthAlongLengthMm ?? 46}/${cf.ferrureDepthInThicknessMm ?? 12} mm · +2 tourillons Ø${cf.dowelDiameter || 8}×${tourillonFaceDrillDepthMm(piece.thicknessMm)} (±${off} mm) par ferrure`
]);
}
if (m.connectorHoles) {
const edge = CONNECTORS.jointPatternEdge || m.connectorHoles.jointPattern;
const offMid = CONNECTORS.jointPatternMiddleTourillonOffsetFromIxMm ?? 30;
let patternNote = '';
const ixJ = m.connectorHoles.ixconnectJointsAlongDepth;
if (ixJ?.length && ['dessus', 'fond'].includes(piece.elementType)) {
patternNote = ` · Axes (bord avant profondeur): extrémités T@${edge?.tourillon1Axis}·IX@${edge?.ixconnectAxis}·T@${edge?.tourillon2Axis} · internes IX milieu + T ±${offMid} mm (${ixJ.length} jonction(s))`;
} else if (m.connectorHoles.jointPattern && edge) {
patternNote = ` · Pattern arête T@${edge.tourillon1Axis}·IX@${edge.ixconnectAxis}·T@${edge.tourillon2Axis} mm (depuis bord avant)`;
}
rows.push([
'Connecteurs Ixconnect',
`About Ø${m.connectorHoles.edgeHole?.diameter || 8} prof ${m.connectorHoles.edgeHole?.depth || 45} mm · Face Ø${m.connectorHoles.faceHole?.diameter || 8} prof ${m.connectorHoles.faceHole?.depth || 12} mm${patternNote}`
]);
}
if (m.groove) {
const groovePos = m.groove.axisFromPanelEdge || m.groove.axisFromBackEdge || 16;
rows.push([
'Rainure',
`${m.groove.width || 9} x ${m.groove.depth || 10.5} mm · position à ${groovePos} mm du bord`
]);
}
if (m.fullHeightHandleGroove) {
const off = m.fullHeightHandleGroove.topBottomOffsetMm ?? 2;
const gw = m.fullHeightHandleGroove.grooveWidthMm ?? 9;
const gd = m.fullHeightHandleGroove.grooveDepthMm ?? 10.5;
const useful = Math.max(0, (piece.heightMm || 0) - 2 * off);
rows.push([
'Rainure poignée TH',
`${gw} × ${gd} mm (larg. × prof.) · retraits ${off} mm haut/bas · longueur utile rainure ${Math.round(useful)} mm`
]);
}
if (m.finishConnectors) {
const fc = m.finishConnectors;
rows.push([
'SC 8/25 (finition)',
`Face Ø${fc.faceHole?.diameter || 8} · prof. ${fc.faceHole?.depth || 12.5} mm · axes arrière ${fc.axes?.back ?? 30} / avant ${fc.axes?.front ?? 43} mm (depuis face intérieure)`
]);
}
if (m.reinforcementFixing) {
const rf = m.reinforcementFixing;
const ix = rf.ixconnectAxisFromTopMm != null ? ` · Ixconnect ${rf.connectorModel || 'SC 8/60'} axe ${rf.ixconnectAxisFromTopMm} mm (depuis haut)` : '';
const axes = rf.tourillonAxesFromTopMm || rf.tourillonAxes || [];
const sides = rf.sideCount || 2;
rows.push([
'Fixation renfort',
`${sides} chants extrémité · par chant: ${rf.connectorModel || 'SC 8/60'}${ix} + ${rf.tourillonsPerSide || rf.tourillonCount || 2} tourillons Ø${rf.tourillonDiameter || 8} · axes ${axes.join(', ')} mm`
]);
}
return rows;
}
function openViewLightbox(piece, index = 0) {
const views = Array.isArray(piece?.piecePlanViews) ? piece.piecePlanViews : [];
if (!views.length) return;
setLightbox({ views, index, title: piece.description });
}
function shiftLightbox(step) {
if (!lightbox) return;
const total = lightbox.views.length;
const next = (lightbox.index + step + total) % total;
setLightbox({ ...lightbox, index: next });
}
useEffect(() => {
if (!lightbox) return undefined;
const onKeyDown = (event) => {
if (event.key === 'Escape') {
setLightbox(null);
} else if (event.key === 'ArrowRight') {
setLightbox((prev) => {
if (!prev) return prev;
const total = prev.views.length;
return { ...prev, index: (prev.index + 1) % total };
});
} else if (event.key === 'ArrowLeft') {
setLightbox((prev) => {
if (!prev) return prev;
const total = prev.views.length;
return { ...prev, index: (prev.index - 1 + total) % total };
});
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [lightbox]);
useLayoutEffect(() => {
if (!previewPiece?.piecePlanSvg) return undefined;
const host = piecePlanHostRef.current;
if (!host) return undefined;
const sync = () => {
const svg = host.querySelector('svg');
setPlanPanelLayout(getPiecePlanPanelPercents(svg));
};
sync();
const ro = new ResizeObserver(sync);
ro.observe(host);
return () => ro.disconnect();
}, [previewPiece]);
if (!cuttingList || !cuttingList.pieces) {
return (
<div className={styles.empty}>
@@ -41,7 +365,7 @@ export default function CuttingListTable({ cuttingList, onExport }) {
</div>
</div>
{/* Tables by finish */}
<div className={styles.tableScrollArea}>
{byFinish.map((group, groupIndex) => (
<div key={groupIndex} className={styles.finishGroup}>
<h4 className={styles.finishTitle}>
@@ -50,25 +374,44 @@ export default function CuttingListTable({ cuttingList, onExport }) {
{group.pieceCount} pièces • {group.totalSurfaceM2.toFixed(2)} m²
</span>
</h4>
<table className={styles.table}>
<thead>
<tr>
<th>Description</th>
<th>Largeur</th>
<th>Hauteur</th>
<th title="Dimension dans la profondeur de l'armoire">Profondeur</th>
<th>Ép.</th>
<th>Qté</th>
<th>Surface</th>
<th>Chant</th>
<th>Perçages</th>
<th className={styles.cncHeader} title="Visualisation CNC 3D">
3D
</th>
{onExportMprPiece && <th className={styles.mprHeader}>MPR</th>}
</tr>
</thead>
<tbody>
{group.pieces.map((piece, pieceIndex) => (
{group.pieces.map((piece, pieceIndex) => {
const cncSegments = cncProgram ? getCncPartsForPiece(cuttingList, piece, cncProgram) : [];
const depthMm = getPieceDepthMm(piece, cuttingList?.config?.depthMm);
const widthMm = piece?.widthMm ?? null;
const heightMm = piece?.heightMm ?? null;
const depthNum = depthMm == null ? null : Number(depthMm);
const widthNum = widthMm == null ? null : Number(widthMm);
const heightNum = heightMm == null ? null : Number(heightMm);
const hideWidth = depthNum != null && widthNum != null && depthNum === widthNum;
const hideHeight = depthNum != null && heightNum != null && depthNum === heightNum;
const mprInstanceCount = countMprInstancesForPiece(piece);
const mprBusy = mprExportingPieceId === piece.id;
return (
<tr key={pieceIndex}>
<td className={styles.descCell}>{piece.description}</td>
<td className={styles.dimCell}>{piece.widthMm}</td>
<td className={styles.dimCell}>{piece.heightMm}</td>
<td className={styles.dimCell}>{hideWidth ? '—' : (widthMm ?? '—')}</td>
<td className={styles.dimCell}>{hideHeight ? '—' : (heightMm ?? '—')}</td>
<td className={styles.dimCell}>{depthMm ?? '—'}</td>
<td className={styles.dimCell}>{piece.thicknessMm}</td>
<td className={styles.qtyCell}>{piece.quantity}</td>
<td className={styles.surfaceCell}>{piece.surfaceM2.toFixed(3)}</td>
@@ -79,28 +422,232 @@ export default function CuttingListTable({ cuttingList, onExport }) {
</span>
)}
</td>
<td>
{piece.piecePlanSvg ? (
<button type="button" className={styles.planBtn} onClick={() => openPiecePlan(piece)}>
Voir plan
</button>
) : '—'}
</td>
<td className={styles.cncCell}>
{cncSegments.length > 0 ? (
<button
type="button"
className={styles.cncBtn}
onClick={() => openCnc3d(piece)}
title="Visualiser CNC 3D"
aria-label={`CNC 3D — ${piece.description}`}
>
3D
</button>
) : (
'—'
)}
</td>
{onExportMprPiece && (
<td className={styles.mprCell}>
<div className={styles.mprCellInner}>
{mprInstanceCount > 1 && (
<select
className={styles.mprInstanceSelect}
value={mprInstanceByPieceId[piece.id] ?? 'all'}
disabled={mprBusy}
onChange={(e) =>
setMprInstanceByPieceId((prev) => ({
...prev,
[piece.id]: e.target.value
}))
}
aria-label={`Exemplaire MPR pour ${piece.description}`}
title="Exemplaire à exporter"
>
<option value="all">T.{mprInstanceCount}</option>
{Array.from({ length: mprInstanceCount }, (_, i) => (
<option key={i} value={String(i)}>
{i + 1}
</option>
))}
</select>
)}
<button
type="button"
className={styles.mprBtn}
disabled={mprBusy}
onClick={() => handleExportMprRow(piece)}
title="Exporter MPR"
aria-label={`Exporter MPR — ${piece.description}`}
>
{mprBusy ? '…' : 'MPR'}
</button>
</div>
</td>
)}
</tr>
))}
);
})}
</tbody>
</table>
</div>
))}
</div>
{/* Export buttons */}
{onExport && (
<div className={styles.exportActions}>
<button
className={styles.exportBtn}
onClick={() => onExport('text')}
>
Exporter en texte
</button>
<button
className={`${styles.exportBtn} ${styles.primary}`}
onClick={() => onExport('pdf')}
>
Exporter en PDF
</button>
{cncModal && (
<div className={styles.previewOverlay} onClick={closeCncModal}>
<div className={styles.cncModal} onClick={(e) => e.stopPropagation()}>
<div className={styles.previewHeader}>
<button
type="button"
className={styles.previewClose}
onClick={closeCncModal}
aria-label="Fermer la prévisualisation CNC"
>
×
</button>
<h4>{cncModal.piece.description}</h4>
<div className={styles.previewMeta}>
Parcours outil (G-code) · Qté {cncModal.piece.quantity}
{cncModal.segments.length > 1 && (
<span className={styles.cncInstanceWrap}>
{' · '}
<label htmlFor="cnc-instance" className={styles.cncInstanceLabel}>Exemplaire</label>
<select
id="cnc-instance"
className={styles.cncInstanceSelect}
value={cncModal.instanceIndex}
onChange={(e) => {
const instanceIndex = Number(e.target.value);
setCncModal((prev) => (prev ? { ...prev, instanceIndex } : prev));
}}
>
{cncModal.segments.map((seg, i) => (
<option key={seg.id} value={i}>
{i + 1} / {cncModal.segments.length}
</option>
))}
</select>
</span>
)}
</div>
</div>
<div className={styles.cncModalBody}>
<div className={styles.cncViewerCol}>
<Cnc3DViewer
gcode={cncModal.segments[cncModal.instanceIndex]?.gcode || ''}
piece={cncModal.piece}
materialColor={getPieceMaterialColor(cncModal.piece)}
orbitStorageKey={`${planRestoreKey}:${cncModal.piece.id}:${cncModal.instanceIndex}`}
/>
<p className={styles.cncModalHint}>
Prévisualisation 3D du parcours pour cette pièce. Glisser pour tourner, molette pour zoomer, clic droit
pour déplacer.
</p>
</div>
<pre className={styles.cncCodeBlock}>
{cncModal.segments[cncModal.instanceIndex]?.gcode || '; —'}
</pre>
</div>
</div>
</div>
)}
{previewPiece && (
<div className={styles.previewOverlay} onClick={() => setPreviewPiece(null)}>
<div className={styles.previewModal} onClick={(e) => e.stopPropagation()}>
<div className={styles.previewHeader}>
<button
type="button"
className={styles.previewClose}
onClick={() => setPreviewPiece(null)}
aria-label="Fermer l'aperçu du plan"
>
×
</button>
<h4>{previewPiece.description}</h4>
<div className={styles.previewMeta}>
Fini: {previewPiece.widthMm} x {previewPiece.heightMm} x {previewPiece.thicknessMm} mm
{' · '}
Brut CNC: {previewPiece.rawCutWidthMm} x {previewPiece.rawCutHeightMm} mm
</div>
</div>
<div className={styles.previewCanvasWrap}>
<div
className={`${styles.previewContent} ${previewPiece?.piecePlanViews?.length ? styles.previewContentClickable : ''}`}
>
<div ref={piecePlanHostRef} className={styles.piecePlanHost}>
<div dangerouslySetInnerHTML={{ __html: previewPiece.piecePlanSvg }} />
{!!previewPiece?.piecePlanViews?.length &&
previewPiece.piecePlanViews.map((view, idx) => {
const p = planPanelLayout.panels[idx];
if (!p) return null;
return (
<div
key={view.id || idx}
className={styles.viewOverlayPanel}
style={{
left: `${p.leftPct}%`,
width: `${p.widthPct}%`,
top: `${p.topPct}%`,
height: `${p.heightPct}%`
}}
onClick={() => openViewLightbox(previewPiece, idx)}
title={`Agrandir ${view.label || `Vue ${idx + 1}`}`}
>
<button
type="button"
className={styles.viewIconBtn}
onClick={(e) => {
e.stopPropagation();
openViewLightbox(previewPiece, idx);
}}
title={`Plein écran ${view.label || `Vue ${idx + 1}`}`}
>
<span className={styles.viewIconGlyph}>⛶</span>
</button>
</div>
);
})}
</div>
</div>
</div>
<div className={styles.previewTech}>
<h5>Informations fabrication</h5>
<table className={styles.previewTable}>
<tbody>
{buildManufacturingRows(previewPiece).map(([label, value]) => (
<tr key={label}>
<th>{label}</th>
<td>{value}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{lightbox && (
<div className={styles.lightboxOverlay} onClick={() => setLightbox(null)}>
<div className={styles.lightboxModal} onClick={(e) => e.stopPropagation()}>
<div className={styles.lightboxHeader}>
<div>
<strong>{lightbox.title}</strong>
<div className={styles.previewMeta}>
{lightbox.views[lightbox.index]?.label} - {lightbox.views[lightbox.index]?.title}
</div>
</div>
<button type="button" className={styles.previewClose} onClick={() => setLightbox(null)}>×</button>
</div>
<div
className={styles.lightboxContent}
dangerouslySetInnerHTML={{ __html: lightbox.views[lightbox.index]?.svg || '' }}
/>
<div className={styles.lightboxFooter}>
<button type="button" className={styles.planBtn} onClick={() => shiftLightbox(-1)}>Précédent</button>
<span className={styles.lightboxCounter}>{lightbox.index + 1} / {lightbox.views.length}</span>
<button type="button" className={styles.planBtn} onClick={() => shiftLightbox(1)}>Suivant</button>
</div>
</div>
</div>
)}
</div>
+453 -36
View File
@@ -2,6 +2,11 @@
background: #fff;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
.empty {
@@ -14,6 +19,15 @@
background: #f0f7ff;
padding: 16px;
border-bottom: 1px solid #cce0ff;
flex-shrink: 0;
}
.tableScrollArea {
flex: 1;
min-height: 0;
overflow: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.summaryTitle {
@@ -51,7 +65,7 @@
border-bottom: 1px solid #e5e7eb;
}
.finishGroup:last-of-type {
.finishGroup:last-child {
border-bottom: none;
}
@@ -80,10 +94,18 @@
.table {
width: 100%;
min-width: 820px;
border-collapse: collapse;
font-size: 0.85rem;
}
.table thead th {
position: sticky;
top: 0;
z-index: 2;
background: #f9fafb;
}
.table th {
padding: 8px 10px;
background: #f9fafb;
@@ -127,6 +149,419 @@
text-align: center;
}
.planBtn {
padding: 4px 8px;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #fff;
color: #1e3a8a;
cursor: pointer;
font-size: 0.75rem;
}
.planBtn:hover {
background: #eff6ff;
}
.cncCell {
text-align: center;
vertical-align: middle;
width: 2.75rem;
max-width: 2.75rem;
padding: 4px 2px;
}
.table th.cncHeader {
text-align: center;
vertical-align: middle;
width: 2.75rem;
max-width: 2.75rem;
white-space: nowrap;
z-index: 3;
padding: 6px 2px !important;
font-size: 0.65rem;
letter-spacing: 0.02em;
}
.cncBtn {
display: block;
width: 100%;
max-width: 2.5rem;
margin: 0 auto;
padding: 2px 0;
border: 1px solid #c4b5fd;
border-radius: 3px;
background: #f5f3ff;
color: #5b21b6;
cursor: pointer;
font-size: 0.62rem;
font-weight: 700;
line-height: 1.25;
letter-spacing: 0.03em;
}
.cncBtn:hover {
background: #ede9fe;
}
.table th.mprHeader {
text-align: center;
vertical-align: middle;
width: 2.75rem;
max-width: 2.75rem;
white-space: nowrap;
z-index: 3;
padding: 6px 2px !important;
font-size: 0.65rem;
letter-spacing: 0.02em;
}
.mprCell {
text-align: center;
vertical-align: middle;
width: 2.75rem;
max-width: 2.75rem;
padding: 4px 2px;
}
.mprCellInner {
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
gap: 2px;
width: 100%;
max-width: 2.5rem;
margin: 0 auto;
}
.mprInstanceSelect {
width: 100%;
padding: 1px 0;
border: 1px solid #cbd5e1;
border-radius: 3px;
font-size: 0.62rem;
line-height: 1.2;
color: #374151;
background: #fff;
text-align: center;
}
.mprBtn {
width: 100%;
padding: 2px 0;
border: 1px solid #86efac;
border-radius: 3px;
background: #f0fdf4;
color: #166534;
cursor: pointer;
font-size: 0.62rem;
font-weight: 700;
line-height: 1.25;
letter-spacing: 0.03em;
}
.mprBtn:hover:not(:disabled) {
background: #dcfce7;
}
.mprBtn:disabled {
opacity: 0.6;
cursor: wait;
}
@media (min-width: 768px) {
.table th.cncHeader,
.table th.mprHeader,
.cncCell,
.mprCell {
width: 3rem;
max-width: 3rem;
}
.cncBtn {
max-width: 2.75rem;
}
.mprCellInner {
max-width: 2.75rem;
}
}
.cncModal {
width: min(95vw, 960px);
max-height: 90vh;
background: #fff;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
.cncModalBody {
display: grid;
grid-template-columns: 1.15fr 1fr;
gap: 12px;
padding: 12px 16px 16px;
overflow: auto;
max-height: min(72vh, 640px);
}
.cncViewerCol {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.cncModalHint {
margin: 0;
font-size: 0.78rem;
color: #64748b;
}
.cncCodeBlock {
margin: 0;
min-height: 200px;
max-height: 420px;
overflow: auto;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #0f172a;
color: #e2e8f0;
font-size: 0.72rem;
line-height: 1.45;
}
.cncInstanceWrap {
display: inline-flex;
align-items: center;
gap: 6px;
}
.cncInstanceLabel {
font-size: 0.85rem;
color: #64748b;
font-weight: 500;
}
.cncInstanceSelect {
padding: 4px 8px;
border-radius: 6px;
border: 1px solid #cbd5e1;
font-size: 0.82rem;
background: #fff;
}
@media (max-width: 768px) {
.cncModalBody {
grid-template-columns: 1fr;
}
}
.previewOverlay {
position: fixed;
inset: 0;
background: rgba(17, 24, 39, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1200;
padding: 20px;
}
.previewModal {
width: min(95vw, 1100px);
max-height: 90vh;
background: #fff;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
.previewHeader {
position: relative;
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
padding-right: 44px;
}
.previewHeader h4 {
margin: 0 0 6px;
font-size: 1rem;
}
.previewMeta {
color: #6b7280;
font-size: 0.85rem;
}
.previewClose {
position: absolute;
top: 10px;
right: 10px;
width: 28px;
height: 28px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
color: #334155;
font-size: 20px;
line-height: 1;
cursor: pointer;
}
.previewClose:hover {
background: #eff6ff;
}
.previewCanvasWrap {
position: relative;
}
.previewContent {
overflow: auto;
padding: 12px;
background: #f8fafc;
}
.previewContentClickable {
cursor: default;
}
.piecePlanHost {
position: relative;
display: block;
width: 100%;
}
.piecePlanHost :global(svg) {
display: block;
max-width: 100%;
height: auto;
}
.viewOverlayPanel {
position: absolute;
z-index: 2;
cursor: zoom-in;
box-sizing: border-box;
}
.viewIconBtn {
position: absolute;
top: 8px;
right: 8px;
border: 1px solid #cbd5e1;
background: rgba(255, 255, 255, 0.92);
border-radius: 999px;
width: 34px;
height: 34px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #1e3a8a;
z-index: 3;
}
.viewIconBtn:hover {
background: #eff6ff;
}
.viewIconGlyph {
font-size: 14px;
line-height: 1;
}
.previewTech {
border-top: 1px solid #e5e7eb;
padding: 12px 16px 16px;
background: #fff;
}
.previewTech h5 {
margin: 0 0 8px;
font-size: 0.95rem;
}
.previewTable {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
}
.previewTable th,
.previewTable td {
border: 1px solid #e5e7eb;
padding: 6px 8px;
text-align: left;
vertical-align: top;
}
.previewTable th {
width: 220px;
background: #f8fafc;
color: #334155;
font-weight: 600;
}
.lightboxOverlay {
position: fixed;
inset: 0;
background: rgba(2, 6, 23, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 1300;
padding: 22px;
}
.lightboxModal {
width: min(96vw, 1280px);
max-height: 94vh;
background: #fff;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.lightboxHeader {
border-bottom: 1px solid #e5e7eb;
padding: 10px 16px;
position: relative;
padding-right: 44px;
}
.lightboxContent {
overflow: auto;
background: #f8fafc;
padding: 10px;
min-height: 420px;
}
.lightboxFooter {
border-top: 1px solid #e5e7eb;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.lightboxCounter {
font-size: 0.85rem;
color: #475569;
min-width: 54px;
text-align: center;
}
.edgeBadge {
display: inline-block;
padding: 2px 6px;
@@ -145,45 +580,27 @@
color: #92400e;
}
.exportActions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
.exportBtn {
padding: 10px 20px;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background: #fff;
border: 1px solid #d1d5db;
color: #374151;
}
.exportBtn:hover {
background: #f3f4f6;
}
.exportBtn.primary {
background: #4f46e5;
border-color: #4f46e5;
color: #fff;
}
.exportBtn.primary:hover {
background: #4338ca;
}
@media (max-width: 640px) {
.table {
font-size: 0.75rem;
}
.table th.mprHeader,
.mprCell {
position: sticky;
right: 0;
z-index: 3;
box-shadow: -4px 0 8px -4px rgba(15, 23, 42, 0.12);
}
.table th.mprHeader {
background: #f9fafb;
}
.mprCell {
background: #fff;
z-index: 2;
}
.descCell {
min-width: 100px;
@@ -0,0 +1,91 @@
import styles from './HardwareListTable.module.css';
function formatChf(value) {
const n = Number(value);
if (!Number.isFinite(n)) return '—';
return `CHF ${n.toLocaleString('fr-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function formatUnitPrice({ unitPriceChf, unitLabel, unit }) {
const n = Number(unitPriceChf);
if (!Number.isFinite(n)) return '—';
const money = `CHF ${n.toLocaleString('fr-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const u = unitLabel || unit || null;
return u ? `${money} / ${u}` : money;
}
export default function HardwareListTable({ hardwareList, error = null }) {
if (error) {
return <div className={styles.empty}>{error}</div>;
}
if (!hardwareList) {
return <div className={styles.empty}>Aucune donnée de quincaillerie disponible</div>;
}
const items = Array.isArray(hardwareList.items) ? hardwareList.items : [];
return (
<div className={styles.container}>
<div className={styles.summary}>
<h3 className={styles.summaryTitle}>Résumé</h3>
<div className={styles.summaryGrid}>
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>Articles</span>
<span className={styles.summaryValue}>{items.length}</span>
</div>
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>Total</span>
<span className={styles.summaryValue}>{formatChf(hardwareList.totalChf)}</span>
</div>
</div>
</div>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.thumbCol}></th>
<th>Matériel</th>
<th>Code</th>
<th className={styles.num}>Qté</th>
<th className={styles.num}>Prix unitaire</th>
<th className={styles.num}>Total</th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={it.key}>
<td className={styles.thumbCell}>
{it.imageUrl ? (
<img className={styles.thumbImg} src={it.imageUrl} alt="" loading="lazy" />
) : (
<span className={styles.thumbEmpty}>—</span>
)}
</td>
<td className={styles.labelCell}>
{it.productUrl ? (
<a className={styles.productLink} href={it.productUrl} target="_blank" rel="noreferrer">
{it.label}
</a>
) : (
it.label
)}
</td>
<td className={styles.codeCell}>{it.code || '—'}</td>
<td className={`${styles.num} ${styles.qty}`}>{it.quantity}</td>
<td className={styles.num}>{formatUnitPrice(it)}</td>
<td className={styles.num}>{formatChf(it.lineTotalChf)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<th colSpan={5} className={styles.totalLabel}>Total</th>
<th className={`${styles.num} ${styles.totalValue}`}>{formatChf(hardwareList.totalChf)}</th>
</tr>
</tfoot>
</table>
</div>
);
}
@@ -0,0 +1,156 @@
.container {
display: flex;
flex-direction: column;
gap: 16px;
}
.empty {
padding: 18px;
border: 1px dashed var(--border);
border-radius: 10px;
background: var(--bg-subtle);
color: var(--text-muted);
font-size: 0.9rem;
}
.summary {
background: #f0f7ff;
border-radius: 10px;
padding: 18px;
border: 1px solid rgba(59, 130, 246, 0.15);
}
.summaryTitle {
margin: 0 0 12px;
font-size: 1.05rem;
font-weight: 700;
color: var(--text);
}
.summaryGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.summaryItem {
display: flex;
flex-direction: column;
gap: 4px;
}
.summaryLabel {
font-size: 0.78rem;
color: var(--text-muted);
font-weight: 600;
}
.summaryValue {
font-size: 1.25rem;
font-weight: 800;
color: #1d4ed8;
}
.table {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 1px solid var(--border-light);
border-radius: 10px;
overflow: hidden;
}
.table th,
.table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border-light);
vertical-align: middle;
}
.table thead th {
background: var(--bg-subtle);
font-size: 0.82rem;
letter-spacing: 0.01em;
text-transform: uppercase;
color: var(--text-muted);
font-weight: 700;
}
.thumbCol {
width: 52px;
}
.thumbCell {
width: 52px;
text-align: center;
}
.thumbImg {
width: 38px;
height: 38px;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--border-light);
background: #fff;
}
.thumbEmpty {
color: var(--text-muted);
}
.labelCell {
font-weight: 600;
color: var(--text);
}
.codeCell {
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.productLink {
color: var(--action);
text-decoration: none;
font-weight: 700;
}
.productLink:hover {
text-decoration: underline;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.qty {
font-weight: 700;
}
.table tfoot th,
.table tfoot td {
background: #fff7ed;
border-top: 1px solid rgba(245, 158, 11, 0.25);
border-bottom: none;
}
.totalLabel {
text-align: right;
font-weight: 800;
color: #7c2d12;
}
.totalValue {
font-weight: 900;
color: #7c2d12;
}
@media (max-width: 768px) {
.summaryGrid {
grid-template-columns: 1fr;
}
.table {
font-size: 0.9rem;
}
}
+1
View File
@@ -22,6 +22,7 @@ export default function Layout({ children }) {
<Link to="/configurateur">Configurateur</Link>
<Link to="/materiaux">Matériaux</Link>
<Link to="/admin">Admin</Link>
<Link to="/parametres">Paramètres</Link>
<button type="button" className={styles.logoutBtn} onClick={handleLogout}>Déconnexion</button>
</>
) : (
+119 -3
View File
@@ -12,6 +12,13 @@ export default function MaterialModal({ material, collections, onClose, onSave }
width_mm: '',
height_mm: '',
thickness_mm: '',
diameter_mm: '',
length_mm: '',
bore_diameter_mm: '',
bore_depth_base_mm: '',
bore_depth_piece_mm: '',
price_chf: '',
price_unit: '',
formex_url: ''
});
@@ -27,6 +34,13 @@ export default function MaterialModal({ material, collections, onClose, onSave }
width_mm: material.width_mm?.toString() || '',
height_mm: material.height_mm?.toString() || '',
thickness_mm: material.thickness_mm?.toString() || '',
diameter_mm: material.diameter_mm?.toString() || '',
length_mm: material.length_mm?.toString() || '',
bore_diameter_mm: material.bore_diameter_mm?.toString() || '',
bore_depth_base_mm: material.bore_depth_base_mm?.toString() || '',
bore_depth_piece_mm: material.bore_depth_piece_mm?.toString() || '',
price_chf: material.price_chf?.toString() || '',
price_unit: material.price_unit || '',
formex_url: material.formex_url || ''
});
}
@@ -56,7 +70,14 @@ export default function MaterialModal({ material, collections, onClose, onSave }
collection: formData.collection.trim(),
width_mm: formData.width_mm ? parseInt(formData.width_mm, 10) : null,
height_mm: formData.height_mm ? parseInt(formData.height_mm, 10) : null,
thickness_mm: formData.thickness_mm ? parseInt(formData.thickness_mm, 10) : null,
thickness_mm: formData.thickness_mm ? parseFloat(formData.thickness_mm) : null,
diameter_mm: formData.diameter_mm ? parseFloat(formData.diameter_mm) : null,
length_mm: formData.length_mm ? parseFloat(formData.length_mm) : null,
bore_diameter_mm: formData.bore_diameter_mm ? parseFloat(formData.bore_diameter_mm) : null,
bore_depth_base_mm: formData.bore_depth_base_mm ? parseFloat(formData.bore_depth_base_mm) : null,
bore_depth_piece_mm: formData.bore_depth_piece_mm ? parseFloat(formData.bore_depth_piece_mm) : null,
price_chf: formData.price_chf ? parseFloat(formData.price_chf) : null,
price_unit: formData.price_unit.trim() || null,
formex_url: formData.formex_url.trim() || null
};
@@ -174,15 +195,110 @@ export default function MaterialModal({ material, collections, onClose, onSave }
</div>
</div>
<div className={styles.row}>
<div className={styles.field}>
<label htmlFor="diameter_mm">Ø Diamètre (mm)</label>
<input
type="number"
id="diameter_mm"
name="diameter_mm"
value={formData.diameter_mm}
onChange={handleChange}
placeholder="8"
step="0.1"
/>
</div>
<div className={styles.field}>
<label htmlFor="length_mm">Longueur (mm)</label>
<input
type="number"
id="length_mm"
name="length_mm"
value={formData.length_mm}
onChange={handleChange}
placeholder="55"
step="0.1"
/>
</div>
</div>
<fieldset className={styles.fieldset}>
<legend>Perçage</legend>
<div className={styles.row}>
<div className={styles.field}>
<label htmlFor="bore_diameter_mm">Ø Perçage (mm)</label>
<input
type="number"
id="bore_diameter_mm"
name="bore_diameter_mm"
value={formData.bore_diameter_mm}
onChange={handleChange}
placeholder="8"
step="0.1"
/>
</div>
<div className={styles.field}>
<label htmlFor="bore_depth_base_mm">Prof. fond (mm)</label>
<input
type="number"
id="bore_depth_base_mm"
name="bore_depth_base_mm"
value={formData.bore_depth_base_mm}
onChange={handleChange}
placeholder="11"
step="0.1"
/>
</div>
<div className={styles.field}>
<label htmlFor="bore_depth_piece_mm">Prof. pièce (mm)</label>
<input
type="number"
id="bore_depth_piece_mm"
name="bore_depth_piece_mm"
value={formData.bore_depth_piece_mm}
onChange={handleChange}
placeholder="8"
step="0.1"
/>
</div>
</div>
</fieldset>
<div className={styles.row}>
<div className={styles.field}>
<label htmlFor="price_chf">Prix CHF</label>
<input
type="number"
id="price_chf"
name="price_chf"
value={formData.price_chf}
onChange={handleChange}
placeholder="0.60"
step="0.01"
/>
</div>
<div className={styles.field}>
<label htmlFor="price_unit">Unité de prix</label>
<input
type="text"
id="price_unit"
name="price_unit"
value={formData.price_unit}
onChange={handleChange}
placeholder="par Pièce (PC)"
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="formex_url">URL Formex</label>
<label htmlFor="formex_url">URL</label>
<input
type="url"
id="formex_url"
name="formex_url"
value={formData.formex_url}
onChange={handleChange}
placeholder="https://www.formex.ch/fr/shop/..."
placeholder="https://..."
/>
</div>
+19 -1
View File
@@ -12,7 +12,7 @@
.modal {
background: #fff;
border-radius: 8px;
max-width: 500px;
max-width: 560px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
@@ -92,6 +92,24 @@
gap: 1rem;
}
.row:has(> :nth-child(2):last-child) {
grid-template-columns: repeat(2, 1fr);
}
.fieldset {
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem 1rem 0.5rem;
margin-bottom: 1rem;
}
.fieldset legend {
font-size: 0.8125rem;
font-weight: 600;
color: var(--accent);
padding: 0 0.375rem;
}
.actions {
display: flex;
gap: 0.75rem;
+301 -38
View File
@@ -4,22 +4,74 @@
*/
import { useState, useEffect, useCallback } from 'react';
import CuttingListTable from './CuttingListTable';
import HardwareListTable from './HardwareListTable';
import styles from './Plans2DView.module.css';
import { saveOrderPlanExport, generateOrderPlanExports } from '../api/client';
import Icon from './ui/Icon';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function Plans2DView({ config, modules = [], colors = {}, onClose }) {
const [activeTab, setActiveTab] = useState('assembly');
const PLANS_TAB_KEY = 'apex.plans.tab';
const VALID_PLAN_TABS = ['assembly', 'side', 'top', 'cutting', 'hardware'];
function readSavedPlanTab() {
try {
let t = localStorage.getItem(PLANS_TAB_KEY);
if (VALID_PLAN_TABS.includes(t)) return t;
t = sessionStorage.getItem(PLANS_TAB_KEY);
if (VALID_PLAN_TABS.includes(t)) {
localStorage.setItem(PLANS_TAB_KEY, t);
sessionStorage.removeItem(PLANS_TAB_KEY);
return t;
}
} catch {
// ignore
}
return 'assembly';
}
export default function Plans2DView({ config, modules = [], colors = {}, orderId = null, onClose }) {
const [activeTab, setActiveTab] = useState(readSavedPlanTab);
const [plans, setPlans] = useState(null);
const [cuttingList, setCuttingList] = useState(null);
const [hardwareList, setHardwareList] = useState(null);
const [hardwareError, setHardwareError] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
/** Erreur API plans 2D seulement — n’empêche pas liste débitage / quincaillerie */
const [planViewsError, setPlanViewsError] = useState(null);
const [exportStatus, setExportStatus] = useState('');
const [cncProgram, setCncProgram] = useState('');
useEffect(() => {
try {
localStorage.setItem(PLANS_TAB_KEY, activeTab);
sessionStorage.removeItem(PLANS_TAB_KEY);
} catch {
// ignore
}
}, [activeTab]);
const planRestoreKey = `${config.widthMm}|${config.heightMm}|${config.depthMm}`;
const handleClose = useCallback(() => {
try {
localStorage.removeItem(PLANS_TAB_KEY);
sessionStorage.removeItem(PLANS_TAB_KEY);
sessionStorage.removeItem('apex.cnc3d.modal');
} catch {
// ignore
}
onClose?.();
}, [onClose]);
// Fetch plans and cutting list
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
setPlanViewsError(null);
setHardwareError(null);
try {
// Fetch plans
@@ -28,10 +80,24 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
const plansData = await plansRes.json();
if (plansData.success) {
let plansData = {};
try {
plansData = await plansRes.json();
} catch {
plansData = {};
}
if (plansRes.ok && plansData.success && plansData.plans) {
setPlans(plansData.plans);
setPlanViewsError(null);
} else {
setPlans(null);
const detail = Array.isArray(plansData.details) ? plansData.details[0]?.message : undefined;
const msg =
plansData.error ||
detail ||
(!plansRes.ok ? `Plans 2D indisponibles (HTTP ${plansRes.status}).` : 'Réponse serveur invalide pour les plans 2D.');
setPlanViewsError(msg);
}
// Fetch cutting list
@@ -42,8 +108,34 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
});
const cutData = await cutRes.json();
if (cutData.success) {
if (cutRes.ok && cutData.success) {
setCuttingList(cutData.cuttingList);
} else {
setCuttingList(null);
}
// Fetch hardware list
const hwRes = await fetch(`${API_BASE}/api/plans/hardware-list`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
const hwData = await hwRes.json();
if (hwData.success) {
setHardwareList({ items: hwData.items || [], totalChf: hwData.totalChf ?? 0 });
} else {
setHardwareList(null);
setHardwareError('Erreur lors de la génération de la liste de quincaillerie');
}
const cncRes = await fetch(`${API_BASE}/api/plans/export-cnc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
if (cncRes.ok) {
const cncText = await cncRes.text();
setCncProgram(cncText);
}
} catch (err) {
@@ -60,19 +152,34 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
// Export handlers
const handleExport = useCallback(async (format) => {
try {
setExportStatus('');
if (format === 'text') {
const res = await fetch(`${API_BASE}/api/plans/export-text`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const textContent = await blob.text();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'liste-debitage.txt';
a.click();
URL.revokeObjectURL(url);
if (orderId) {
await saveOrderPlanExport(orderId, {
exportType: 'cutting_list_text',
content: textContent,
mimeType: 'text/plain; charset=utf-8',
fileExtension: 'txt'
});
setExportStatus(`Export enregistré pour la commande #${orderId}.`);
} else {
setExportStatus('Export téléchargé localement (non archivé: commande non envoyée).');
}
} else if (format === 'pdf') {
const res = await fetch(`${API_BASE}/api/plans/export-pdf`, {
@@ -80,27 +187,123 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
if (!res.ok) throw new Error(await res.text());
const html = await res.text();
// Open in new window for printing
const printWindow = window.open('', '_blank');
printWindow.document.write(html);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print();
};
if (printWindow) {
printWindow.document.write(html);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print();
};
}
if (orderId) {
await generateOrderPlanExports(orderId);
setExportStatus(`PDF dossier enregistré pour la commande #${orderId}.`);
} else {
setExportStatus('Version imprimable ouverte (non archivée: commande non envoyée).');
}
} else if (format === 'cnc') {
const res = await fetch(`${API_BASE}/api/plans/export-cnc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
if (!res.ok) throw new Error(await res.text());
const gcode = await res.text();
setCncProgram(gcode);
const blob = new Blob([gcode], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'apex-program.nc';
a.click();
URL.revokeObjectURL(url);
setExportStatus('Programme CNC (.nc) téléchargé.');
} else if (format === 'mpr') {
const res = await fetch(`${API_BASE}/api/plans/export-mpr`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config,
modules,
colors,
orderReference: orderId ? `COMMANDE_${orderId}` : '',
stackListKm: '01',
mprGroupIndex: 1
})
});
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'apex-mpr-g1.zip';
a.click();
URL.revokeObjectURL(url);
setExportStatus('Archive Homag MPR (.zip) téléchargée.');
}
} catch (err) {
console.error('Export error:', err);
alert('Erreur lors de l\'export');
}
}, [config, modules, colors]);
}, [config, modules, colors, orderId]);
const handleExportMprPiece = useCallback(async (piece, instanceIndex = null) => {
try {
setExportStatus('');
if (!piece?.id) return;
const res = await fetch(`${API_BASE}/api/plans/export-mpr-piece`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config,
modules,
colors,
pieceId: piece.id,
...(instanceIndex != null && Number.isFinite(instanceIndex) ? { instanceIndex } : {}),
orderReference: orderId ? `COMMANDE_${orderId}` : '',
stackListKm: '01',
mprGroupIndex: 1
})
});
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try {
const err = await res.json();
msg = err.error || msg;
} catch {
msg = (await res.text()) || msg;
}
throw new Error(msg);
}
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename="?([^";\n]+)"?/i);
const downloadName = match?.[1] || 'piece.mpr';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = downloadName;
a.click();
URL.revokeObjectURL(url);
setExportStatus(`MPR téléchargé : ${piece.description}`);
} catch (err) {
console.error('Export MPR piece error:', err);
alert(err.message || 'Erreur lors de l\'export MPR');
}
}, [config, modules, colors, orderId]);
const tabs = [
{ id: 'assembly', label: 'Vue de face' },
{ id: 'side', label: 'Vue de côté' },
{ id: 'top', label: 'Vue de dessus' },
{ id: 'cutting', label: 'Liste de débitage' }
{ id: 'cutting', label: 'Liste de débitage' },
{ id: 'hardware', label: 'Quincaillerie' }
];
return (
@@ -108,7 +311,10 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
<div className={styles.modal}>
<div className={styles.header}>
<h2 className={styles.title}>Plans de fabrication</h2>
<button className={styles.closeBtn} onClick={onClose}>×</button>
<button className={styles.closeBtn} onClick={handleClose}>
<Icon name="x" size={14} />
Fermer
</button>
</div>
<div className={styles.tabs}>
@@ -123,38 +329,65 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
))}
</div>
<div className={styles.content}>
<div className={`${styles.content} ${activeTab === 'cutting' ? styles.contentCutting : ''}`}>
{loading ? (
<div className={styles.loading}>Génération des plans...</div>
) : error ? (
<div className={styles.error}>{error}</div>
) : (
<>
{activeTab === 'assembly' && plans?.assembly && (
<div
className={styles.planContainer}
dangerouslySetInnerHTML={{ __html: plans.assembly }}
/>
{activeTab === 'assembly' && (
planViewsError ? (
<div className={styles.empty}>{planViewsError}</div>
) : plans?.assembly ? (
<div
className={styles.planContainer}
dangerouslySetInnerHTML={{ __html: plans.assembly }}
/>
) : (
<div className={styles.empty}>Aucun plan de face disponible.</div>
)
)}
{activeTab === 'side' && plans?.side && (
<div
className={styles.planContainer}
dangerouslySetInnerHTML={{ __html: plans.side }}
/>
{activeTab === 'side' && (
planViewsError ? (
<div className={styles.empty}>{planViewsError}</div>
) : plans?.side ? (
<div
className={styles.planContainer}
dangerouslySetInnerHTML={{ __html: plans.side }}
/>
) : (
<div className={styles.empty}>Aucun plan de côté disponible.</div>
)
)}
{activeTab === 'top' && plans?.top && (
<div
className={styles.planContainer}
dangerouslySetInnerHTML={{ __html: plans.top }}
/>
{activeTab === 'top' && (
planViewsError ? (
<div className={styles.empty}>{planViewsError}</div>
) : plans?.top ? (
<div
className={styles.planContainer}
dangerouslySetInnerHTML={{ __html: plans.top }}
/>
) : (
<div className={styles.empty}>Aucun plan de dessus disponible.</div>
)
)}
{activeTab === 'cutting' && (
<CuttingListTable
<CuttingListTable
cuttingList={cuttingList}
onExport={handleExport}
onExportMprPiece={handleExportMprPiece}
cncProgram={cncProgram}
planRestoreKey={planRestoreKey}
/>
)}
{activeTab === 'hardware' && (
<HardwareListTable
hardwareList={hardwareList}
error={hardwareError}
/>
)}
</>
@@ -162,20 +395,50 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
</div>
<div className={styles.footer}>
<div className={styles.specs}>
<span>{config.widthMm} × {config.heightMm} × {config.depthMm} mm</span>
<span>•</span>
<span>{config.installationType === 'niche' ? 'Encastrée' : 'Libre'}</span>
<span>•</span>
<span>{config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</span>
<div className={styles.footerInfo}>
<div className={styles.specs}>
<span className={styles.specsDims}>
{config.widthMm} × {config.heightMm} × {config.depthMm} mm
</span>
<span className={styles.specsSep} aria-hidden="true">•</span>
<span>{config.installationType === 'niche' ? 'Encastrée' : 'Libre'}</span>
<span className={styles.specsSep} aria-hidden="true">•</span>
<span>{config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</span>
</div>
{exportStatus && <div className={styles.exportStatus}>{exportStatus}</div>}
</div>
<div className={styles.actions}>
{activeTab === 'cutting' && (
<button
className={styles.exportBtnSecondary}
onClick={() => handleExport('text')}
>
<Icon name="download" size={15} />
Liste texte
</button>
)}
<button
className={styles.exportBtn}
onClick={() => handleExport('pdf')}
>
<Icon name="download" size={15} />
Imprimer / PDF
</button>
{activeTab === 'cutting' && (
<>
<button
className={styles.exportBtn}
onClick={() => handleExport('cnc')}
>
<Icon name="download" size={15} />
Export CNC (.nc)
</button>
<button className={styles.exportBtn} onClick={() => handleExport('mpr')}>
<Icon name="download" size={15} />
Export MPR (ZIP)
</button>
</>
)}
</div>
</div>
</div>
+131 -21
View File
@@ -17,10 +17,11 @@
border-radius: 12px;
width: 100%;
max-width: 1000px;
max-height: 90vh;
max-height: min(90vh, calc(100dvh - 40px));
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.header {
@@ -39,17 +40,26 @@
}
.closeBtn {
background: none;
border: none;
font-size: 1.5rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: #fff;
border: 1px solid var(--border);
border-radius: 9px;
color: var(--text-muted);
cursor: pointer;
padding: 4px 8px;
line-height: 1;
padding: 0.35rem 0.6rem;
font-size: 0.8rem;
font-weight: 600;
line-height: 1.2;
transition: all 0.15s ease;
}
.closeBtn:hover {
color: var(--text);
border-color: var(--action);
color: var(--action);
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.15);
transform: translateY(-1px);
}
.tabs {
@@ -97,6 +107,15 @@
min-height: 400px;
}
.contentCutting {
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
padding: 20px;
}
.loading,
.error {
display: flex;
@@ -111,6 +130,20 @@
color: var(--danger);
}
.empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 280px;
padding: 24px;
text-align: center;
color: var(--text-muted);
font-size: 0.9rem;
line-height: 1.45;
max-width: 420px;
margin: 0 auto;
}
.planContainer {
display: flex;
align-items: center;
@@ -128,45 +161,107 @@
.footer {
display: flex;
align-items: center;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
padding: 16px 20px;
gap: 12px 16px;
padding: 14px 16px 16px;
border-top: 1px solid var(--border-light);
background: var(--bg-subtle);
flex-shrink: 0;
}
.footerInfo {
flex: 1 1 12rem;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 8px;
}
.specs {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 8px;
font-size: 0.85rem;
line-height: 1.45;
color: var(--text-muted);
}
.specsDims {
white-space: nowrap;
}
.specsSep {
opacity: 0.55;
}
.exportStatus {
font-size: 0.8rem;
line-height: 1.4;
color: #166534;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
flex: 1 1 16rem;
max-width: 100%;
}
.exportBtn {
padding: 10px 20px;
background: var(--action);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 9px 14px;
background: linear-gradient(135deg, var(--action), var(--action-hover));
border: none;
border-radius: 6px;
border-radius: 10px;
color: #fff;
font-size: 0.9rem;
font-weight: 500;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
line-height: 1.25;
}
.exportBtnSecondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 9px 14px;
background: #fff;
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
line-height: 1.25;
}
.exportBtnSecondary:hover {
border-color: var(--action);
color: var(--action);
}
.exportBtn:hover {
background: var(--action-hover);
box-shadow: 0 10px 22px rgba(79, 70, 229, 0.3);
transform: translateY(-1px);
}
@media (max-width: 768px) {
.modal {
max-height: 95vh;
max-height: min(95vh, calc(100dvh - 24px));
}
.tabs {
@@ -182,11 +277,26 @@
.footer {
flex-direction: column;
align-items: stretch;
gap: 12px;
padding: 12px 14px 14px;
}
.footerInfo {
padding-right: 0;
}
.specs {
flex-wrap: wrap;
justify-content: center;
justify-content: flex-start;
}
.actions {
justify-content: stretch;
}
.exportBtn,
.exportBtnSecondary {
flex: 1 1 calc(50% - 4px);
min-width: 9rem;
}
}
@@ -23,6 +23,8 @@ const DEFAULT_COLORS = {
panneauxInterieurs: '#d4c4a4',
separateurs: '#6b5344',
etageres: '#d4c4a4',
rayonFixe: '#d4c4a4',
renfortsTiroir: '#c4a574',
tiroirs: '#c4a574',
penderie: '#8b7355'
};
@@ -43,6 +45,8 @@ const ELEMENT_LABELS = {
panneauxInterieurs: 'Panneaux intérieurs',
separateurs: 'Séparateurs',
etageres: 'Étagères',
rayonFixe: 'Rayon fixe',
renfortsTiroir: 'Renforts tiroir',
tiroirs: 'Tiroirs',
penderie: 'Tringle penderie'
};
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,9 @@
min-height: 280px;
border-radius: 8px;
overflow: hidden;
background: var(--bg);
border: 1px solid var(--border);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
}
.controls {
@@ -17,6 +20,19 @@
z-index: 10;
}
.stepBadge {
padding: 4px 8px;
border-radius: 999px;
background: rgba(79, 70, 229, 0.92);
color: #fff;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.01em;
text-transform: uppercase;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.controlBtn {
width: 36px;
height: 36px;
@@ -44,6 +60,20 @@
transform: scale(0.95);
}
.controlBtnDisabled,
.controlBtn:disabled {
opacity: 0.42;
cursor: not-allowed;
transform: none;
pointer-events: none;
}
.controlBtnDisabled:hover,
.controlBtn:disabled:hover {
transform: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.loading {
display: flex;
align-items: center;
@@ -121,3 +151,4 @@
border-color: var(--action);
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2);
}
@@ -0,0 +1,140 @@
import { useCallback, useEffect } from 'react';
import WardrobePreview from '../WardrobePreview';
import DesignTip from '../DesignTip';
import { resolveShelfPositionsMm, minShelvesForStructuralHeight } from '../../constants/wardrobe';
import styles from './PreviewArea.module.css';
export default function PreviewArea({ wc, ui }) {
const colorPickEnabled = ui.activeStep === 4 || ui.activeStep === 6;
const infoByStep = {
1: 'Survolez les zones colorees du cadre puis glissez pour regler L/H/P.',
2: 'Tirez les separateurs pour redimensionner et glissez les rayonnages par la tranche avant.',
3: 'Activez ou masquez les portes pour verifier l ouverture.',
4: 'Choisissez un element puis appliquez sa couleur.',
5: 'Verifiez les volumes interieurs pour valider la quincaillerie.',
6: 'Controle final: zoom, rotation et verification globale.'
};
const structuralWarnings = (() => {
if (ui.activeStep !== 2) return [];
if (minShelvesForStructuralHeight(wc.config.heightMm) <= 0) return [];
const heightMm = Number(wc.config.heightMm) || 0;
const plinthMm = Number(wc.config.plinthHeightMm) || 0;
const midMm = (heightMm - plinthMm) / 2;
const mods = Array.isArray(wc.config.modules) ? wc.config.modules : [];
const out = [];
for (let i = 0; i < mods.length; i++) {
const mod = mods[i];
const shelfCount = Math.max(0, Number(mod?.shelves) || 0);
const positions = resolveShelfPositionsMm({
shelfCount,
heightMm,
plinthHeightMm: plinthMm,
shelfPositionsMm: mod?.shelfPositionsMm
});
const hasStructural = positions.some((p) => p >= midMm - 200 && p <= midMm + 200);
if (!hasStructural) out.push(`Colonne ${i + 1}: placer un rayon dans la zone structurelle (±20 cm).`);
}
return out;
})();
const handleSetDimension = useCallback((key, valueMm) => {
wc.setDimension(key, valueMm);
}, [wc.setDimension]);
useEffect(() => {
const onKeyDown = (e) => {
if (!e.ctrlKey && !e.metaKey) return;
const el = e.target;
if (el && (el.closest?.('input, textarea, select') || el.isContentEditable)) return;
if (e.key === 'z' && !e.shiftKey) {
e.preventDefault();
wc.undo();
return;
}
if (e.key === 'z' && e.shiftKey) {
e.preventDefault();
wc.redo();
return;
}
if (e.key === 'y' && e.ctrlKey && !e.metaKey) {
e.preventDefault();
wc.redo();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [wc.undo, wc.redo]);
return (
<div className={styles.previewArea}>
<div className={styles.roomControls}>
{ui.roomViewActive && ui.roomConfig ? (
<>
<span className={styles.roomLabel}>
Vue : Ma pi&egrave;ce ({ui.roomAnalysis?.roomDimensions?.widthM}m &times; {ui.roomAnalysis?.roomDimensions?.lengthM}m)
</span>
<div className={styles.roomBtnGroup}>
<button type="button" className={styles.roomBtnSecondary} onClick={() => ui.setShowRoomUpload(true)}>
Nouvelle photo
</button>
<button type="button" className={styles.roomBtn} onClick={() => ui.setRoomViewActive(false)}>
Vue simple
</button>
</div>
</>
) : (
<>
<span className={styles.roomLabel}>Vue : Simple</span>
<div className={styles.roomBtnGroup}>
{ui.roomConfig && (
<button type="button" className={styles.roomBtn} onClick={() => ui.setRoomViewActive(true)}>
Afficher ma pi&egrave;ce
</button>
)}
<button
type="button"
className={ui.roomConfig ? styles.roomBtnSecondary : styles.roomBtn}
onClick={() => ui.setShowRoomUpload(true)}
>
{ui.roomConfig ? 'Nouvelle photo' : 'Visualiser dans ma pi\u00e8ce'}
</button>
<button type="button" className={styles.aiRenderBtn} onClick={() => ui.setShowAIRender(true)}>
<span className={styles.aiBadgeSmall}>IA</span>
Rendu r&eacute;aliste
</button>
</div>
</>
)}
</div>
<div className={styles.preview}>
<WardrobePreview
config={wc.config}
activeStep={ui.activeStep}
roomConfig={ui.roomViewActive ? ui.roomConfig : null}
colors={wc.config.colors}
modules={wc.config.modules}
onElementClick={colorPickEnabled ? (elementType, label) => ui.openColorPicker(elementType, label) : undefined}
onModuleClick={ui.handleModuleClick}
onUpdateModule={wc.handleColumnUpdate}
onResizeModules={wc.handleResizeModules}
selectedModuleIndex={ui.expandedColumn}
onSetDimension={handleSetDimension}
onBeginUndoableGesture={wc.pushUndoSnapshot}
canUndo3d={wc.canUndo}
canRedo3d={wc.canRedo}
onUndo3d={wc.undo}
onRedo3d={wc.redo}
suppressHtmlOverlays={ui.showPlansView || ui.showAIRender || ui.showRoomUpload}
/>
</div>
<div className={styles.infoZone}>
<div className={styles.infoLine}>{infoByStep[ui.activeStep] || 'Manipulez la vue pour controler la configuration.'}</div>
{structuralWarnings.map((msg) => (
<div key={msg} className={styles.warnLine}>{msg}</div>
))}
</div>
<DesignTip config={wc.config} />
</div>
);
}
@@ -0,0 +1,102 @@
.previewArea {
position: relative;
width: 100%;
max-width: 100%;
margin: 0;
}
.roomControls {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
gap: 0.5rem;
flex-wrap: wrap;
}
.roomLabel {
color: var(--text-muted);
font-size: 0.8125rem;
}
.roomBtnGroup {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.roomBtn, .roomBtnSecondary, .aiRenderBtn {
padding: 0.3rem 0.65rem;
border-radius: 5px;
font-size: 0.75rem;
font-weight: 500;
border: 1px solid var(--border);
cursor: pointer;
background: var(--bg-card);
color: var(--text);
transition: all 0.15s;
}
.roomBtn:hover, .roomBtnSecondary:hover, .aiRenderBtn:hover {
background: var(--bg);
}
.aiRenderBtn {
background: var(--action);
color: #fff;
border-color: var(--action);
}
.aiRenderBtn:hover { opacity: 0.9; }
.aiBadgeSmall {
display: inline-block;
background: rgba(255,255,255,0.2);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
padding: 0.1rem 0.3rem;
border-radius: 3px;
margin-right: 0.25rem;
vertical-align: middle;
}
.preview {
width: 100%;
aspect-ratio: 16 / 10;
max-height: 50vh;
background: var(--bg);
border-radius: 8px;
overflow: hidden;
}
.infoZone {
margin-top: 0.5rem;
border: 1px solid var(--border);
background: var(--bg-card);
border-radius: 8px;
padding: 0.45rem 0.6rem;
display: grid;
gap: 0.35rem;
}
.infoLine {
font-size: 0.78rem;
color: var(--text-muted);
}
.warnLine {
font-size: 0.78rem;
color: #b91c1c;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
padding: 0.25rem 0.45rem;
}
@media (max-width: 700px) {
.preview {
aspect-ratio: 16 / 9;
max-height: 40vh;
}
.roomControls { padding: 0.35rem 0.5rem; }
}
@@ -0,0 +1,443 @@
import { useState, useRef, useCallback } from 'react';
import {
COLUMN_TYPES,
maxShelfGapMm,
SHELF_GAP_MM,
minShelvesForStructuralHeight,
resolveShelfPositionsMm,
system32ShelfPositionsMm,
SYSTEM32_PITCH_MM
} from '../../constants/wardrobe';
import s from './steps.module.css';
/** After moving array item from -> to, map a selected index to the new position. */
function mapExpandedIndexAfterReorder(expanded, from, to) {
if (expanded === null || expanded === undefined) return expanded;
if (expanded === from) return to;
if (from < to) {
if (expanded > from && expanded <= to) return expanded - 1;
} else if (from > to) {
if (expanded >= to && expanded < from) return expanded + 1;
}
return expanded;
}
export default function StepColumns({ wc, ui }) {
const {
config, numColumns,
handleNumColumnsChange, handleAIOptimize, handleColumnTypeChange,
handleColumnLock, handleColumnUpdate,
aiOptimizing, aiOptimizeMsg, setAiOptimizeMsg, totalModuleWidth,
reorderModules, pushUndoSnapshot,
} = wc;
const { expandedColumn, setExpandedColumn } = ui;
const [draggingIndex, setDraggingIndex] = useState(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
const dragSourceRef = useRef(null);
const suppressCardClickRef = useRef(false);
const canReorderColumns = config.modules.length > 1;
const handleColumnDragStart = useCallback((index, e) => {
if (!canReorderColumns) {
e.preventDefault();
return;
}
dragSourceRef.current = index;
setDraggingIndex(index);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(index));
}, [canReorderColumns]);
const handleColumnDragOver = useCallback((index, e) => {
if (!canReorderColumns || dragSourceRef.current === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(index);
}, [canReorderColumns]);
const handleColumnDrop = useCallback((toIndex, e) => {
e.preventDefault();
const raw = e.dataTransfer.getData('text/plain');
const fromIndex = Number.parseInt(raw, 10);
dragSourceRef.current = null;
setDragOverIndex(null);
setDraggingIndex(null);
if (!Number.isFinite(fromIndex) || fromIndex === toIndex) return;
suppressCardClickRef.current = true;
pushUndoSnapshot();
reorderModules(fromIndex, toIndex);
setExpandedColumn((prev) => mapExpandedIndexAfterReorder(prev, fromIndex, toIndex));
}, [pushUndoSnapshot, reorderModules, setExpandedColumn]);
const handleColumnDragEnd = useCallback(() => {
dragSourceRef.current = null;
setDraggingIndex(null);
setDragOverIndex(null);
}, []);
const handleColCardClick = useCallback((i, isActive) => {
if (suppressCardClickRef.current) {
suppressCardClickRef.current = false;
return;
}
setExpandedColumn(isActive ? null : i);
}, [setExpandedColumn]);
const minShelvesStructural = minShelvesForStructuralHeight(config.heightMm);
const hasTallCabinet = minShelvesStructural > 0;
const hw = config.hardware || {};
const drawerDepth = config.effectiveDrawerDepthMm || Math.max(400, Math.min(650, config.depthMm - 10));
return (
<div className={s.stepSection}>
<h2 className={s.stepTitle}>Configuration int&eacute;rieure</h2>
<p className={s.stepDesc}>
Choisissez le nombre de colonnes et personnalisez chacune. Cliquez sur un module dans la vue 3D pour le s&eacute;lectionner.
{canReorderColumns && (
<span className={s.stepDescExtra}> Glissez-d&eacute;posez les cartes ci-dessous pour changer l&apos;ordre des colonnes (gauche &rarr; droite = avant &rarr; arri&egrave;re dans la vue 3D).</span>
)}
</p>
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Choix colonne: séparations ou block</label>
<div className={s.colCountBtns}>
{[
{ key: 'separations', label: 'Séparations' },
{ key: 'block', label: 'Block (x caisses)' }
].map((m) => (
<button
key={m.key}
type="button"
className={`${s.colCountBtn} ${config.columnMode === m.key ? s.colCountActive : ''}`}
onClick={() => wc.set('columnMode', m.key)}
>
{m.label}
</button>
))}
</div>
{config.columnMode === 'block' && (
<p className={s.hint}>Assemblage block: vis de jonction + perçages 3 milieux et +100/-100 mm (30 mm face/dos).</p>
)}
</div>
{/* Column count */}
<div className={s.colCountRow}>
<span className={s.colCountLabel}>Nombre de colonnes</span>
<div className={s.colCountBtns}>
{[1, 2, 3, 4, 5, 6].map(n => (
<button
key={n}
type="button"
className={`${s.colCountBtn} ${numColumns === n ? s.colCountActive : ''}`}
onClick={() => handleNumColumnsChange(n)}
>
{n}
</button>
))}
</div>
<button
type="button"
className={s.optimizeBtn}
onClick={handleAIOptimize}
disabled={aiOptimizing}
>
{aiOptimizing ? '...' : '\u2728 Optimiser'}
</button>
</div>
{aiOptimizeMsg && (
<div className={s.aiMsg}>
<span className={s.aiBadge}>IA</span>
<span>{aiOptimizeMsg}</span>
<button className={s.aiDismiss} onClick={() => setAiOptimizeMsg('')}>&times;</button>
</div>
)}
{/* Horizontal column strip */}
{config.modules.length > 0 && (
<>
<p className={s.columnStripLegend}>
Gauche &rarr; droite : colonne 1, 2, 3&hellip; (identique &agrave; la vue 3D).
</p>
<div className={s.columnStrip}>
{config.modules.map((mod, i) => {
const typeDef = COLUMN_TYPES.find(t => t.key === mod.type) || COLUMN_TYPES[0];
const isActive = expandedColumn === i;
const isDragging = draggingIndex === i;
const isOver = dragOverIndex === i && draggingIndex !== null && draggingIndex !== i;
return (
<button
key={mod.id}
type="button"
draggable={canReorderColumns}
onDragStart={(e) => handleColumnDragStart(i, e)}
onDragOver={(e) => handleColumnDragOver(i, e)}
onDrop={(e) => handleColumnDrop(i, e)}
onDragEnd={handleColumnDragEnd}
className={`${s.colCard} ${isActive ? s.colCardActive : ''} ${mod.locked ? s.colCardLocked : ''} ${isDragging ? s.colCardDragging : ''} ${isOver ? s.colCardDropTarget : ''}`}
onClick={() => handleColCardClick(i, isActive)}
title={canReorderColumns ? `Colonne ${i + 1} — cliquer pour d\u00e9tails, glisser pour r\u00e9organiser` : `Colonne ${i + 1}`}
>
{mod.locked && <span className={s.colLockIcon}>&#x1F512;</span>}
<span className={s.colCardIndex}>Colonne {i + 1}</span>
{canReorderColumns && (
<span className={s.colDragHint} aria-hidden="true">::</span>
)}
<span className={s.colCardIcon}>{typeDef.icon}</span>
<span className={s.colCardType}>{typeDef.label}</span>
<span className={s.colCardWidth}>{mod.widthMm} mm</span>
</button>
);
})}
</div>
{/* Detail tray for expanded column */}
{expandedColumn !== null && config.modules[expandedColumn] && (() => {
const i = expandedColumn;
const mod = config.modules[i];
const typeDef = COLUMN_TYPES.find(t => t.key === mod.type) || COLUMN_TYPES[0];
const shelfN = mod.shelves || 0;
const shelfPositions = resolveShelfPositionsMm({
shelfCount: shelfN,
heightMm: config.heightMm,
plinthHeightMm: config.plinthHeightMm ?? 100,
shelfPositionsMm: mod.shelfPositionsMm
});
const minShelves = minShelvesStructural;
const bodyHmm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 0);
const bodyMidMm = bodyHmm / 2;
const shelfSlots = system32ShelfPositionsMm(config.heightMm, config.plinthHeightMm ?? 100);
const structuralIdx =
minShelves > 0 && shelfPositions.length > 0
? shelfPositions.reduce((bestIdx, posMm, idx, arr) => {
const d = Math.abs(posMm - bodyMidMm);
const best = Math.abs(arr[bestIdx] - bodyMidMm);
return d < best ? idx : bestIdx;
}, 0)
: null;
const hasShelfInStructuralWindow =
minShelves <= 0 ||
shelfPositions.some((p) => p >= bodyMidMm - 200 && p <= bodyMidMm + 200);
const gapMax = Math.min(
SHELF_GAP_MM.max,
maxShelfGapMm(config.heightMm, config.plinthHeightMm ?? 100, shelfN)
);
const gapMin = SHELF_GAP_MM.min;
const canCustomGap = shelfN >= 2 && gapMax >= gapMin;
const handleShelfPosChange = (shelfIndex, rawValue) => {
if (!shelfPositions.length) return;
const next = [...shelfPositions];
next[shelfIndex] = Number(rawValue);
handleColumnUpdate(i, { shelfPositionsMm: next });
};
return (
<div className={s.colDetail}>
<div className={s.colDetailHeader}>
<span className={s.colDetailTitle}>Colonne {i + 1} &mdash; {typeDef.icon} {typeDef.label}</span>
<button
type="button"
className={`${s.lockBtn} ${mod.locked ? s.isLocked : ''}`}
onClick={() => handleColumnLock(i)}
>
{mod.locked ? '\u{1F512} Verrouill\u00e9e' : '\u{1F513} Verrouiller'}
</button>
</div>
{/* Type tiles */}
<div className={s.typeTiles}>
{COLUMN_TYPES.map(t => (
<button
key={t.key}
type="button"
className={`${s.typeTile} ${mod.type === t.key ? s.typeTileActive : ''}`}
onClick={() => handleColumnTypeChange(i, t.key)}
>
{t.icon} {t.label}
</button>
))}
</div>
{/* Shelves */}
<div className={s.detailRow}>
<span className={s.detailLabel}>&Eacute;tag&egrave;res</span>
<div className={s.counter}>
<button
type="button"
className={s.counterBtn}
onClick={() => handleColumnUpdate(i, { shelves: Math.max(minShelves, (mod.shelves || 0) - 1) })}
disabled={(mod.shelves || 0) <= minShelves}
>&minus;</button>
<span className={s.counterValue}>{mod.shelves || 0}</span>
<button
type="button"
className={s.counterBtn}
onClick={() => handleColumnUpdate(i, { shelves: Math.min(10, (mod.shelves || 0) + 1) })}
disabled={(mod.shelves || 0) >= 10}
>+</button>
</div>
</div>
{minShelves >= 1 && (mod.shelves || 0) <= minShelves && (
<p className={s.hint}>
Hauteur &gt; 2&nbsp;m : au moins 1 &eacute;tag&egrave;re par colonne (rigidit&eacute;, assemblage RAZENTZAB). Le compteur ne peut pas descendre en dessous.
</p>
)}
{shelfN >= 2 && (
<div className={s.detailRow}>
<span className={s.detailLabel}>Entre rayons (clair)</span>
{mod.shelfGapMm == null ? (
<div className={s.gapModeRow}>
<span className={s.gapAutoLabel}>Automatique</span>
<button
type="button"
className={s.gapCustomizeBtn}
disabled={!canCustomGap}
onClick={() => handleColumnUpdate(i, {
shelfGapMm: Math.round(Math.min(280, gapMax) / SHELF_GAP_MM.step) * SHELF_GAP_MM.step,
})}
>
Personnaliser
</button>
</div>
) : (
<div className={s.gapModeRow}>
<span className={s.counterValue}>{mod.shelfGapMm} mm</span>
<button
type="button"
className={s.gapCustomizeBtn}
onClick={() => handleColumnUpdate(i, { shelfGapMm: null })}
>
Auto
</button>
</div>
)}
</div>
)}
{shelfN >= 2 && mod.shelfGapMm != null && canCustomGap && (
<div className={s.gapRangeRow}>
<input
type="range"
className={s.gapRange}
min={gapMin}
max={gapMax}
step={SHELF_GAP_MM.step}
value={Math.min(gapMax, Math.max(gapMin, mod.shelfGapMm))}
onChange={(e) => handleColumnUpdate(i, { shelfGapMm: Number(e.target.value) })}
/>
</div>
)}
{shelfN >= 2 && !canCustomGap && (
<p className={s.hint}>Hauteur insuffisante pour fixer l&apos;espacement entre rayons (répartition auto).</p>
)}
{shelfN > 0 && shelfPositions.length > 0 && (
<div className={s.detailRow} style={{ alignItems: 'flex-start' }}>
<span className={s.detailLabel}>Positions rayons</span>
<div style={{ display: 'grid', gap: 8, width: '100%' }}>
{shelfPositions.map((posMm, shelfIdx) => {
let minMm = shelfSlots[shelfIdx] ?? shelfSlots[0];
let maxMm = shelfSlots[shelfSlots.length - (shelfPositions.length - shelfIdx)] ?? shelfSlots[shelfSlots.length - 1];
return (
<div key={`shelf-pos-${shelfIdx}`} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className={s.counterValue} style={{ minWidth: 56 }}>
R{String(shelfIdx + 1)}{structuralIdx === shelfIdx ? '*' : ''}
</span>
<input
type="number"
min={minMm}
max={maxMm}
step={SYSTEM32_PITCH_MM}
value={posMm}
onChange={(e) => handleShelfPosChange(shelfIdx, e.target.value)}
className={s.gapRange}
/>
<span className={s.hint}>mm</span>
</div>
);
})}
</div>
</div>
)}
{structuralIdx !== null && (
<p className={s.hint}>* Rayon structurel recommandé autour du milieu (zone ±200 mm).</p>
)}
{minShelves > 0 && !hasShelfInStructuralWindow && (
<p className={s.warnText}>Aucun rayon dans la zone structurelle ±200 mm autour du milieu. Placez-en un pour la rigidité.</p>
)}
{/* Drawers */}
<div className={s.detailRow}>
<span className={s.detailLabel}>Tiroirs</span>
<div className={s.counter}>
<button
type="button"
className={s.counterBtn}
onClick={() => handleColumnUpdate(i, { drawers: Math.max(0, (mod.drawers || 0) - 1) })}
disabled={(mod.drawers || 0) <= 0}
>&minus;</button>
<span className={s.counterValue}>{mod.drawers || 0}</span>
<button
type="button"
className={s.counterBtn}
onClick={() => handleColumnUpdate(i, { drawers: Math.min(6, (mod.drawers || 0) + 1) })}
disabled={(mod.drawers || 0) >= 6}
>+</button>
</div>
</div>
{(mod.drawers || 0) > 0 && (
<>
<div className={s.detailRow}>
<span className={s.detailLabel}>Type tiroirs</span>
<div className={s.colCountBtns} style={{ flexWrap: 'wrap' }}>
{[
{ key: 'visible', label: 'Visible' },
{ key: 'hidden', label: 'Caché' }
].map((item) => (
<button
key={item.key}
type="button"
className={`${s.colCountBtn} ${hw.drawerType === item.key ? s.colCountActive : ''}`}
onClick={() => wc.setHardware('drawerType', item.key)}
>
{item.label}
</button>
))}
</div>
</div>
<p className={s.hint} style={{ marginTop: 0 }}>
Manufacture depuis le bas. Profondeur utile: {drawerDepth} mm (réglage global).
</p>
</>
)}
{/* Rod */}
<div className={s.detailRow}>
<span className={s.detailLabel}>Tringle</span>
<button
type="button"
className={`${s.toggleBtn} ${mod.hasRod ? s.toggleOn : ''}`}
onClick={() => handleColumnUpdate(i, { hasRod: !mod.hasRod })}
>
{mod.hasRod ? 'Oui' : 'Non'}
</button>
</div>
</div>
);
})()}
<p className={s.summaryLine}>
{config.modules.length} colonne{config.modules.length > 1 ? 's' : ''} &mdash; Largeur totale : {totalModuleWidth} mm
</p>
{hasTallCabinet && <p className={s.warnText}>Armoire &gt; 2m: rayon au milieu obligatoire avec assemblage RAZENTZAB.</p>}
{config.modules.some((m) => (m.drawers || 0) > 0 && (m.widthMm || 0) > 1000) && (
<p className={s.warnText}>Une colonne avec tiroirs dépasse 1000 mm. Limite max appliquée.</p>
)}
{config.modules.some((m) => m.hasRod) && config.heightMm < 1200 && (
<p className={s.warnText}>Rayon avec tringle: hauteur minimale 1200 mm.</p>
)}
<p className={s.hint}>Tirez les s&eacute;parateurs dans la vue 3D pour redimensionner</p>
</>
)}
</div>
);
}
@@ -0,0 +1,196 @@
import s from './steps.module.css';
const DIMS = [
{ key: 'widthMm', label: 'Largeur totale', min: 400, max: 5000, step: 50, unit: 'mm' },
{ key: 'heightMm', label: 'Hauteur', min: 1800, max: 2800, step: 50, unit: 'mm' },
{ key: 'depthMm', label: 'Profondeur', min: 400, max: 800, step: 10, unit: 'mm' },
{ key: 'plinthHeightMm', label: 'Plinthe', min: 60, max: 160, step: 10, unit: 'mm' },
];
export default function StepDimensions({ wc }) {
const { config, set } = wc;
const niche = config.nicheMeasurements || {};
const sideDefaultsEnabled = config.sideFinishesEnabled !== false;
const sideLeftEnabled = config.sideFinishLeftEnabled ?? sideDefaultsEnabled;
const sideRightEnabled = config.sideFinishRightEnabled ?? sideDefaultsEnabled;
const topFinishEnabled = config.topFinishEnabled !== false;
const minNicheWidth = Math.min(niche.widthTopMm || config.widthMm, niche.widthMidMm || config.widthMm, niche.widthBottomMm || config.widthMm);
const minNicheDepth = Math.min(niche.depthTopMm || config.depthMm, niche.depthMidMm || config.depthMm, niche.depthBottomMm || config.depthMm);
const minNicheHeight = Math.min(niche.heightLeftMm || config.heightMm, niche.heightRightMm || config.heightMm);
const gap = Number(config.sanitaryGapMm || 30);
const usableWidth = Math.max(400, minNicheWidth - gap * 2);
const usableDepth = Math.max(400, minNicheDepth - gap * 2);
const usableHeight = Math.max(1800, minNicheHeight - gap * 2);
return (
<div className={s.stepSection}>
<h2 className={s.stepTitle}>Espace &amp; Dimensions</h2>
{/* Installation type */}
<div className={s.cardToggle}>
<button
type="button"
className={`${s.cardToggleItem} ${config.installationType === 'free' ? s.cardToggleActive : ''}`}
onClick={() => set('installationType', 'free')}
>
<span className={s.cardToggleIcon} aria-hidden="true">
<svg className={s.cardToggleIconSvg} viewBox="0 0 24 24" fill="none">
<rect x="4" y="6" width="10" height="12" rx="1.5" />
<path d="M17 4v16" />
<path d="M7.5 10h3M7.5 14h3" />
</svg>
</span>
<span className={s.cardToggleLabel}>Libre</span>
<span className={s.cardToggleHint}>Pos&eacute;e contre un mur</span>
</button>
<button
type="button"
className={`${s.cardToggleItem} ${config.installationType === 'niche' ? s.cardToggleActive : ''}`}
onClick={() => set('installationType', 'niche')}
>
<span className={s.cardToggleIcon} aria-hidden="true">
<svg className={s.cardToggleIconSvg} viewBox="0 0 24 24" fill="none">
<path d="M4 4v16M20 4v16" />
<rect x="8" y="6" width="8" height="12" rx="1.5" />
<path d="M10.5 10h3M10.5 14h3" />
</svg>
</span>
<span className={s.cardToggleLabel}>Niche</span>
<span className={s.cardToggleHint}>Encastr&eacute;e entre deux murs</span>
</button>
</div>
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Type de pièce</label>
<div className={s.typeTiles}>
{[
{ key: 'other', label: 'Autre' },
{ key: 'bathroom', label: 'Salle de bain' },
{ key: 'laundry', label: 'Buanderie' },
{ key: 'technical', label: 'Local technique' }
].map((r) => (
<button
key={r.key}
type="button"
className={`${s.typeTile} ${config.roomType === r.key ? s.typeTileActive : ''}`}
onClick={() => set('roomType', r.key)}
>
{r.label}
</button>
))}
</div>
</div>
{/* Dimension sliders */}
<div className={s.dimGrid}>
{DIMS.map(({ key, label, min, max, step, unit }) => (
<div key={key} className={s.dimField}>
<span className={s.dimLabel}>{label}</span>
<div className={s.dimSliderRow}>
<input
type="range"
className={s.dimSlider}
min={min}
max={max}
step={step}
value={config[key]}
onChange={(e) => set(key, Number(e.target.value))}
/>
<input
type="number"
className={s.dimValue}
min={min}
max={max}
step={step}
value={config[key]}
onChange={(e) => set(key, Number(e.target.value) || min)}
/>
<span className={s.dimUnit}>{unit}</span>
</div>
</div>
))}
</div>
{config.installationType === 'free' && (
<div className={`${s.hardwareGroup} ${s.finishHabillageGroup}`}>
<label className={s.hardwareLabel}>Habillages de finition</label>
<div className={s.finishSelector}>
<button
type="button"
className={`${s.finishOption} ${sideLeftEnabled ? s.finishOptionActive : ''}`}
onClick={() => set('sideFinishLeftEnabled', !sideLeftEnabled)}
>
<span className={s.finishOptionLabel}>C&ocirc;t&eacute; gauche</span>
<span className={s.finishOptionState}>{sideLeftEnabled ? 'Actif' : 'Retir&eacute;'}</span>
</button>
<button
type="button"
className={`${s.finishOption} ${sideRightEnabled ? s.finishOptionActive : ''}`}
onClick={() => set('sideFinishRightEnabled', !sideRightEnabled)}
>
<span className={s.finishOptionLabel}>C&ocirc;t&eacute; droit</span>
<span className={s.finishOptionState}>{sideRightEnabled ? 'Actif' : 'Retir&eacute;'}</span>
</button>
<button
type="button"
className={`${s.finishOption} ${topFinishEnabled ? s.finishOptionActive : ''}`}
onClick={() => set('topFinishEnabled', !topFinishEnabled)}
>
<span className={s.finishOptionLabel}>Dessus</span>
<span className={s.finishOptionState}>{topFinishEnabled ? 'Actif' : 'Retir&eacute;'}</span>
</button>
</div>
</div>
)}
{config.installationType === 'niche' && (
<div className={s.panelInfo}>
<div className={s.panelInfoTitle}>Mesures niche complètes</div>
<div className={s.ruleGrid3}>
<label className={s.dimField}>
<span className={s.dimLabel}>Largeur haut</span>
<input className={s.dimValue} type="number" value={niche.widthTopMm || config.widthMm} onChange={(e) => set('nicheMeasurements', { ...niche, widthTopMm: Number(e.target.value) || config.widthMm })} />
</label>
<label className={s.dimField}>
<span className={s.dimLabel}>Largeur milieu</span>
<input className={s.dimValue} type="number" value={niche.widthMidMm || config.widthMm} onChange={(e) => set('nicheMeasurements', { ...niche, widthMidMm: Number(e.target.value) || config.widthMm })} />
</label>
<label className={s.dimField}>
<span className={s.dimLabel}>Largeur bas</span>
<input className={s.dimValue} type="number" value={niche.widthBottomMm || config.widthMm} onChange={(e) => set('nicheMeasurements', { ...niche, widthBottomMm: Number(e.target.value) || config.widthMm })} />
</label>
<label className={s.dimField}>
<span className={s.dimLabel}>Profondeur haut</span>
<input className={s.dimValue} type="number" value={niche.depthTopMm || config.depthMm} onChange={(e) => set('nicheMeasurements', { ...niche, depthTopMm: Number(e.target.value) || config.depthMm })} />
</label>
<label className={s.dimField}>
<span className={s.dimLabel}>Profondeur milieu</span>
<input className={s.dimValue} type="number" value={niche.depthMidMm || config.depthMm} onChange={(e) => set('nicheMeasurements', { ...niche, depthMidMm: Number(e.target.value) || config.depthMm })} />
</label>
<label className={s.dimField}>
<span className={s.dimLabel}>Profondeur bas</span>
<input className={s.dimValue} type="number" value={niche.depthBottomMm || config.depthMm} onChange={(e) => set('nicheMeasurements', { ...niche, depthBottomMm: Number(e.target.value) || config.depthMm })} />
</label>
<label className={s.dimField}>
<span className={s.dimLabel}>Hauteur gauche</span>
<input className={s.dimValue} type="number" value={niche.heightLeftMm || config.heightMm} onChange={(e) => set('nicheMeasurements', { ...niche, heightLeftMm: Number(e.target.value) || config.heightMm })} />
</label>
<label className={s.dimField}>
<span className={s.dimLabel}>Hauteur droite</span>
<input className={s.dimValue} type="number" value={niche.heightRightMm || config.heightMm} onChange={(e) => set('nicheMeasurements', { ...niche, heightRightMm: Number(e.target.value) || config.heightMm })} />
</label>
<label className={s.dimField}>
<span className={s.dimLabel}>Vide sanitaire (mm)</span>
<input className={s.dimValue} type="number" min={10} max={50} step={1} value={config.sanitaryGapMm || 30} onChange={(e) => set('sanitaryGapMm', Number(e.target.value) || 30)} />
</label>
</div>
<div className={s.panelRow}><span>Plus petite largeur:</span><strong>{minNicheWidth} mm</strong></div>
<div className={s.panelRow}><span>Plus petite profondeur:</span><strong>{minNicheDepth} mm</strong></div>
<div className={s.panelRow}><span>Plus petite hauteur:</span><strong>{minNicheHeight} mm</strong></div>
<div className={s.panelRow}><span>Cote utile retenue:</span><strong>{usableWidth} × {usableHeight} × {usableDepth} mm</strong></div>
</div>
)}
</div>
);
}
@@ -0,0 +1,187 @@
import s from './steps.module.css';
import { getDoorHingeSide } from '@shared/manufacturing.js';
export default function StepDoors({ wc }) {
const { config, set } = wc;
const hw = config.hardware || {};
const handleHeight = Number(hw.handleBoreHeightMm || 1000);
const isNonStandardHandleHeight = handleHeight < 800 || handleHeight > 1200;
const doorCount = Math.max(1, (config.modules?.length || 0) || (config.blocks?.length || 0) || 2);
const hingeSides = Array.isArray(hw.doorHingeSides) ? hw.doorHingeSides : null;
const getHingeSideForDoor = (i) => {
if (hingeSides && hingeSides.length === doorCount) {
return hingeSides[i] === 'right' ? 'right' : 'left';
}
return getDoorHingeSide(i, doorCount, hw);
};
const setDoorHingeSideAt = (i, side) => {
const next = Array.from({ length: doorCount }, (_, idx) => getHingeSideForDoor(idx));
next[i] = side === 'right' ? 'right' : 'left';
wc.setHardware('doorHingeSides', next);
};
return (
<div className={s.stepSection}>
<h2 className={s.stepTitle}>Type de portes</h2>
<p className={s.stepDesc}>Portes battantes uniquement pour le moment. Configurez charni&egrave;res et poign&eacute;es.</p>
<div className={s.cardToggle}>
<button
type="button"
className={`${s.cardToggleItem} ${config.doorType === 'hinged' ? s.cardToggleActive : ''}`}
onClick={() => set('doorType', 'hinged')}
>
<span className={s.cardToggleIcon}>&#x1F6AA;</span>
<span className={s.cardToggleLabel}>Battantes</span>
<span className={s.cardToggleHint}>Portes classiques &agrave; charni&egrave;res</span>
</button>
</div>
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Côté charnières (par porte)</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{Array.from({ length: doorCount }).map((_, i) => {
const active = getHingeSideForDoor(i);
return (
<div key={`door-hinge-${i}`} style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span style={{ minWidth: 88, fontSize: 13, color: '#334155', fontWeight: 600 }}>
Porte {i + 1}
</span>
<div className={s.colCountBtns}>
{[
{ key: 'left', label: 'Gauche' },
{ key: 'right', label: 'Droite' }
].map((item) => (
<button
key={item.key}
type="button"
className={`${s.colCountBtn} ${active === item.key ? s.colCountActive : ''}`}
onClick={() => setDoorHingeSideAt(i, item.key)}
>
{item.label}
</button>
))}
</div>
</div>
);
})}
</div>
</div>
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Valeurs par défaut (si non défini par porte)</label>
<div className={s.detailRow} style={{ marginBottom: 8 }}>
<span className={s.hint}>
Si vous définissez « par porte » ci-dessus, ces réglages ne servent que de fallback.
</span>
</div>
<label className={s.hardwareLabel}>Portes uniques: charnières gauche/droite</label>
<div className={s.colCountBtns}>
{[
{ key: 'left', label: 'Gauche' },
{ key: 'right', label: 'Droite' }
].map((item) => (
<button
key={item.key}
type="button"
className={`${s.colCountBtn} ${hw.doorHingeSide === item.key ? s.colCountActive : ''}`}
onClick={() => wc.setHardware('doorHingeSide', item.key)}
>
{item.label}
</button>
))}
</div>
<label className={s.hardwareLabel} style={{ marginTop: 10 }}>Portes centrales: fiche gauche/droite</label>
<div className={s.colCountBtns}>
{[
{ key: 'left', label: 'Fiche gauche' },
{ key: 'right', label: 'Fiche droite' }
].map((item) => (
<button
key={item.key}
type="button"
className={`${s.colCountBtn} ${hw.centerDoorHingeSide === item.key ? s.colCountActive : ''}`}
onClick={() => wc.setHardware('centerDoorHingeSide', item.key)}
>
{item.label}
</button>
))}
</div>
</div>
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Perçage poignée (axe depuis le sol)</label>
<div className={s.dimSliderRow}>
<input
type="range"
className={s.dimSlider}
min={600}
max={1600}
step={10}
value={handleHeight}
onChange={(e) => wc.setHardware('handleBoreHeightMm', Number(e.target.value))}
/>
<input
type="number"
className={s.dimValue}
min={600}
max={1600}
step={10}
value={handleHeight}
onChange={(e) => wc.setHardware('handleBoreHeightMm', Number(e.target.value) || 1000)}
/>
<span className={s.dimUnit}>mm</span>
</div>
<p className={s.hint}>Plage standard visible sur plan: 1000 mm ± 200 mm.</p>
{isNonStandardHandleHeight && (
<p className={s.warnText}>Configuration non standard: hauteur hors plage standard.</p>
)}
</div>
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Type de poignée</label>
<div className={s.colCountBtns}>
{[
{ key: 'standard', label: 'Standard' },
{ key: 'full_height', label: 'Toute hauteur' }
].map((item) => (
<button
key={item.key}
type="button"
className={`${s.colCountBtn} ${hw.handleType === item.key ? s.colCountActive : ''}`}
onClick={() => wc.setHardware('handleType', item.key)}
>
{item.label}
</button>
))}
</div>
</div>
<div className={s.detailRow}>
<span className={s.detailLabel}>Portes coulissantes</span>
<span className={s.hint}>Indisponible pour le moment (support backend conservé).</span>
</div>
{config.doorType === 'sliding' && (
<div className={s.detailRow}>
<span className={s.detailLabel}>Nombre de vantaux (legacy)</span>
<div className={s.colCountBtns}>
{[2, 3].map((n) => (
<button
key={n}
type="button"
className={`${s.colCountBtn} ${config.slidingPanelsCount === n ? s.colCountActive : ''}`}
onClick={() => set('slidingPanelsCount', n)}
>
{n}
</button>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,239 @@
import { COLUMN_TYPES } from '../../constants/wardrobe';
import { submitOrder } from '../../api/client';
import Icon from '../ui/Icon';
import s from './steps.module.css';
export default function StepFinalize({ wc, ui }) {
const { config, priceResult, serviceOptions, selectedFinish, totalModuleWidth } = wc;
const { orderForm, setOrderForm, orderSent, setOrderSent, setActiveStep } = ui;
const errors = priceResult?.errors || [];
const totalChf = priceResult?.totalChf ?? 0;
const handleSubmitOrder = (e) => {
e.preventDefault();
submitOrder({ config, totalChf: priceResult?.totalChf, ...orderForm })
.then((order) => {
setOrderSent(true);
ui.setLastOrderId(order?.id || null);
})
.catch(alert);
};
// Build summary lines
const installLabel = config.installationType === 'free' ? 'Libre' : 'Niche';
const doorLabel = config.doorType === 'hinged' ? 'Battantes' : `Coulissantes (${config.slidingPanelsCount} vantaux) [legacy]`;
const roomLabel = {
other: 'Autre',
bathroom: 'Salle de bain',
laundry: 'Buanderie',
technical: 'Local technique'
}[config.roomType || 'other'];
const columnsDesc = config.modules.map((m, i) => {
const t = COLUMN_TYPES.find(ct => ct.key === m.type) || COLUMN_TYPES[0];
return `${t.icon} ${t.label} (${m.widthMm}mm)`;
}).join(', ');
return (
<div className={s.stepSection}>
<h2 className={s.stepTitle}>R&eacute;capitulatif &amp; Commande</h2>
{errors.length > 0 && (
<ul className={s.errors}>
{errors.map((err, i) => <li key={i}>{err.message}</li>)}
</ul>
)}
{/* Configuration summary */}
<div className={s.summaryCard}>
<div className={s.summaryRow}>
<span className={s.summaryLabel}>Installation</span>
<span className={s.summaryValue}>
{installLabel} · {roomLabel}
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(1)}>modifier</button>
</span>
</div>
<div className={s.summaryRow}>
<span className={s.summaryLabel}>Dimensions</span>
<span className={s.summaryValue}>
{config.widthMm} &times; {config.heightMm} &times; {config.depthMm} mm
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(1)}>modifier</button>
</span>
</div>
<div className={s.summaryRow}>
<span className={s.summaryLabel}>Colonnes ({config.modules.length})</span>
<span className={s.summaryValue}>
{totalModuleWidth} mm
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(2)}>modifier</button>
</span>
</div>
<div className={s.summaryRow}>
<span className={s.summaryLabel}>Portes</span>
<span className={s.summaryValue}>
{doorLabel}
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(3)}>modifier</button>
</span>
</div>
<div className={s.summaryRow}>
<span className={s.summaryLabel}>Mat&eacute;riau</span>
<span className={s.summaryValue}>
{selectedFinish?.label || 'Non s\u00e9lectionn\u00e9'}
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(4)}>modifier</button>
</span>
</div>
</div>
{/* Panel optimization */}
{priceResult?.details?.panelOptimization && (
<div className={s.panelInfo}>
<div className={s.panelInfoTitle}>Optimisation panneaux</div>
<div className={s.panelRow}>
<span>Format optimal :</span>
<strong>
{priceResult.details.panelOptimization.selectedDimension.width_mm} &times;{' '}
{priceResult.details.panelOptimization.selectedDimension.height_mm} mm
</strong>
</div>
<div className={s.panelRow}>
<span>Panneaux n&eacute;cessaires :</span>
<strong>{priceResult.details.panelOptimization.panelsNeeded}</strong>
</div>
<div className={s.panelRow}>
<span>Surface requise :</span>
<span>{priceResult.surfaceM2?.toFixed(2)} m&sup2;</span>
</div>
<div className={s.panelRow}>
<span>Chute estim&eacute;e :</span>
<span>{priceResult.details.panelOptimization.wasteM2?.toFixed(2)} m&sup2; ({priceResult.details.panelOptimization.wastePercent}%)</span>
</div>
</div>
)}
{/* Manufacturing specs (APEX 4.0) */}
<div className={s.panelInfo}>
<div className={s.panelInfoTitle}>Fabrication APEX 4.0</div>
<div className={s.panelRow}>
<span>Structure / Renforts / Dos :</span>
<strong>19 mm / 16 mm / 8 mm</strong>
</div>
<div className={s.panelRow}>
<span>System 32 :</span>
<strong>&Oslash;5 mm, prof. 12 mm, pas 32 mm</strong>
</div>
<div className={s.panelRow}>
<span>Rainure dos :</span>
<strong>9&times;10.5 mm, &agrave; 16 mm du bord</strong>
</div>
<div className={s.panelRow}>
<span>Connecteurs structure :</span>
<strong>SC 8/60 (extrémités T@20 &rarr; IX@50 &rarr; T@80 · milieu IX + T ±30 mm)</strong>
</div>
<div className={s.panelRow}>
<span>Connecteurs finition :</span>
<strong>SC 8/25 (axes 30/43 mm)</strong>
</div>
<div className={s.panelRow}>
<span>Plinthe :</span>
<strong>AXILO&trade; 78</strong>
</div>
{priceResult?.details?.reinforcementCount > 0 && (
<div className={s.panelRow}>
<span>Renforts Forteresse :</span>
<strong>{priceResult.details.reinforcementCount} pcs (1&times; SC 8/60 + 2&times; tourillons)</strong>
</div>
)}
{priceResult?.details?.reinforcementChf > 0 && (
<div className={s.panelRow}>
<span>Co&ucirc;t renforts :</span>
<strong>CHF {priceResult.details.reinforcementChf.toFixed(2)}</strong>
</div>
)}
{priceResult?.details?.blockExtraStructureChf > 0 && (
<div className={s.panelRow}>
<span>Surplus structure (assemblage block, montants suppl.) :</span>
<strong>CHF {priceResult.details.blockExtraStructureChf.toFixed(2)}</strong>
</div>
)}
</div>
{/* Action buttons */}
<div className={s.actionBtns}>
<button type="button" className={s.actionBtn} onClick={() => ui.setShowPlansView(true)}>
<Icon name="blueprint" size={14} />
Plans de fabrication
</button>
<button type="button" className={s.actionBtn} onClick={() => ui.setShowAIRender(true)}>
<Icon name="sparkles" size={14} />
Rendu IA r&eacute;aliste
</button>
<button type="button" className={s.actionBtn} onClick={() => ui.setShowRoomUpload(true)}>
<Icon name="eye" size={14} />
Visualiser dans ma pi&egrave;ce
</button>
</div>
{/* Service options */}
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Options</label>
{serviceOptions.map((opt) => (
<label key={opt.id} className={s.checkLabel}>
<input
type="checkbox"
checked={(config.serviceOptionIds || []).includes(opt.id)}
onChange={(e) => {
const ids = config.serviceOptionIds || [];
wc.set('serviceOptionIds', e.target.checked ? [...ids, opt.id] : ids.filter((id) => id !== opt.id));
}}
/>
{opt.label} &mdash; CHF {opt.price_chf}/h
</label>
))}
</div>
{/* Price */}
<div className={s.priceBreakdown}>
<div className={s.priceTotal}>
<span>Total</span>
<span>CHF {totalChf.toLocaleString('fr-CH', { minimumFractionDigits: 2 })}.&ndash;</span>
</div>
</div>
{/* Order form */}
{!orderSent ? (
<form onSubmit={handleSubmitOrder} className={s.orderForm}>
<input
type="email"
placeholder="Email *"
value={orderForm.email}
onChange={(e) => setOrderForm((f) => ({ ...f, email: e.target.value }))}
required
/>
<input
type="text"
placeholder="Nom"
value={orderForm.name}
onChange={(e) => setOrderForm((f) => ({ ...f, name: e.target.value }))}
/>
<input
type="tel"
placeholder="T&eacute;l&eacute;phone"
value={orderForm.phone}
onChange={(e) => setOrderForm((f) => ({ ...f, phone: e.target.value }))}
/>
<textarea
placeholder="Remarques"
value={orderForm.notes}
onChange={(e) => setOrderForm((f) => ({ ...f, notes: e.target.value }))}
rows={2}
/>
<button type="submit" className={s.submitBtn}>
<Icon name="file" size={15} />
Commander / Envoyer ma demande
</button>
</form>
) : (
<p className={s.success}>Votre demande a bien &eacute;t&eacute; envoy&eacute;e. Nous vous recontacterons.</p>
)}
</div>
);
}
@@ -0,0 +1,110 @@
import s from './steps.module.css';
export default function StepHardware({ wc }) {
const { config, handleVariants, hingeVariants, blumHardware, setHardware } = wc;
const hasDrawers = config.modules.some(m => m.drawers > 0);
const drawerType = config.hardware?.drawerType || 'visible';
const standardHeights = [60, 90, 128, 177, 240];
const currentHeights = config.hardware?.drawerHeightsMm || [90, 128, 177, 240];
return (
<div className={s.stepSection}>
<h2 className={s.stepTitle}>Quincaillerie</h2>
<p className={s.stepDesc}>S&eacute;lectionnez les poign&eacute;es, charni&egrave;res et syst&egrave;mes de tiroirs.</p>
{/* Handles */}
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Poign&eacute;es</label>
<div className={s.hardwareGrid}>
{handleVariants.map(h => (
<button
key={h.id}
type="button"
className={`${s.hardwareBtn} ${config.hardware.handleVariantId === h.id ? s.hardwareBtnSelected : ''}`}
onClick={() => setHardware('handleVariantId', h.id)}
title={`${h.name} - ${h.price_chf} CHF`}
disabled={drawerType === 'visible' && hasDrawers}
>
{h.image_url ? (
<img src={h.image_url} alt={h.name} />
) : (
<span className={s.hardwareIcon}>&#x1F6AA;</span>
)}
<span className={s.hardwareName}>{h.name}</span>
<span className={s.hardwarePrice}>{h.price_chf} CHF</span>
</button>
))}
</div>
{drawerType === 'visible' && hasDrawers && (
<p className={s.hint}>Tiroir visible avec coulisse Blum Motion: poignée non requise.</p>
)}
</div>
{/* Hinges */}
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Charni&egrave;res</label>
<div className={s.hardwareGrid}>
{hingeVariants.map(h => (
<button
key={h.id}
type="button"
className={`${s.hardwareBtn} ${config.hardware.hingeVariantId === h.id ? s.hardwareBtnSelected : ''}`}
onClick={() => setHardware('hingeVariantId', h.id)}
>
<span className={s.hardwareIcon}>{h.type === 'vissee' ? '\u{1F529}' : '\u{1F4CC}'}</span>
<span className={s.hardwareName}>{h.name}</span>
<span className={s.hardwareType}>{h.type === 'vissee' ? 'Viss\u00e9e' : 'Plant\u00e9e'}</span>
<span className={s.hardwarePrice}>{h.price_chf} CHF</span>
</button>
))}
</div>
</div>
{/* Drawer systems */}
{hasDrawers && (
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Syst&egrave;me de tiroirs (Blum)</label>
<div className={s.hardwareGrid}>
{blumHardware.filter(h => h.type === 'drawer_system').map(h => (
<button
key={h.id}
type="button"
className={`${s.hardwareBtn} ${config.hardware.drawerSystemId === h.id ? s.hardwareBtnSelected : ''}`}
onClick={() => setHardware('drawerSystemId', h.id)}
>
<span className={s.hardwareIcon}>&#x1F5C4;&#xFE0F;</span>
<span className={s.hardwareName}>{h.name}</span>
<span className={s.hardwarePrice}>{h.price_chf} CHF</span>
</button>
))}
</div>
<div className={s.panelInfo}>
<div className={s.panelInfoTitle}>Hauteurs standard tiroirs</div>
<div className={s.typeTiles}>
{standardHeights.map((mm) => {
const active = currentHeights.includes(mm);
return (
<button
key={mm}
type="button"
className={`${s.typeTile} ${active ? s.typeTileActive : ''}`}
onClick={() => {
const next = active
? currentHeights.filter((h) => h !== mm)
: [...currentHeights, mm].sort((a, b) => a - b);
setHardware('drawerHeightsMm', next.slice(0, 4));
}}
>
{mm} mm
</button>
);
})}
</div>
<p className={s.hint}>4 hauteurs standard max (hypothèse codée en dur pour cette version).</p>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,154 @@
import s from './steps.module.css';
const ELEMENT_LABELS = {
portes: 'Portes',
cotes: 'C\u00f4t\u00e9s',
dessus: 'Dessus',
fond: 'Fond',
dos: 'Dos',
etageres: '\u00c9tag\u00e8res',
tiroirs: 'Tiroirs',
separateurs: 'S\u00e9parateurs',
plintheBas: 'Plinthe',
bandeauHaut: 'Bandeau haut',
};
export default function StepMaterials({ wc, ui }) {
const { config, finishes, selectedFinish } = wc;
const roomType = config.roomType || 'other';
const needsHydro = roomType === 'bathroom' || roomType === 'laundry';
const needsFire = roomType === 'technical';
const hasOverride = !!config.allowMaterialOverride;
const normalize = (v) => String(v || '').toLowerCase();
const isHydro = (m) =>
[normalize(m.category), normalize(m.collection), normalize(m.label), normalize(m.name), normalize(m.code)]
.join(' ')
.match(/hydro|hydrofuge|hpl|marine/);
const isFire = (m) =>
[normalize(m.category), normalize(m.collection), normalize(m.label), normalize(m.name), normalize(m.code)]
.join(' ')
.match(/igni|fire|m1|a2|incomb/);
const allowedMaterial = (m) => {
if (hasOverride) return true;
if (needsHydro) return !!isHydro(m);
if (needsFire) return !!isFire(m);
return true;
};
// Quick pick: first 10 finishes with images
const quickPicks = finishes.filter((f) => f.image_url && allowedMaterial(f)).slice(0, 10);
return (
<div className={s.stepSection}>
<h2 className={s.stepTitle}>Mat&eacute;riaux &amp; Couleurs</h2>
<p className={s.stepDesc}>Choisissez le mat&eacute;riau principal et personnalisez les couleurs par &eacute;l&eacute;ment.</p>
{(needsHydro || needsFire) && !hasOverride && (
<p className={s.warnText}>
{needsHydro
? 'Pièce humide: seuls les panneaux hydrofuges sont autorisés.'
: 'Local technique: seuls les panneaux ignifuges sont autorisés.'}
</p>
)}
{(needsHydro || needsFire) && hasOverride && (
<p className={s.warnText}>Dérogation active: tous les matériaux sont affichés (configuration non standard).</p>
)}
{(needsHydro || needsFire) && (
<label className={s.overrideToggle}>
<input
type="checkbox"
checked={hasOverride}
onChange={(e) => wc.set('allowMaterialOverride', e.target.checked)}
/>
<span>
<strong>Dérogation matériaux</strong>
<small>Débloquer tous les matériaux avec avertissement.</small>
</span>
</label>
)}
{/* Current material */}
{selectedFinish && (
<div className={s.currentMaterial}>
{selectedFinish.image_url ? (
<img src={selectedFinish.image_url} alt={selectedFinish.label} className={s.currentMaterialImg} />
) : (
<div className={s.quickSwatchNoImg}>?</div>
)}
<div className={s.currentMaterialInfo}>
<strong>{selectedFinish.label}</strong>
<span>{selectedFinish.code} &mdash; {selectedFinish.collection}</span>
</div>
</div>
)}
{/* Quick pick row */}
<div className={s.quickPick}>
{quickPicks.map(mat => (
<button
key={mat.id}
type="button"
className={`${s.quickSwatch} ${mat.id === config.finishId ? s.quickSwatchActive : ''}`}
onClick={() => {
ui.openMaterialDetail(mat);
}}
title={mat.label}
>
<img src={mat.image_url} alt={mat.label} className={s.quickSwatchImg} />
<span className={s.quickSwatchLabel}>{mat.label}</span>
</button>
))}
</div>
<button
type="button"
className={s.browseBtn}
onClick={() => ui.setShowMaterialPicker(true)}
>
{(needsHydro || needsFire) && !hasOverride
? 'Parcourir les matériaux autorisés'
: 'Parcourir tous les mat&eacute;riaux'}
</button>
<div className={s.panelInfo}>
<div className={s.panelInfoTitle}>Contraintes chants et finitions</div>
<div className={s.panelRow}>
<span>Bande de chant:</span>
<strong>même couleur que la face extérieure</strong>
</div>
<div className={s.panelRow}>
<span>Mode libre:</span>
<strong>couleur doublage au choix</strong>
</div>
<div className={s.panelRow}>
<span>Mode encastré:</span>
<strong>fileurs + bandeau couleur face extérieure</strong>
</div>
</div>
{/* Per-element color dots */}
<div className={s.colorDotsTitle}>Couleur par &eacute;l&eacute;ment</div>
<div className={s.colorDots}>
{Object.entries(ELEMENT_LABELS).map(([key, label]) => {
const c = config.colors[key];
return (
<button
key={key}
type="button"
className={s.colorDot}
onClick={() => ui.openColorPicker(key, label)}
title={`${label}: ${c?.label || 'Non d\u00e9fini'}`}
>
<span
className={s.colorDotSwatch}
style={{ background: c?.hexColor || '#ccc' }}
/>
{label}
</button>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,29 @@
import { STEPS } from '../../constants/wardrobe';
import styles from './StepperBar.module.css';
export default function StepperBar({ activeStep, onStepChange, totalChf, touched }) {
return (
<div className={styles.stepperBar}>
{STEPS.map(({ id, label }) => (
<button
key={id}
type="button"
className={`${styles.step} ${activeStep === id ? styles.stepActive : ''}`}
onClick={() => onStepChange(id)}
>
<span className={styles.stepNum}>
{touched?.has(id) && activeStep !== id ? (
<span className={styles.stepCheck}>&#10003;</span>
) : id}
</span>
<span className={styles.stepLabel}>{label}</span>
</button>
))}
<span className={styles.spacer} />
<div className={styles.priceBadge}>
<span className={styles.priceLabel}>CHF</span>
{(totalChf || 0).toLocaleString('fr-CH', { minimumFractionDigits: 0 })}.&ndash;
</div>
</div>
);
}
@@ -0,0 +1,96 @@
.stepperBar {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 1rem;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
position: -webkit-sticky;
position: sticky;
top: 0;
z-index: 60;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.stepperBar::-webkit-scrollbar { display: none; }
.step {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.85rem;
border: none;
background: transparent;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
flex-shrink: 0;
}
.step:hover { background: var(--bg); color: var(--text); }
.stepActive {
background: var(--action);
color: #fff;
}
.stepActive:hover {
background: var(--action);
color: #fff;
}
.stepNum {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 700;
background: var(--border);
color: var(--text-muted);
flex-shrink: 0;
}
.stepActive .stepNum {
background: rgba(255,255,255,0.25);
color: #fff;
}
.stepCheck {
color: var(--success, #22c55e);
font-size: 0.75rem;
}
.spacer { flex: 1; min-width: 0.5rem; }
.priceBadge {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.85rem;
background: var(--primary);
color: #fff;
border-radius: 6px;
font-size: 0.9375rem;
font-weight: 700;
white-space: nowrap;
flex-shrink: 0;
letter-spacing: -0.01em;
}
.priceLabel {
font-weight: 400;
font-size: 0.75rem;
opacity: 0.8;
}
@media (max-width: 700px) {
.stepperBar { padding: 0.4rem 0.5rem; gap: 0.15rem; }
.step { padding: 0.4rem 0.6rem; font-size: 0.75rem; }
.stepLabel { display: none; }
.priceBadge { font-size: 0.8125rem; padding: 0.35rem 0.65rem; }
}
@@ -0,0 +1,987 @@
/* Shared step styles */
.stepSection {
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.stepTitle {
font-size: 1.1rem;
font-weight: 700;
color: var(--primary);
margin: 0 0 1rem;
}
.stepDesc {
font-size: 0.8125rem;
color: var(--text-muted);
margin: -0.5rem 0 1rem;
}
.stepDescExtra {
display: block;
margin-top: 0.4rem;
font-size: 0.78rem;
line-height: 1.35;
}
/* Card toggle (installation type, door type) */
.cardToggle {
display: flex;
gap: 0.8rem;
margin-bottom: 1.25rem;
}
.cardToggleItem {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.15rem;
min-height: 5.85rem;
padding: 0.9rem 1rem;
border: 2px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
cursor: pointer;
text-align: center;
transition: all 0.15s;
}
.cardToggleItem:hover { border-color: var(--text-muted); }
.cardToggleActive {
border-color: var(--action);
background: color-mix(in srgb, var(--action) 6%, var(--bg-card));
}
.cardToggleIcon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
line-height: 1;
}
.cardToggleIconSvg {
width: 2rem;
height: 2rem;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.cardToggleLabel {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
line-height: 1.25;
}
.cardToggleHint {
display: block;
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.12rem;
line-height: 1.3;
}
.cardToggleActive .cardToggleIcon {
color: var(--action);
}
/* Dimension sliders */
.dimGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.dimField {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.dimLabel {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
}
.dimSliderRow {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dimSlider {
flex: 1;
accent-color: var(--action);
height: 6px;
}
.dimValue {
min-width: 4.5rem;
padding: 0.35rem 0.5rem;
border: 1px solid var(--border);
border-radius: 5px;
font-size: 0.8125rem;
text-align: right;
font-variant-numeric: tabular-nums;
}
.dimUnit {
font-size: 0.75rem;
color: var(--text-muted);
}
/* Column count row */
.colCountRow {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.colCountLabel {
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
}
.colCountBtns {
display: flex;
gap: 0.25rem;
align-items: center;
}
.colCountBtn {
min-width: 2rem;
height: 2rem;
padding: 0 0.55rem;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
font-weight: 600;
font-size: 0.875rem;
white-space: nowrap;
cursor: pointer;
transition: all 0.15s;
}
.colCountBtn:hover { border-color: var(--action); }
.colCountActive {
background: var(--action);
border-color: var(--action);
color: #fff;
}
.optimizeBtn {
padding: 0.4rem 0.75rem;
border: 1px solid var(--action);
border-radius: 6px;
background: transparent;
color: var(--action);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
margin-left: 0.35rem;
transition: all 0.15s;
}
.optimizeBtn:hover { background: var(--action); color: #fff; }
.optimizeBtn:disabled { opacity: 0.5; cursor: default; }
.aiMsg {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
background: color-mix(in srgb, var(--action) 8%, var(--bg));
border-radius: 6px;
font-size: 0.8125rem;
color: var(--text);
margin-bottom: 0.75rem;
}
.aiBadge {
display: inline-block;
background: var(--action);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
padding: 0.15rem 0.35rem;
border-radius: 3px;
flex-shrink: 0;
margin-top: 0.1rem;
}
.aiDismiss {
margin-left: auto;
background: none;
border: none;
font-size: 1rem;
color: var(--text-muted);
cursor: pointer;
padding: 0;
line-height: 1;
}
/* Column strip (horizontal cards mirroring 3D) */
.columnStripLegend {
font-size: 0.72rem;
color: var(--text-muted);
margin: 0 0 0.45rem;
line-height: 1.35;
}
.columnStrip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(4.85rem, 1fr));
gap: 0.4rem;
margin-bottom: 0.75rem;
width: 100%;
min-width: 0;
}
/* Many columns: allow horizontal scroll instead of squashing */
@media (max-width: 520px) {
.columnStrip {
display: flex;
overflow-x: auto;
padding-bottom: 0.35rem;
-webkit-overflow-scrolling: touch;
}
.colCard {
flex: 0 0 auto;
min-width: 5.25rem;
}
}
.colCard {
flex-shrink: 0;
min-width: 0;
width: 100%;
padding: 0.45rem 0.45rem 0.5rem;
border: 2px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
cursor: grab;
text-align: center;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s, opacity 0.15s;
position: relative;
}
.colCard:active { cursor: grabbing; }
.colCard:hover { border-color: var(--text-muted); }
.colCardActive { border-color: var(--action); background: color-mix(in srgb, var(--action) 6%, var(--bg-card)); }
.colCardLocked { opacity: 0.7; }
.colCardDragging {
opacity: 0.55;
cursor: grabbing;
}
.colCardDropTarget {
border-color: var(--action);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--action) 35%, transparent);
background: color-mix(in srgb, var(--action) 10%, var(--bg-card));
}
.colCardIndex {
display: block;
font-size: 0.65rem;
font-weight: 800;
letter-spacing: 0.02em;
color: var(--action);
line-height: 1.15;
margin-bottom: 0.2rem;
user-select: none;
}
.colDragHint {
display: block;
font-size: 0.55rem;
font-weight: 800;
letter-spacing: 0.06em;
color: var(--text-muted);
line-height: 1;
margin-bottom: 0.15rem;
opacity: 0.75;
user-select: none;
}
.colCardIcon { font-size: 1.2rem; display: block; }
.colCardType { font-size: 0.6875rem; font-weight: 600; color: var(--text); margin-top: 0.15rem; }
.colCardWidth { font-size: 0.625rem; color: var(--text-muted); }
.colLockIcon {
position: absolute;
top: 0.2rem;
right: 0.25rem;
font-size: 0.6rem;
}
/* Column detail tray (expanded below strip) */
.colDetail {
padding: 1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
margin-bottom: 0.75rem;
animation: fadeIn 0.15s ease-out;
}
.colDetailHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.colDetailTitle {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
}
.lockBtn {
background: none;
border: 1px solid var(--border);
border-radius: 5px;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
cursor: pointer;
color: var(--text-muted);
}
.lockBtn:hover { border-color: var(--text); }
.isLocked { background: var(--bg); }
/* Type selector tiles */
.typeTiles {
display: flex;
gap: 0.35rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.typeTile {
padding: 0.4rem 0.7rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
cursor: pointer;
font-size: 0.75rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.3rem;
transition: all 0.15s;
}
.typeTile:hover { border-color: var(--action); }
.typeTileActive {
border-color: var(--action);
background: color-mix(in srgb, var(--action) 10%, var(--bg));
color: var(--action);
}
/* Counter rows */
.detailRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 0;
}
.detailLabel {
font-size: 0.8125rem;
color: var(--text);
}
.counter {
display: flex;
align-items: center;
gap: 0.35rem;
}
.counterBtn {
width: 1.6rem;
height: 1.6rem;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-card);
cursor: pointer;
font-size: 0.875rem;
color: var(--text);
transition: all 0.1s;
}
.counterBtn:hover:not(:disabled) { border-color: var(--action); }
.counterBtn:disabled { opacity: 0.3; cursor: default; }
.counterValue {
min-width: 1.5rem;
text-align: center;
font-weight: 600;
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
}
.gapModeRow {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.gapAutoLabel {
font-size: 0.8125rem;
color: var(--text-muted);
}
.gapCustomizeBtn {
padding: 0.25rem 0.55rem;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-card);
cursor: pointer;
font-size: 0.75rem;
color: var(--action);
transition: border-color 0.15s, background 0.15s;
}
.gapCustomizeBtn:hover:not(:disabled) {
border-color: var(--action);
background: color-mix(in srgb, var(--action) 8%, var(--bg-card));
}
.gapCustomizeBtn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.gapRangeRow {
padding: 0 0 0.35rem;
margin-top: -0.15rem;
}
.gapRange {
width: 100%;
accent-color: var(--action);
}
/* Toggle button */
.toggleBtn {
padding: 0.3rem 0.7rem;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg);
cursor: pointer;
font-size: 0.8125rem;
color: var(--text-muted);
transition: all 0.15s;
}
.toggleOn {
background: var(--action);
border-color: var(--action);
color: #fff;
}
/* Summary line */
.summaryLine {
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
.hint {
font-size: 0.75rem;
color: var(--text-muted);
opacity: 0.7;
margin-top: 0.25rem;
}
/* Hardware grid */
.hardwareGroup {
margin-bottom: 1rem;
}
/** Extra air above habillage row (after dimension sliders). */
.finishHabillageGroup {
padding-top: 0.85rem;
margin-top: 0.35rem;
}
.hardwareLabel {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
display: block;
margin-bottom: 0.5rem;
}
.hardwareGrid {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.hardwareBtn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.6rem 0.75rem;
border: 2px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
cursor: pointer;
min-width: 5.5rem;
transition: all 0.15s;
}
.hardwareBtn:hover { border-color: var(--text-muted); }
.hardwareBtnSelected {
border-color: var(--action);
background: color-mix(in srgb, var(--action) 6%, var(--bg-card));
}
.hardwareBtn img {
width: 3rem;
height: 3rem;
object-fit: contain;
border-radius: 4px;
}
.hardwareIcon { font-size: 1.5rem; }
.hardwareName { font-size: 0.6875rem; font-weight: 600; color: var(--text); text-align: center; }
.hardwareType { font-size: 0.625rem; color: var(--text-muted); }
.hardwarePrice { font-size: 0.6875rem; color: var(--action); font-weight: 500; }
.skipBtn {
padding: 0.4rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text-muted);
font-size: 0.8125rem;
cursor: pointer;
margin-bottom: 1rem;
}
.skipBtn:hover { color: var(--text); border-color: var(--text-muted); }
/* Material quick pick */
.quickPick {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.35rem;
margin-bottom: 1rem;
-webkit-overflow-scrolling: touch;
}
.quickSwatch {
flex-shrink: 0;
width: 4.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
cursor: pointer;
border: 2px solid transparent;
border-radius: 8px;
padding: 0.35rem;
transition: all 0.15s;
}
.quickSwatch:hover { border-color: var(--text-muted); }
.quickSwatchActive { border-color: var(--action); }
.quickSwatchImg {
width: 3.5rem;
height: 3.5rem;
border-radius: 6px;
object-fit: cover;
background: var(--bg);
}
.quickSwatchNoImg {
width: 3.5rem;
height: 3.5rem;
border-radius: 6px;
background: var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
color: var(--text-muted);
}
.quickSwatchLabel {
font-size: 0.5625rem;
color: var(--text);
text-align: center;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.browseBtn {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
margin-bottom: 1rem;
}
.browseBtn:hover { border-color: var(--action); color: var(--action); }
.currentMaterial {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 0.75rem;
background: var(--bg-card);
}
.currentMaterialImg {
width: 3rem;
height: 3rem;
border-radius: 6px;
object-fit: cover;
}
.currentMaterialInfo {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.currentMaterialInfo strong { font-size: 0.875rem; }
.currentMaterialInfo span { font-size: 0.75rem; color: var(--text-muted); }
/* Element color dots */
.colorDotsTitle {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.5rem;
}
.colorDots {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.colorDot {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.55rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
cursor: pointer;
font-size: 0.6875rem;
color: var(--text);
transition: all 0.15s;
}
.colorDot:hover { border-color: var(--action); }
.colorDotSwatch {
width: 1rem;
height: 1rem;
border-radius: 3px;
border: 1px solid rgba(0,0,0,0.1);
flex-shrink: 0;
}
/* Finalize: summary card */
.summaryCard {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
padding: 1rem;
margin-bottom: 1rem;
}
.summaryRow {
display: flex;
justify-content: space-between;
padding: 0.35rem 0;
font-size: 0.8125rem;
border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
}
.summaryRow:last-child { border-bottom: none; }
.summaryLabel { color: var(--text-muted); }
.summaryValue { color: var(--text); font-weight: 500; }
.summaryEdit {
font-size: 0.6875rem;
color: var(--action);
cursor: pointer;
background: none;
border: none;
padding: 0;
margin-left: 0.5rem;
}
.summaryEdit:hover { text-decoration: underline; }
/* Price breakdown */
.priceBreakdown {
margin-bottom: 1rem;
}
.priceRow {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
font-size: 0.8125rem;
}
.priceTotal {
display: flex;
justify-content: space-between;
padding: 0.6rem 0;
font-size: 1.1rem;
font-weight: 700;
border-top: 2px solid var(--primary);
margin-top: 0.35rem;
}
/* Action buttons */
.actionBtns {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.actionBtn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.85rem;
border: 1px solid var(--action);
border-radius: 9px;
background: var(--bg-card);
color: var(--action);
font-size: 0.8rem;
font-weight: 600;
transition: all 0.15s ease;
}
.actionBtn:hover {
box-shadow: 0 8px 18px rgba(79, 70, 229, 0.18);
transform: translateY(-1px);
}
/* Order form */
.orderForm {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.orderForm input,
.orderForm textarea {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.875rem;
}
.orderForm textarea { resize: vertical; }
.submitBtn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.7rem 1.25rem;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, var(--action), var(--action-hover));
color: #fff;
font-size: 0.95rem;
font-weight: 600;
transition: all 0.15s ease;
margin-top: 0.25rem;
}
.submitBtn:hover {
box-shadow: 0 10px 22px rgba(79, 70, 229, 0.3);
transform: translateY(-1px);
}
.success {
padding: 1rem;
background: color-mix(in srgb, var(--success, #22c55e) 10%, var(--bg-card));
border-radius: 8px;
color: var(--text);
font-size: 0.875rem;
text-align: center;
}
.errors {
list-style: none;
padding: 0;
margin: 0 0 0.75rem;
}
.errors li {
padding: 0.35rem 0.6rem;
background: color-mix(in srgb, #ef4444 10%, var(--bg-card));
border-radius: 5px;
font-size: 0.8125rem;
color: #ef4444;
margin-bottom: 0.25rem;
}
/* Checkboxes for service options */
.checkLabel {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
}
/* Panel optimization info */
.panelInfo {
padding: 0.75rem;
background: var(--bg);
border-radius: 6px;
margin-bottom: 0.75rem;
}
.panelInfoTitle {
font-size: 0.8125rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.panelRow {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
padding: 0.15rem 0;
}
.finishSelector {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.45rem;
align-items: stretch;
}
.finishOption {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
min-width: 0;
padding: 0.5rem 0.45rem;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
}
.finishOption:hover {
border-color: var(--action);
transform: translateY(-1px);
}
.finishOptionActive {
border-color: var(--action);
background: color-mix(in srgb, var(--action) 10%, var(--bg-card));
box-shadow: 0 8px 18px color-mix(in srgb, var(--action) 20%, transparent);
}
.finishOptionLabel {
font-size: 0.75rem;
font-weight: 600;
color: var(--text);
line-height: 1.2;
text-align: left;
word-break: break-word;
}
.finishOptionState {
font-size: 0.68rem;
color: var(--text-muted);
}
.ruleGrid3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.warnText {
font-size: 0.8125rem;
color: #b45309;
background: color-mix(in srgb, #f59e0b 10%, var(--bg-card));
border: 1px solid color-mix(in srgb, #f59e0b 30%, var(--border));
border-radius: 6px;
padding: 0.4rem 0.6rem;
margin: 0.4rem 0;
}
.overrideToggle {
display: flex;
align-items: flex-start;
gap: 0.55rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
margin: 0.4rem 0 0.75rem;
cursor: pointer;
}
.overrideToggle input {
margin-top: 0.15rem;
}
.overrideToggle span {
display: flex;
flex-direction: column;
gap: 0.12rem;
font-size: 0.8125rem;
color: var(--text);
}
.overrideToggle small {
color: var(--text-muted);
font-size: 0.75rem;
}
@media (max-width: 600px) {
.dimGrid { grid-template-columns: 1fr; }
.cardToggle { flex-direction: column; }
.columnStrip { gap: 0.25rem; }
.ruleGrid3 { grid-template-columns: 1fr; }
}
/* Compact mode for desktop split-panel (keeps everything visible) */
@media (min-width: 1000px) {
.stepTitle {
font-size: 1rem;
margin-bottom: 0.65rem;
}
.stepDesc {
margin: -0.35rem 0 0.65rem;
font-size: 0.78rem;
}
.cardToggle {
gap: 0.5rem;
margin-bottom: 0.85rem;
}
.cardToggleItem {
min-height: 4.6rem;
padding: 0.65rem 0.75rem;
border-radius: 9px;
}
.cardToggleLabel {
font-size: 0.82rem;
}
.cardToggleHint {
font-size: 0.7rem;
}
.dimGrid {
gap: 0.75rem;
}
/* Prevent right-column slider rows from being clipped in narrow 1/3 panel */
.dimSliderRow {
display: grid;
grid-template-columns: minmax(0, 1fr) 4.1rem auto;
gap: 0.45rem;
align-items: center;
}
.dimSlider {
min-width: 0;
}
.dimLabel {
font-size: 0.78rem;
}
.dimValue {
min-width: 4.1rem;
padding: 0.3rem 0.45rem;
font-size: 0.78rem;
}
.colCountRow {
margin-bottom: 0.6rem;
gap: 0.6rem;
}
.aiMsg {
padding: 0.5rem 0.65rem;
margin-bottom: 0.6rem;
}
}
+89
View File
@@ -0,0 +1,89 @@
export default function Icon({ name, size = 16, className = '' }) {
const props = {
width: size,
height: size,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
strokeLinecap: 'round',
strokeLinejoin: 'round',
'aria-hidden': 'true',
className
};
switch (name) {
case 'logout':
return (
<svg {...props}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</svg>
);
case 'sync':
return (
<svg {...props}>
<path d="M3 12a9 9 0 0 1 15.5-6.4L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-15.5 6.4L3 16" />
<path d="M3 21v-5h5" />
</svg>
);
case 'eye':
return (
<svg {...props}>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
case 'download':
return (
<svg {...props}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="m7 10 5 5 5-5" />
<path d="M12 15V3" />
</svg>
);
case 'trash':
return (
<svg {...props}>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
);
case 'file':
return (
<svg {...props}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
</svg>
);
case 'blueprint':
return (
<svg {...props}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
);
case 'sparkles':
return (
<svg {...props}>
<path d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8Z" />
<path d="M5 3v3M3.5 4.5h3M19 16v5M16.5 18.5h5" />
</svg>
);
case 'x':
return (
<svg {...props}>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
default:
return null;
}
}
+228
View File
@@ -0,0 +1,228 @@
import { PANEL } from '@shared/manufacturing.js';
/** Inner carcass height (mm): floor to ceiling inside side panels. */
export function innerCarcassHeightMm(heightMm, plinthHeightMm) {
return heightMm - plinthHeightMm - 2 * PANEL.structure;
}
/** Max clear vertical gap (mm) between shelf faces for n shelves; n >= 2. */
export function maxShelfGapMm(heightMm, plinthHeightMm, shelfCount) {
const H = innerCarcassHeightMm(heightMm, plinthHeightMm);
const n = shelfCount;
if (n < 2) return 0;
const t = PANEL.structure;
if (H <= n * t) return 0;
return Math.floor((H - n * t) / (n - 1));
}
export const SHELF_GAP_MM = { min: 80, max: 520, step: 10 };
export const SYSTEM32_PITCH_MM = 32;
export const SYSTEM32_FIRST_HOLE_MM = 51;
/** Above this carcass height (mm), at least one shelf per column is required (rigidité / RAZENTZAB). */
export const STRUCTURAL_MIN_SHELF_HEIGHT_MM = 2000;
/** Minimum shelf count for a column at this height (0 or 1). */
export function minShelvesForStructuralHeight(heightMm) {
return (Number(heightMm) || 0) > STRUCTURAL_MIN_SHELF_HEIGHT_MM ? 1 : 0;
}
/**
* Shelf center Y (meters), wardrobe group space — matches WardrobePreview formulas.
* @param {number|null|undefined} shelfGapMm — clear opening between shelf faces; null = equal division (default).
*/
export function shelfCenterYsMeters({
shelfCount,
heightM,
plinthHM,
shelfGapMm,
shelfPositionsMm,
}) {
const T = PANEL.structure / 1000;
const bodyH = heightM - plinthHM;
const H_inner = bodyH - 2 * T;
const yBase = -heightM / 2 + plinthHM + T;
if (shelfCount <= 0) return [];
if (Array.isArray(shelfPositionsMm) && shelfPositionsMm.length === shelfCount) {
return shelfPositionsMm.map((posMm) => -heightM / 2 + plinthHM + Number(posMm) / 1000);
}
const equal = () =>
Array.from({ length: shelfCount }, (_, i) =>
yBase + (H_inner / (shelfCount + 1)) * (i + 1)
);
if (shelfCount === 1 || shelfGapMm == null || !Number.isFinite(Number(shelfGapMm))) {
return equal();
}
const n = shelfCount;
const gReq = Number(shelfGapMm) / 1000;
const maxG = n > 1 ? (H_inner - n * T) / (n - 1) : 0;
if (n > 1 && !(maxG > 0)) {
return equal();
}
const gUse = n > 1 ? Math.max(0, Math.min(gReq, maxG)) : 0;
const needed = n * T + (n > 1 ? (n - 1) * gUse : 0);
const m = (H_inner - needed) / 2;
return Array.from({ length: n }, (_, i) =>
yBase + m + T / 2 + i * (T + (n > 1 ? gUse : 0))
);
}
export function system32ShelfPositionsMm(heightMm, plinthHeightMm) {
const bodyH = (Number(heightMm) || 0) - (Number(plinthHeightMm) || 0);
if (!(bodyH > 0)) return [];
const minPos = SYSTEM32_FIRST_HOLE_MM;
const maxPos = bodyH - SYSTEM32_FIRST_HOLE_MM;
if (maxPos < minPos) return [];
const count = Math.floor((maxPos - minPos) / SYSTEM32_PITCH_MM) + 1;
return Array.from({ length: Math.max(0, count) }, (_, i) => minPos + i * SYSTEM32_PITCH_MM);
}
export function resolveShelfPositionsMm({
shelfCount,
heightMm,
plinthHeightMm,
shelfPositionsMm
}) {
const n = Math.max(0, Number(shelfCount) || 0);
if (n <= 0) return [];
const slots = system32ShelfPositionsMm(heightMm, plinthHeightMm);
if (slots.length <= 0) return [];
if (n >= slots.length) return slots.slice(0, n);
const toNearestIndex = (valueMm) => {
const v = Number(valueMm);
if (!Number.isFinite(v)) return null;
let bestIdx = 0;
let bestDist = Infinity;
for (let i = 0; i < slots.length; i++) {
const d = Math.abs(slots[i] - v);
if (d < bestDist) {
bestDist = d;
bestIdx = i;
}
}
return bestIdx;
};
let indexes = [];
if (Array.isArray(shelfPositionsMm) && shelfPositionsMm.length === n) {
indexes = shelfPositionsMm.map(toNearestIndex);
} else {
indexes = Array.from({ length: n }, (_, i) => {
const t = ((i + 1) * (slots.length + 1)) / (n + 1);
return Math.max(0, Math.min(slots.length - 1, Math.round(t) - 1));
});
}
for (let i = 0; i < n; i++) {
const minIdx = i;
const idx = Number.isFinite(indexes[i]) ? indexes[i] : minIdx;
indexes[i] = Math.max(minIdx, Math.min(slots.length - (n - i), idx));
}
for (let i = n - 2; i >= 0; i--) {
indexes[i] = Math.min(indexes[i], indexes[i + 1] - 1);
}
return indexes.map((idx) => slots[idx]);
}
// Column element types -- single source of truth
export const COLUMN_TYPES = [
{ key: 'penderie', label: 'Penderie', icon: '\u{1F454}', shelves: 1, drawers: 0, hasRod: true, defaultWidth: 600, minWidth: 400, maxWidth: 1200 },
{ key: 'tiroirs', label: 'Tiroirs', icon: '\u{1F5C4}\uFE0F', shelves: 0, drawers: 3, hasRod: false, defaultWidth: 500, minWidth: 400, maxWidth: 800 },
{ key: 'rayonnage', label: 'Rayonnage', icon: '\u{1F4DA}', shelves: 4, drawers: 0, hasRod: false, defaultWidth: 500, minWidth: 400, maxWidth: 900 },
{ key: 'bloc_carre', label: 'Bloc carr\u00e9', icon: '\u2B1C', shelves: 0, drawers: 0, hasRod: false, defaultWidth: 400, minWidth: 300, maxWidth: 600 },
{ key: 'mixte', label: 'Mixte', icon: '\u{1F4E6}', shelves: 2, drawers: 2, hasRod: true, defaultWidth: 600, minWidth: 400, maxWidth: 1000 },
];
// Default cycling types when creating new columns
export const DEFAULT_CYCLE = ['penderie', 'rayonnage', 'tiroirs'];
// Default colors for each element type — must include ALL element types rendered in 3D
export const DEFAULT_COLORS = {
plintheBas: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#8b7355' },
fileur: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#8b7355' },
bandeauHaut: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#8b7355' },
champs: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#6b5344' },
caisse: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
cotesDoublage: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
habillageFinitionGauche: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
habillageFinitionDroite: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
habillageFinitionDessus: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
cotes: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#b09060' },
dessus: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
fond: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#d4c4a4' },
dos: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#d4c4a4' },
portes: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#a08060' },
panneauxInterieurs: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#d4c4a4' },
separateurs: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#6b5344' },
etageres: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#d4c4a4' },
rayonFixe: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#d4c4a4' },
renfortsTiroir: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
tiroirs: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
penderie: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#8b7355' }
};
export const DEFAULT_CONFIG = {
roomType: 'other', // other | bathroom | laundry | technical
allowMaterialOverride: false,
installationType: 'free',
widthMm: 2400,
heightMm: 2400,
depthMm: 600,
plinthHeightMm: 100,
exteriorFinishesEnabled: true,
sideFinishesEnabled: true,
sideFinishLeftEnabled: true,
sideFinishRightEnabled: true,
topFinishEnabled: true,
nicheMeasurements: {
widthTopMm: 2400,
widthMidMm: 2400,
widthBottomMm: 2400,
depthTopMm: 600,
depthMidMm: 600,
depthBottomMm: 600,
heightLeftMm: 2400,
heightRightMm: 2400
},
sanitaryGapMm: 30,
modules: [],
columnMode: 'separations', // separations | block
colors: { ...DEFAULT_COLORS },
hardware: {
handleVariantId: null,
hingeVariantId: null,
drawerSystemId: null,
/**
* Optional per-door hinge side override.
* When present and length matches door count, each entry is 'left'|'right' for door i.
*/
doorHingeSides: null,
doorHingeSide: 'left', // left | right
centerDoorHingeSide: 'left', // left | right
handleBoreHeightMm: 1000,
handleType: 'standard', // standard | full_height
drawerType: 'visible', // visible | hidden
drawerHeightsMm: [90, 128, 177, 240]
},
doorType: 'hinged',
slidingPanelsCount: 2,
finishId: null,
dimensionId: null,
serviceOptionIds: []
};
export const STEPS = [
{ id: 1, key: 'espace', label: 'Espace' },
{ id: 2, key: 'colonnes', label: 'Colonnes' },
{ id: 3, key: 'portes', label: 'Portes' },
{ id: 4, key: 'materiaux', label: 'Mat\u00e9riaux' },
{ id: 5, key: 'quincaillerie', label: 'Quincaillerie' },
{ id: 6, key: 'finaliser', label: 'Finaliser' },
];
+126
View File
@@ -0,0 +1,126 @@
import { useState, useEffect, useCallback } from 'react';
const SESSION_STEP_KEY = 'apex_wardrobe_step';
const PLANS_OPEN_KEY = 'apex.plans.open';
function readPlansOpenFlag() {
try {
if (localStorage.getItem(PLANS_OPEN_KEY) === '1') return true;
if (sessionStorage.getItem(PLANS_OPEN_KEY) === '1') {
localStorage.setItem(PLANS_OPEN_KEY, '1');
sessionStorage.removeItem(PLANS_OPEN_KEY);
return true;
}
} catch {
// ignore
}
return false;
}
export default function useConfiguratorUI() {
const [activeStep, setActiveStep] = useState(() => {
try { return Number(sessionStorage.getItem(SESSION_STEP_KEY)) || 1; } catch { return 1; }
});
const [expandedColumn, setExpandedColumn] = useState(null);
useEffect(() => {
try { sessionStorage.setItem(SESSION_STEP_KEY, String(activeStep)); } catch {}
}, [activeStep]);
// Modal visibility
const [showMaterialPicker, setShowMaterialPicker] = useState(false);
const [showMaterialModal, setShowMaterialModal] = useState(false);
const [showColorPicker, setShowColorPicker] = useState(false);
const [showRoomUpload, setShowRoomUpload] = useState(false);
const [showPlansView, setShowPlansView] = useState(readPlansOpenFlag);
useEffect(() => {
try {
if (showPlansView) {
localStorage.setItem(PLANS_OPEN_KEY, '1');
sessionStorage.removeItem(PLANS_OPEN_KEY);
} else {
localStorage.removeItem(PLANS_OPEN_KEY);
sessionStorage.removeItem(PLANS_OPEN_KEY);
}
} catch {
// ignore
}
}, [showPlansView]);
const [showAIRender, setShowAIRender] = useState(false);
// Material picker state
const [selectedMaterial, setSelectedMaterial] = useState(null);
const [materialFilter, setMaterialFilter] = useState('');
const [selectedCollection, setSelectedCollection] = useState('all');
// Color picker state
const [colorPickerElement, setColorPickerElement] = useState(null);
// Room visualization
const [roomConfig, setRoomConfig] = useState(null);
const [roomAnalysis, setRoomAnalysis] = useState(null);
const [roomViewActive, setRoomViewActive] = useState(false);
// Order form
const [orderForm, setOrderForm] = useState({ email: '', name: '', phone: '', notes: '' });
const [orderSent, setOrderSent] = useState(false);
const [lastOrderId, setLastOrderId] = useState(null);
// Module click from 3D view
const handleModuleClick = useCallback((moduleIndex) => {
setExpandedColumn(prev => prev === moduleIndex ? null : moduleIndex);
setActiveStep(2); // jump to columns step when clicking a module
}, []);
// Element click from 3D view
const openColorPicker = useCallback((elementType, label) => {
setColorPickerElement({ type: elementType, label });
setShowColorPicker(true);
setActiveStep(4); // jump to materials step
}, []);
const closeColorPicker = useCallback(() => {
setShowColorPicker(false);
setColorPickerElement(null);
}, []);
// Material handlers
const openMaterialDetail = useCallback((material) => {
setSelectedMaterial(material);
setShowMaterialModal(true);
}, []);
const handleVariantSwitch = useCallback((variant, finishes) => {
const variantMaterial = finishes.find(f => f.code === variant.variant_code);
if (variantMaterial) {
setSelectedMaterial(variantMaterial);
}
}, []);
return {
activeStep, setActiveStep,
expandedColumn, setExpandedColumn,
showMaterialPicker, setShowMaterialPicker,
showMaterialModal, setShowMaterialModal,
showColorPicker, setShowColorPicker,
showRoomUpload, setShowRoomUpload,
showPlansView, setShowPlansView,
showAIRender, setShowAIRender,
selectedMaterial, setSelectedMaterial,
materialFilter, setMaterialFilter,
selectedCollection, setSelectedCollection,
colorPickerElement, setColorPickerElement,
roomConfig, setRoomConfig,
roomAnalysis, setRoomAnalysis,
roomViewActive, setRoomViewActive,
orderForm, setOrderForm,
orderSent, setOrderSent,
lastOrderId, setLastOrderId,
handleModuleClick,
openColorPicker,
closeColorPicker,
openMaterialDetail,
handleVariantSwitch,
};
}
+676
View File
@@ -0,0 +1,676 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { fetchFinishes, fetchServiceOptions, calculatePrice } from '../api/client';
import {
COLUMN_TYPES, DEFAULT_CYCLE, DEFAULT_COLORS, DEFAULT_CONFIG, maxShelfGapMm, SHELF_GAP_MM,
minShelvesForStructuralHeight, resolveShelfPositionsMm
} from '../constants/wardrobe';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const SESSION_KEY = 'apex_wardrobe_config';
const SESSION_COLS_KEY = 'apex_wardrobe_numcols';
const MAX_CONFIG_UNDO = 50;
function loadSavedConfig() {
try {
const raw = sessionStorage.getItem(SESSION_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export default function useWardrobeConfig() {
const saved = loadSavedConfig();
const [config, setConfig] = useState(saved || DEFAULT_CONFIG);
const configRef = useRef(config);
useEffect(() => {
configRef.current = config;
}, [config]);
const undoPastRef = useRef([]);
const undoFutureRef = useRef([]);
const [undoRevision, setUndoRevision] = useState(0);
const pushUndoSnapshot = useCallback(() => {
const snap = JSON.parse(JSON.stringify(configRef.current));
undoPastRef.current.push(snap);
if (undoPastRef.current.length > MAX_CONFIG_UNDO) undoPastRef.current.shift();
undoFutureRef.current = [];
setUndoRevision((n) => n + 1);
}, []);
const undo = useCallback(() => {
if (undoPastRef.current.length === 0) return;
const prev = undoPastRef.current.pop();
undoFutureRef.current.push(JSON.parse(JSON.stringify(configRef.current)));
setConfig(prev);
setUndoRevision((n) => n + 1);
}, []);
const redo = useCallback(() => {
if (undoFutureRef.current.length === 0) return;
const next = undoFutureRef.current.pop();
undoPastRef.current.push(JSON.parse(JSON.stringify(configRef.current)));
setConfig(next);
setUndoRevision((n) => n + 1);
}, []);
const [finishes, setFinishes] = useState([]);
const [serviceOptions, setServiceOptions] = useState([]);
const [priceResult, setPriceResult] = useState(null);
const [loading, setLoading] = useState(true);
const [numColumns, setNumColumns] = useState(() => {
try { return Number(sessionStorage.getItem(SESSION_COLS_KEY)) || 3; } catch { return 3; }
});
// Hardware catalogs
const [handleVariants, setHandleVariants] = useState([]);
const [hingeVariants, setHingeVariants] = useState([]);
const [blumHardware, setBlumHardware] = useState([]);
// AI optimize state
const [aiOptimizing, setAiOptimizing] = useState(false);
const [aiOptimizeMsg, setAiOptimizeMsg] = useState('');
// Persist config to sessionStorage on every change
useEffect(() => {
try { sessionStorage.setItem(SESSION_KEY, JSON.stringify(config)); } catch {}
}, [config]);
// Keep extended configuration schema compatible with older saved sessions.
useEffect(() => {
setConfig((c) => ({
...DEFAULT_CONFIG,
...c,
nicheMeasurements: {
...DEFAULT_CONFIG.nicheMeasurements,
...(c.nicheMeasurements || {})
},
hardware: {
...DEFAULT_CONFIG.hardware,
...(c.hardware || {})
}
}));
// Run once at startup for legacy session payloads.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
try { sessionStorage.setItem(SESSION_COLS_KEY, String(numColumns)); } catch {}
}, [numColumns]);
// Clamp per-module shelf gap (mm) when height or shelf count changes.
// Enforce minimum shelf count when height requires a structural middle shelf.
useEffect(() => {
setConfig((c) => {
const h = c.heightMm;
const pl = c.plinthHeightMm ?? 100;
const minS = minShelvesForStructuralHeight(h);
const list = c.modules || [];
let touched = false;
const modules = list.map((mod) => {
const nRaw = mod.shelves || 0;
const n = Math.max(minS, nRaw);
let nextMod = mod;
if (n !== nRaw) {
touched = true;
nextMod = { ...nextMod, shelves: n };
}
const resolvedShelfPositions = resolveShelfPositionsMm({
shelfCount: n,
heightMm: h,
plinthHeightMm: pl,
shelfPositionsMm: nextMod.shelfPositionsMm
});
const nextShelfPositions = n > 0 ? resolvedShelfPositions : [];
const prevShelfPositions = Array.isArray(nextMod.shelfPositionsMm) ? nextMod.shelfPositionsMm : [];
const shelfPosChanged =
nextShelfPositions.length !== prevShelfPositions.length ||
nextShelfPositions.some((v, idx) => v !== prevShelfPositions[idx]);
if (shelfPosChanged) {
touched = true;
nextMod = { ...nextMod, shelfPositionsMm: nextShelfPositions };
}
if (n < 2) {
if (nextMod.shelfGapMm != null) {
touched = true;
return { ...nextMod, shelfGapMm: null };
}
return nextMod;
}
const capMax = maxShelfGapMm(h, pl, n);
if (nextMod.shelfGapMm != null && capMax < SHELF_GAP_MM.min) {
touched = true;
return { ...nextMod, shelfGapMm: null };
}
if (nextMod.shelfGapMm == null || !Number.isFinite(Number(nextMod.shelfGapMm))) return nextMod;
const minG = SHELF_GAP_MM.min;
const raw = Math.round(Number(nextMod.shelfGapMm) / SHELF_GAP_MM.step) * SHELF_GAP_MM.step;
const g = Math.min(capMax, Math.max(minG, raw));
if (g !== Number(nextMod.shelfGapMm)) {
touched = true;
return { ...nextMod, shelfGapMm: g };
}
return nextMod;
});
if (!touched) return c;
return { ...c, modules };
});
}, [config.modules, config.heightMm, config.plinthHeightMm]);
// Enforce UI constraints directly in state so downstream services always receive valid values.
useEffect(() => {
setConfig((c) => {
let next = c;
const sanitizeStep = (value, min, max, step) => {
const n = Number(value);
if (!Number.isFinite(n)) return min;
const clamped = Math.max(min, Math.min(max, n));
return Math.round(clamped / step) * step;
};
// Sliding doors are currently disabled in UI; keep old configs stable.
if (next.doorType === 'sliding') {
next = { ...next, doorType: 'hinged', slidingPanelsCount: 2 };
}
const modules = Array.isArray(next.modules) ? [...next.modules] : [];
const rawDrawerType = next.hardware?.drawerType || 'visible';
const drawerType =
rawDrawerType === 'hidden' || rawDrawerType === 'hidden_with_handle' ? 'hidden' : 'visible';
const handleType = next.hardware?.handleType || 'standard';
// Drawers constraints: from bottom, depth 400..650 by 50, and drawer columns max 1000 mm.
const constrainedModules = modules.map((mod) => {
const hasDrawers = (mod.drawers || 0) > 0 || mod.type === 'tiroirs';
if (!hasDrawers) return mod;
const nextMaxWidth = Math.min(1000, mod.maxWidth || 1200);
// Keep same reference when nothing changes to avoid render loops/flicker.
if (mod.maxWidth === nextMaxWidth) return mod;
return {
...mod,
maxWidth: nextMaxWidth
};
});
const modulesChanged = constrainedModules.some((m, i) => m !== modules[i]);
const sanitizedHandleBore = sanitizeStep(next.hardware?.handleBoreHeightMm, 600, 1600, 10);
const sanitizedDrawerHeights = (next.hardware?.drawerHeightsMm || [90, 128, 177, 240]).slice(0, 4);
const allowedHeights = [60, 90, 128, 177, 240];
const fixedDrawerHeights = sanitizedDrawerHeights.map((h) => {
return allowedHeights.includes(Number(h)) ? Number(h) : 128;
});
const drawerDepthStepMm = sanitizeStep(next.depthMm - 10, 400, 650, 50);
// Hidden drawers: depth reserve until poignée is modelled separately.
const depthReductionMm = drawerType === 'hidden' ? 40 : 0;
const effectiveDrawerDepthMm = Math.max(350, drawerDepthStepMm - depthReductionMm);
const niche = next.nicheMeasurements || {};
const minNicheWidth = Math.min(niche.widthTopMm || next.widthMm, niche.widthMidMm || next.widthMm, niche.widthBottomMm || next.widthMm);
const minNicheDepth = Math.min(niche.depthTopMm || next.depthMm, niche.depthMidMm || next.depthMm, niche.depthBottomMm || next.depthMm);
const minNicheHeight = Math.min(niche.heightLeftMm || next.heightMm, niche.heightRightMm || next.heightMm);
let widthMm = next.widthMm;
let depthMm = next.depthMm;
let heightMm = next.heightMm;
if (next.installationType === 'niche') {
const gap = Number(next.sanitaryGapMm || 30);
widthMm = Math.max(400, minNicheWidth - gap * 2);
depthMm = Math.max(400, minNicheDepth - gap * 2);
heightMm = Math.max(1800, minNicheHeight - gap * 2);
}
const hardware = {
...next.hardware,
handleType,
drawerType,
handleBoreHeightMm: sanitizedHandleBore,
drawerHeightsMm: fixedDrawerHeights
};
const changed =
modulesChanged ||
widthMm !== next.widthMm ||
depthMm !== next.depthMm ||
heightMm !== next.heightMm ||
sanitizedHandleBore !== (next.hardware?.handleBoreHeightMm || 1000) ||
effectiveDrawerDepthMm !== next.effectiveDrawerDepthMm ||
handleType !== (next.hardware?.handleType || 'standard') ||
(next.hardware?.drawerType != null && drawerType !== next.hardware.drawerType) ||
next.hardware?.drawerType === 'hidden_with_handle';
if (!changed) return c;
return {
...next,
modules: constrainedModules,
widthMm,
depthMm,
heightMm,
hardware,
effectiveDrawerDepthMm
};
});
}, [config.installationType, config.nicheMeasurements, config.sanitaryGapMm, config.doorType, config.depthMm, config.heightMm, config.hardware]);
// Fetch initial data
useEffect(() => {
Promise.all([fetchFinishes(), fetchServiceOptions()])
.then(([fin, opt]) => {
setFinishes(fin);
setServiceOptions(opt);
// Only apply default finish if none was saved
if (fin.length) {
setConfig((c) => {
if (c.finishId) return c; // already has a saved finish
const firstMaterial = fin[0];
const defaultDim = firstMaterial.dimensions?.find(d => d.is_default) || firstMaterial.dimensions?.[0];
return { ...c, finishId: firstMaterial.id, dimensionId: defaultDim?.id || null };
});
}
})
.catch(console.error)
.finally(() => setLoading(false));
fetch(`${API_BASE}/api/handles`).then(r => r.json()).then(setHandleVariants).catch(console.error);
fetch(`${API_BASE}/api/hinges`).then(r => r.json()).then(setHingeVariants).catch(console.error);
fetch(`${API_BASE}/api/hardware`).then(r => r.json()).then(setBlumHardware).catch(console.error);
}, []);
// Price calculation
const updatePrice = useCallback(() => {
const totalW = config.modules.reduce((s, m) => s + (m.widthMm || 0), 0) || config.widthMm;
const payload = {
...config,
widthMm: totalW,
blocks: config.modules.map((m) => ({ blockTypeId: 1, widthMm: m.widthMm }))
};
calculatePrice(payload)
.then(setPriceResult)
.catch((err) => setPriceResult({ totalChf: 0, errors: [{ message: err.message }] }));
}, [config]);
useEffect(() => {
if (!loading) updatePrice();
}, [config, loading, updatePrice]);
// Simple setter helper
const set = useCallback((key, value) => setConfig((c) => ({ ...c, [key]: value })), []);
const setDimension = useCallback((key, valueMm) => {
const ranges = {
widthMm: { min: 400, max: 5000, step: 50 },
heightMm: { min: 1800, max: 2800, step: 50 },
depthMm: { min: 400, max: 800, step: 10 },
};
const rules = ranges[key];
if (!rules) return;
const nextRaw = Number(valueMm);
if (!Number.isFinite(nextRaw)) return;
const nextStepped = Math.round(nextRaw / rules.step) * rules.step;
const next = Math.min(rules.max, Math.max(rules.min, nextStepped));
setConfig((c) => {
const patch = { [key]: next };
if (c.installationType === 'niche') {
const gap = Number(c.sanitaryGapMm || 30);
const nicheOuter = next + gap * 2;
const niche = { ...(c.nicheMeasurements || {}) };
if (key === 'widthMm') {
niche.widthTopMm = nicheOuter;
niche.widthMidMm = nicheOuter;
niche.widthBottomMm = nicheOuter;
} else if (key === 'depthMm') {
niche.depthTopMm = nicheOuter;
niche.depthMidMm = nicheOuter;
niche.depthBottomMm = nicheOuter;
} else if (key === 'heightMm') {
niche.heightLeftMm = nicheOuter;
niche.heightRightMm = nicheOuter;
}
patch.nicheMeasurements = niche;
}
if (next === c[key] && !patch.nicheMeasurements) return c;
return { ...c, ...patch };
});
}, []);
// Column generation
const generateColumns = useCallback((nCols, totalW, existingModules = []) => {
const lockedWidth = existingModules
.filter((m, i) => i < nCols && m?.locked)
.reduce((s, m) => s + (m.widthMm || 0), 0);
const lockedCount = existingModules
.filter((m, i) => i < nCols && m?.locked).length;
const unlockedCount = nCols - lockedCount;
const remainingW = totalW - lockedWidth;
const colWidth = unlockedCount > 0 ? Math.round(remainingW / unlockedCount) : Math.round(totalW / nCols);
return Array.from({ length: nCols }, (_, i) => {
const existing = existingModules[i];
if (existing?.locked) {
return { ...existing, position: i };
}
const typeKey = existing?.type || DEFAULT_CYCLE[i % DEFAULT_CYCLE.length];
const typeDef = COLUMN_TYPES.find(t => t.key === typeKey) || COLUMN_TYPES[0];
return {
id: existing?.id || `col-${Date.now()}-${i}`,
type: typeDef.key,
moduleCode: typeDef.key,
widthMm: colWidth,
minWidth: 300,
maxWidth: 1200,
position: i,
locked: false,
shelves: typeDef.shelves,
drawers: typeDef.drawers,
hasRod: typeDef.hasRod
};
});
}, []);
const handleNumColumnsChange = useCallback((newNum) => {
setNumColumns(newNum);
const modules = generateColumns(newNum, config.widthMm, config.modules);
setConfig(c => ({ ...c, modules }));
}, [config.widthMm, config.modules, generateColumns]);
// AI layout optimization
const handleAIOptimize = useCallback(async () => {
setAiOptimizing(true);
setAiOptimizeMsg('');
try {
const res = await fetch(`${API_BASE}/api/layout/optimize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
totalWidth: config.totalWidth || config.widthMm,
height: config.height,
depth: config.depth,
installationType: config.installationType,
modules: config.modules.map(m => ({
type: m.type || m.moduleCode,
width: m.width,
locked: m.locked || false,
shelves: m.shelves,
drawers: m.drawers,
hasRod: m.hasRod
}))
})
});
if (!res.ok) throw new Error('API error');
const data = await res.json();
if (data.modules && Array.isArray(data.modules)) {
setConfig(c => {
const newModules = data.modules.map((m, i) => ({
...c.modules[i],
...m,
id: c.modules[i]?.id || `col-${i}`
}));
return { ...c, modules: newModules };
});
setNumColumns(data.modules.length);
}
if (data.reasoning) {
setAiOptimizeMsg(data.reasoning);
}
} catch (err) {
setAiOptimizeMsg('Impossible d\'optimiser. V\u00e9rifiez la configuration IA.');
} finally {
setAiOptimizing(false);
}
}, [config]);
const handleColumnTypeChange = useCallback((index, typeKey) => {
const typeDef = COLUMN_TYPES.find(t => t.key === typeKey) || COLUMN_TYPES[0];
setConfig(c => {
const modules = [...c.modules];
const minS = minShelvesForStructuralHeight(c.heightMm);
const shelves = Math.max(minS, typeDef.shelves);
modules[index] = {
...modules[index],
type: typeDef.key,
moduleCode: typeDef.key,
shelves,
drawers: typeDef.drawers,
hasRod: typeDef.hasRod
};
return { ...c, modules };
});
}, []);
const handleColumnLock = useCallback((index) => {
setConfig(c => {
const modules = [...c.modules];
modules[index] = { ...modules[index], locked: !modules[index].locked };
return { ...c, modules };
});
}, []);
const redistributeWidths = useCallback(() => {
setConfig(c => {
const modules = [...c.modules];
const lockedWidth = modules.filter(m => m.locked).reduce((s, m) => s + m.widthMm, 0);
const unlockedCount = modules.filter(m => !m.locked).length;
const remainingW = c.widthMm - lockedWidth;
const colWidth = unlockedCount > 0 ? Math.round(remainingW / unlockedCount) : 0;
return {
...c,
modules: modules.map(m => m.locked ? m : { ...m, widthMm: colWidth })
};
});
}, []);
// Re-distribute when width changes
useEffect(() => {
if (config.modules.length > 0) {
redistributeWidths();
}
}, [config.widthMm]);
// Initialize columns on first load
useEffect(() => {
if (config.modules.length === 0) {
const modules = generateColumns(numColumns, config.widthMm, []);
setConfig(c => ({ ...c, modules }));
}
}, []);
const handleColumnUpdate = useCallback((index, updates) => {
setConfig(c => {
const modules = [...c.modules];
const prev = modules[index];
const merged = { ...prev, ...updates };
const minS = minShelvesForStructuralHeight(c.heightMm);
const shelves = Math.max(minS, merged.shelves ?? 0);
const drawers = merged.drawers ?? 0;
const withShelves = {
...merged,
shelves,
shelfPositionsMm: resolveShelfPositionsMm({
shelfCount: shelves,
heightMm: c.heightMm,
plinthHeightMm: c.plinthHeightMm ?? 100,
shelfPositionsMm: merged.shelfPositionsMm
})
};
const needsMixte = shelves > 0 && drawers > 0 && withShelves.type !== 'mixte';
modules[index] = needsMixte
? { ...withShelves, type: 'mixte', moduleCode: 'mixte' }
: withShelves;
return { ...c, modules };
});
}, []);
const reorderModules = useCallback((fromIndex, toIndex) => {
if (fromIndex === toIndex) return;
setConfig((c) => {
const list = [...c.modules];
const n = list.length;
if (fromIndex < 0 || fromIndex >= n || toIndex < 0 || toIndex >= n) return c;
const [item] = list.splice(fromIndex, 1);
list.splice(toIndex, 0, item);
return {
...c,
modules: list.map((m, idx) => ({ ...m, position: idx })),
};
});
}, []);
const handleResizeModules = useCallback((separatorIndex, deltaXmm) => {
setConfig((c) => {
const modules = [...c.modules];
const left = { ...modules[separatorIndex] };
const right = { ...modules[separatorIndex + 1] };
const leftMin = left.minWidth || 300;
const leftMax = left.maxWidth || 1200;
const rightMin = right.minWidth || 300;
const rightMax = right.maxWidth || 1200;
let newLeftW = left.widthMm + deltaXmm;
let newRightW = right.widthMm - deltaXmm;
if (newLeftW < leftMin) { newRightW += (newLeftW - leftMin); newLeftW = leftMin; }
if (newLeftW > leftMax) { newRightW += (newLeftW - leftMax); newLeftW = leftMax; }
if (newRightW < rightMin) { newLeftW += (newRightW - rightMin); newRightW = rightMin; }
if (newRightW > rightMax) { newLeftW += (newRightW - rightMax); newRightW = rightMax; }
left.widthMm = Math.round(newLeftW);
right.widthMm = Math.round(newRightW);
modules[separatorIndex] = left;
modules[separatorIndex + 1] = right;
return { ...c, modules };
});
}, []);
// Color management
const handleElementClick = useCallback((elementType, label) => {
return { type: elementType, label };
}, []);
const handleColorSelect = useCallback((elementType, finish) => {
setConfig((c) => ({
...c,
colors: {
...c.colors,
[elementType]: {
finishId: finish.id,
label: finish.label || finish.name,
hexColor: finish.hexColor || '#c4a574',
imageUrl: finish.image_url || null
}
}
}));
}, []);
const handleColorApplyToAll = useCallback((finish) => {
const colorEntry = {
finishId: finish.id,
label: finish.label || finish.name,
hexColor: finish.hexColor || '#c4a574',
imageUrl: finish.image_url || null
};
setConfig((c) => {
const newColors = {};
Object.keys(DEFAULT_COLORS).forEach(key => {
newColors[key] = { ...colorEntry };
});
Object.keys(c.colors).forEach(key => {
if (!newColors[key]) newColors[key] = { ...colorEntry };
});
return { ...c, colors: newColors };
});
}, []);
// Hardware
const setHardware = useCallback((key, value) => {
setConfig((c) => ({
...c,
hardware: { ...c.hardware, [key]: value }
}));
}, []);
// Material management
const handleMaterialConfirm = useCallback((materialId, dimensionId) => {
setConfig((c) => {
const material = finishes.find(f => f.id === materialId);
const updated = { ...c, finishId: materialId, dimensionId };
if (material) {
const colorEntry = {
finishId: material.id,
label: material.label || material.name,
hexColor: material.hexColor || '#c4a574',
imageUrl: material.image_url || null
};
const newColors = {};
Object.keys(DEFAULT_COLORS).forEach(key => {
newColors[key] = { ...colorEntry };
});
Object.keys(c.colors).forEach(key => {
if (!newColors[key]) newColors[key] = { ...colorEntry };
});
updated.colors = newColors;
}
return updated;
});
}, [finishes]);
// Derived values
const totalModuleWidth = useMemo(() =>
config.modules.reduce((sum, m) => sum + (m.widthMm || 0), 0),
[config.modules]
);
const selectedFinish = useMemo(() =>
finishes.find(f => f.id === config.finishId),
[finishes, config.finishId]
);
const materialsByCollection = useMemo(() =>
finishes.reduce((acc, mat) => {
const coll = mat.collection || 'Autres';
if (!acc[coll]) acc[coll] = [];
acc[coll].push(mat);
return acc;
}, {}),
[finishes]
);
return {
config, setConfig, set, setDimension,
finishes, serviceOptions,
priceResult,
loading,
numColumns, setNumColumns,
handleVariants, hingeVariants, blumHardware,
aiOptimizing, aiOptimizeMsg, setAiOptimizeMsg,
generateColumns,
handleNumColumnsChange,
handleAIOptimize,
handleColumnTypeChange,
handleColumnLock,
redistributeWidths,
handleColumnUpdate,
reorderModules,
handleResizeModules,
handleElementClick,
handleColorSelect,
handleColorApplyToAll,
setHardware,
handleMaterialConfirm,
totalModuleWidth,
selectedFinish,
materialsByCollection,
canUndo: undoPastRef.current.length > 0,
canRedo: undoFutureRef.current.length > 0,
pushUndoSnapshot,
undo,
redo,
};
}
+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>
+376 -14
View File
@@ -19,18 +19,24 @@
}
.logout {
padding: 0.5rem 1rem;
background: transparent;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 0.95rem;
background: #fff;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: 10px;
color: var(--text-muted);
font-size: 0.9375rem;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.15s ease;
}
.logout:hover {
border-color: var(--accent);
color: var(--primary);
border-color: var(--action);
color: var(--action);
box-shadow: 0 6px 18px rgba(79, 70, 229, 0.15);
transform: translateY(-1px);
}
.muted {
@@ -112,19 +118,22 @@
}
.syncBtn {
padding: 0.625rem 1.25rem;
background: var(--accent);
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.625rem 1rem;
background: linear-gradient(135deg, var(--action), var(--action-hover));
border: none;
border-radius: 6px;
border-radius: 10px;
color: #fff;
font-size: 0.9375rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
transition: all 0.15s ease;
}
.syncBtn:hover:not(:disabled) {
background: var(--primary);
box-shadow: 0 10px 22px rgba(79, 70, 229, 0.26);
transform: translateY(-1px);
}
.syncBtn:disabled {
@@ -132,6 +141,25 @@
cursor: not-allowed;
}
.forceLabel {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--text-secondary, #666);
cursor: pointer;
user-select: none;
}
.forceLabel input[type="checkbox"] {
accent-color: var(--accent, #e67e22);
}
.forceLabel:has(input:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
.success {
color: #16a34a;
}
@@ -233,8 +261,342 @@
line-height: 1.5;
}
.liveLogsHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 1rem;
background: #2d2d2d;
border-bottom: 1px solid #444;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.75rem;
color: #888;
}
.clearBtn {
background: transparent;
border: 1px solid #555;
border-radius: 4px;
color: #aaa;
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
cursor: pointer;
}
.clearBtn:hover {
border-color: #888;
color: #ddd;
}
.progressDone {
background: #16a34a;
}
.progressError {
background: #b91c1c;
}
.haefeleInputWrap {
margin-top: 0.75rem;
}
.urlTextarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 0.86rem;
line-height: 1.4;
resize: vertical;
margin-bottom: 0.75rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.urlTextarea:focus {
outline: none;
border-color: var(--action);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.14);
}
.haefeleListHeader {
margin-top: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.haefeleListActions {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.searchInput {
min-width: 280px;
padding: 0.45rem 0.6rem;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.82rem;
}
.searchInput:focus {
outline: none;
border-color: var(--action);
}
.logLine {
color: #d4d4d4;
white-space: pre-wrap;
word-break: break-word;
}
.logLine_error {
color: #f87171;
}
.logLine_warn {
color: #fbbf24;
}
.logLine_ok {
color: #4ade80;
}
.logLine_info {
color: #d4d4d4;
}
.detailsBtn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.7rem;
border: 1px solid var(--action);
border-radius: 9px;
background: #fff;
color: var(--action);
font-size: 0.78rem;
font-weight: 600;
transition: all 0.15s ease;
}
.detailsBtn:hover {
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.15);
transform: translateY(-1px);
}
.detailsRow td {
background: #fcfcfd;
}
.supplierDetails {
padding: 0.5rem 0;
}
.detailsSection {
margin-bottom: 1rem;
}
.detailsSectionTitle {
margin: 0 0 0.5rem;
font-size: 0.9rem;
color: var(--primary);
}
.planExportActions {
margin-bottom: 0.5rem;
}
.generateBtn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.42rem 0.72rem;
border: 1px solid var(--accent);
border-radius: 9px;
background: #fff;
color: var(--accent);
font-size: 0.78rem;
font-weight: 600;
transition: all 0.15s ease;
}
.generateBtn:hover:not(:disabled) {
border-color: var(--action);
color: var(--action);
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.15);
transform: translateY(-1px);
}
.generateBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.supplierGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 0.75rem;
}
.supplierCard {
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.supplierHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.supplierHeader strong {
color: var(--primary);
font-size: 0.875rem;
}
.supplierHeader span {
color: var(--text-muted);
font-size: 0.75rem;
}
.supplierItemsTable {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.supplierItemsTable th,
.supplierItemsTable td {
padding: 0.4rem 0.5rem;
border-bottom: 1px solid var(--border);
text-align: left;
}
.supplierItemsTable th {
background: var(--bg);
color: var(--text-muted);
font-weight: 600;
}
.supplierItemsTable tfoot td {
font-weight: 600;
}
.planExportsTable {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.planExportsTable th,
.planExportsTable td {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--border);
text-align: left;
}
.planExportsTable th {
background: var(--bg);
color: var(--text-muted);
font-weight: 600;
}
.planExportsTable tbody tr:last-child td {
border-bottom: none;
}
.downloadLink {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: var(--accent);
text-decoration: none;
font-weight: 600;
}
.downloadLink:hover {
text-decoration: underline;
}
.deleteLink {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: transparent;
border: none;
color: #b91c1c;
text-decoration: underline;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
padding: 0;
}
.deleteLink:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.productionWrap {
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
padding: 0.75rem;
}
.productionSummary {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
color: var(--text-muted);
font-size: 0.8rem;
margin-bottom: 0.75rem;
}
.planGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.planCard {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem;
background: var(--bg);
overflow: auto;
}
.planCard h4 {
margin: 0 0 0.4rem;
font-size: 0.8rem;
color: var(--primary);
}
.planCard svg {
max-width: 100%;
height: auto;
}
.productionTables {
margin-top: 0.75rem;
}
.productionTables h4 {
margin: 0 0 0.4rem;
font-size: 0.8rem;
color: var(--primary);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+51 -15
View File
@@ -4,6 +4,7 @@ import { useAuth } from '../context/AuthContext';
import {
fetchFormexMaterials,
fetchFormexCollections,
fetchMaterialSuppliers,
deleteFormexMaterial
} from '../api/client';
import MaterialModal from '../components/MaterialModal';
@@ -16,6 +17,7 @@ export default function Materials() {
// Data state
const [materials, setMaterials] = useState([]);
const [collections, setCollections] = useState([]);
const [suppliers, setSuppliers] = useState([]);
const [pagination, setPagination] = useState({ page: 1, limit: 25, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -23,6 +25,7 @@ export default function Materials() {
// Filter state
const [search, setSearch] = useState('');
const [collection, setCollection] = useState('');
const [supplier, setSupplier] = useState('');
const [searchInput, setSearchInput] = useState('');
// Modal state
@@ -36,7 +39,7 @@ export default function Materials() {
setLoading(true);
setError('');
try {
const result = await fetchFormexMaterials({ search, collection, page, limit: 25 });
const result = await fetchFormexMaterials({ search, collection, supplier, page, limit: 25 });
setMaterials(result.materials);
setPagination(result.pagination);
} catch (err) {
@@ -44,18 +47,15 @@ export default function Materials() {
} finally {
setLoading(false);
}
}, [search, collection]);
}, [search, collection, supplier]);
useEffect(() => {
if (!isAdmin) {
navigate('/login?next=/materiaux', { replace: true });
return;
}
// Load collections for filter dropdown
fetchFormexCollections()
.then(setCollections)
.catch(() => {});
// Load materials
fetchFormexCollections().then(setCollections).catch(() => {});
fetchMaterialSuppliers().then(setSuppliers).catch(() => {});
loadMaterials(1);
}, [isAdmin, navigate, loadMaterials]);
@@ -68,10 +68,9 @@ export default function Materials() {
setCollection(e.target.value);
};
// Reload when filters change
useEffect(() => {
loadMaterials(1);
}, [search, collection, loadMaterials]);
}, [search, collection, supplier, loadMaterials]);
const handlePageChange = (newPage) => {
loadMaterials(newPage);
@@ -123,13 +122,12 @@ export default function Materials() {
return (
<div className={styles.page}>
<div className={styles.header}>
<h1>Matériaux Formex</h1>
<h1>Matériaux</h1>
<button type="button" onClick={handleCreate} className={styles.createBtn}>
+ Nouveau matériau
</button>
</div>
{/* Filters */}
<div className={styles.filters}>
<form onSubmit={handleSearch} className={styles.searchForm}>
<input
@@ -141,6 +139,16 @@ export default function Materials() {
/>
<button type="submit" className={styles.searchBtn}>Rechercher</button>
</form>
<select
value={supplier}
onChange={(e) => setSupplier(e.target.value)}
className={styles.collectionSelect}
>
<option value="">Tous les fournisseurs</option>
{suppliers.map((s) => (
<option key={s.code} value={s.code}>{s.name}</option>
))}
</select>
<select
value={collection}
onChange={handleCollectionChange}
@@ -163,30 +171,58 @@ export default function Materials() {
<table className={styles.table}>
<thead>
<tr>
<th>Fournisseur</th>
<th>Code</th>
<th>Nom</th>
<th>Collection</th>
<th>Dimensions (mm)</th>
<th>Perçage</th>
<th>Prix CHF</th>
<th>Unité</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{materials.length === 0 ? (
<tr>
<td colSpan={5} className={styles.empty}>
<td colSpan={9} className={styles.empty}>
Aucun matériau trouvé.
</td>
</tr>
) : (
materials.map((m) => (
<tr key={m.id}>
<td><span className={styles.supplierBadge}>{m.supplier_name || '—'}</span></td>
<td className={styles.code}>{m.formex_code}</td>
<td>{m.name}</td>
<td>{m.collection}</td>
<td>
{m.width_mm && m.height_mm && m.thickness_mm
? `${m.width_mm} × ${m.height_mm} × ${m.thickness_mm}`
: '—'}
{m.diameter_mm
? `Ø ${m.diameter_mm}${m.length_mm ? ` × ${m.length_mm}` : ''}`
: m.width_mm && m.height_mm && m.thickness_mm
? `${m.width_mm} × ${m.height_mm} × ${m.thickness_mm}`
: m.thickness_mm ? `ép. ${m.thickness_mm}` : '—'}
</td>
<td className={styles.boreCell}>
{(m.bore_diameter_mm != null || m.bore_depth_base_mm != null || m.bore_depth_piece_mm != null) ? (
<div className={styles.boreInfo}>
{m.bore_diameter_mm != null && <span className={styles.boreBadge}>Ø {m.bore_diameter_mm}</span>}
{m.bore_depth_base_mm != null && <span className={styles.boreDepth} title="Profondeur perçage fond">fond: {m.bore_depth_base_mm}</span>}
{m.bore_depth_piece_mm != null && <span className={styles.boreDepth} title="Profondeur perçage pièce">pièce: {m.bore_depth_piece_mm}</span>}
</div>
) : <span className={styles.priceNone}>—</span>}
</td>
<td>
{m.price_chf != null
? <span className={styles.priceOk}>{Number(m.price_chf).toFixed(2)}</span>
: <span className={styles.priceNone}>—</span>
}
</td>
<td>
{m.price_unit
? <span className={styles.priceUnit}>{m.price_unit}</span>
: <span className={styles.priceNone}>—</span>
}
</td>
<td className={styles.actions}>
<button
+66
View File
@@ -129,6 +129,72 @@
font-weight: 500;
}
.supplierBadge {
display: inline-block;
padding: 0.15rem 0.5rem;
background: #eff6ff;
color: #1d4ed8;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: capitalize;
white-space: nowrap;
}
.boreCell {
white-space: nowrap;
}
.boreInfo {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
}
.boreBadge {
display: inline-block;
padding: 0.1rem 0.4rem;
background: #fef3c7;
color: #92400e;
border-radius: 4px;
font-weight: 600;
white-space: nowrap;
}
.boreDepth {
display: inline-block;
padding: 0.1rem 0.4rem;
background: #f0fdf4;
color: #166534;
border-radius: 4px;
font-weight: 500;
white-space: nowrap;
}
.priceOk {
display: inline-block;
padding: 0.175rem 0.5rem;
background: #dcfce7;
color: #15803d;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.priceNone {
color: var(--text-muted);
font-size: 0.8125rem;
}
.priceUnit {
font-size: 0.8125rem;
color: var(--text-muted);
font-style: italic;
}
.actions {
display: flex;
gap: 0.5rem;
+627
View File
@@ -0,0 +1,627 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import {
fetchSettings, updateSettings,
fetchSuppliers, createSupplier, updateSupplier, deleteSupplier,
fetchEngineeringRules, createEngineeringRule, updateEngineeringRule, deleteEngineeringRule
} from '../api/client';
import styles from './Settings.module.css';
// Human-readable labels for settings keys
const SETTINGS_META = [
{ key: 'kit_preparation_fee_chf', label: 'Forfait préparation kit (CHF)', type: 'number', min: 0, step: 10, unit: 'CHF' },
{ key: 'panel_thickness_mm', label: 'Épaisseur panneaux', type: 'number', min: 1, step: 1, unit: 'mm' },
{ key: 'plinth_height_min_mm', label: 'Hauteur plinthe minimale', type: 'number', min: 0, step: 5, unit: 'mm' },
{ key: 'plinth_height_max_mm', label: 'Hauteur plinthe maximale', type: 'number', min: 0, step: 5, unit: 'mm' },
{ key: 'sliding_doors_min_width_mm', label: 'Largeur min. portes coulissantes', type: 'number', min: 0, step: 50, unit: 'mm' },
{ key: 'allow_sliding_doors', label: 'Autoriser portes coulissantes (1/0)', type: 'number', min: 0, step: 1, unit: '' },
{ key: 'sanitary_gap_mm', label: 'Vide sanitaire niche', type: 'number', min: 0, step: 1, unit: 'mm' },
{ key: 'drawer_depth_min_mm', label: 'Tiroir profondeur min', type: 'number', min: 0, step: 10, unit: 'mm' },
{ key: 'drawer_depth_max_mm', label: 'Tiroir profondeur max', type: 'number', min: 0, step: 10, unit: 'mm' },
{ key: 'drawer_depth_step_mm', label: 'Tiroir pas profondeur', type: 'number', min: 1, step: 1, unit: 'mm' },
{ key: 'drawer_back_gap_mm', label: 'Vide sanitaire derrière tiroir', type: 'number', min: 0, step: 1, unit: 'mm' },
{ key: 'drawer_hidden_handle_reduction_mm', label: 'Réduction profondeur tiroir caché', type: 'number', min: 0, step: 1, unit: 'mm' },
{ key: 'standard_handle_axis_mm', label: 'Axe perçage poignée standard', type: 'number', min: 0, step: 10, unit: 'mm' },
{ key: 'standard_handle_tolerance_mm', label: 'Tolérance axe poignée', type: 'number', min: 0, step: 10, unit: 'mm' },
{ key: 'groove_width_mm', label: 'Rainure largeur', type: 'number', min: 0, step: 0.5, unit: 'mm' },
{ key: 'groove_depth_mm', label: 'Rainure profondeur', type: 'number', min: 0, step: 0.5, unit: 'mm' },
{ key: 'groove_edge_offset_mm', label: 'Rainure distance au bord', type: 'number', min: 0, step: 0.5, unit: 'mm' },
{ key: 'niche_clearance_multiplier', label: 'Multiplicateur jeu niche', type: 'number', min: 1, step: 0.5, unit: '' },
{ key: 'min_clearance_mm', label: 'Jeu minimum', type: 'number', min: 0, step: 1, unit: 'mm' },
];
const SUPPLIER_TYPES = [
{ value: 'panels', label: 'Panneaux / Matériaux' },
{ value: 'hardware', label: 'Quincaillerie' },
{ value: 'handles', label: 'Poignées' },
{ value: 'other', label: 'Autre' },
];
const EMPTY_SUPPLIER = { code: '', name: '', email: '', type: 'panels', active: 1 };
const EMPTY_RULE = { sort_order: 9999, chapter: '', title: '', details: '' };
export default function Settings() {
const { isAdmin } = useAuth();
const navigate = useNavigate();
// App settings state
const [settings, setSettings] = useState({});
const [settingsDraft, setSettingsDraft] = useState({});
const [settingsSaving, setSettingsSaving] = useState(false);
const [settingsMsg, setSettingsMsg] = useState(null); // { ok: bool, text: string }
// Suppliers state
const [suppliers, setSuppliers] = useState([]);
const [editingSupplier, setEditingSupplier] = useState(null); // null | supplier object | 'new'
const [supplierDraft, setSupplierDraft] = useState(EMPTY_SUPPLIER);
const [supplierSaving, setSupplierSaving] = useState(false);
const [supplierMsg, setSupplierMsg] = useState(null);
const [deleteConfirm, setDeleteConfirm] = useState(null); // supplier id to confirm delete
// Engineering rules state
const [engineeringRules, setEngineeringRules] = useState([]);
const [editingRule, setEditingRule] = useState(null); // null | rule object | 'new'
const [ruleDraft, setRuleDraft] = useState(EMPTY_RULE);
const [ruleSaving, setRuleSaving] = useState(false);
const [ruleMsg, setRuleMsg] = useState(null);
const [ruleDeleteConfirm, setRuleDeleteConfirm] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
if (!isAdmin) {
navigate('/login?next=/parametres', { replace: true });
return;
}
Promise.all([fetchSettings(), fetchSuppliers(), fetchEngineeringRules()])
.then(([s, sup, rules]) => {
setSettings(s);
setSettingsDraft(s);
setSuppliers(sup);
setEngineeringRules(rules);
})
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [isAdmin, navigate]);
if (!isAdmin) return null;
// ─── App Settings ──────────────────────────────────────────────────────────
function handleSettingChange(key, value) {
setSettingsDraft(prev => ({ ...prev, [key]: value }));
}
async function handleSettingsSave(e) {
e.preventDefault();
setSettingsSaving(true);
setSettingsMsg(null);
try {
const saved = await updateSettings(settingsDraft);
setSettings(saved);
setSettingsDraft(saved);
setSettingsMsg({ ok: true, text: 'Paramètres sauvegardés.' });
} catch (err) {
setSettingsMsg({ ok: false, text: err.message });
} finally {
setSettingsSaving(false);
setTimeout(() => setSettingsMsg(null), 4000);
}
}
function handleSettingsReset() {
setSettingsDraft(settings);
setSettingsMsg(null);
}
// ─── Suppliers ─────────────────────────────────────────────────────────────
function openNewSupplier() {
setEditingSupplier('new');
setSupplierDraft(EMPTY_SUPPLIER);
setSupplierMsg(null);
}
function openEditSupplier(supplier) {
setEditingSupplier(supplier);
setSupplierDraft({ ...supplier });
setSupplierMsg(null);
}
function cancelSupplierEdit() {
setEditingSupplier(null);
setSupplierDraft(EMPTY_SUPPLIER);
setSupplierMsg(null);
}
function handleSupplierDraftChange(field, value) {
setSupplierDraft(prev => ({ ...prev, [field]: value }));
}
async function handleSupplierSave(e) {
e.preventDefault();
setSupplierSaving(true);
setSupplierMsg(null);
try {
if (editingSupplier === 'new') {
const created = await createSupplier(supplierDraft);
setSuppliers(prev => [...prev, created]);
} else {
const updated = await updateSupplier(editingSupplier.id, supplierDraft);
setSuppliers(prev => prev.map(s => s.id === updated.id ? updated : s));
}
setEditingSupplier(null);
setSupplierDraft(EMPTY_SUPPLIER);
setSupplierMsg({ ok: true, text: 'Fournisseur sauvegardé.' });
setTimeout(() => setSupplierMsg(null), 3000);
} catch (err) {
setSupplierMsg({ ok: false, text: err.message });
} finally {
setSupplierSaving(false);
}
}
async function handleSupplierDelete(id) {
try {
await deleteSupplier(id);
setSuppliers(prev => prev.filter(s => s.id !== id));
setDeleteConfirm(null);
} catch (err) {
setSupplierMsg({ ok: false, text: err.message });
}
}
const typeLabel = v => SUPPLIER_TYPES.find(t => t.value === v)?.label ?? v;
// ─── Engineering Rules ─────────────────────────────────────────────────────
function openNewRule() {
setEditingRule('new');
setRuleDraft(EMPTY_RULE);
setRuleMsg(null);
}
function openEditRule(rule) {
setEditingRule(rule);
setRuleDraft({
sort_order: rule.sort_order ?? 9999,
chapter: rule.chapter ?? '',
title: rule.title ?? '',
details: rule.details ?? ''
});
setRuleMsg(null);
}
function cancelRuleEdit() {
setEditingRule(null);
setRuleDraft(EMPTY_RULE);
setRuleMsg(null);
}
function handleRuleDraftChange(field, value) {
setRuleDraft(prev => ({ ...prev, [field]: value }));
}
async function handleRuleSave(e) {
e.preventDefault();
setRuleSaving(true);
setRuleMsg(null);
try {
const payload = {
sort_order: Number(ruleDraft.sort_order),
chapter: ruleDraft.chapter,
title: ruleDraft.title,
details: ruleDraft.details
};
if (editingRule === 'new') {
const created = await createEngineeringRule(payload);
setEngineeringRules(prev =>
[...prev, created].sort((a, b) => (a.sort_order - b.sort_order) || (a.id - b.id))
);
} else {
const updated = await updateEngineeringRule(editingRule.id, payload);
setEngineeringRules(prev =>
prev
.map(r => r.id === updated.id ? updated : r)
.sort((a, b) => (a.sort_order - b.sort_order) || (a.id - b.id))
);
}
setEditingRule(null);
setRuleDraft(EMPTY_RULE);
setRuleMsg({ ok: true, text: 'Règle sauvegardée.' });
setTimeout(() => setRuleMsg(null), 3000);
} catch (err) {
setRuleMsg({ ok: false, text: err.message });
} finally {
setRuleSaving(false);
}
}
async function handleRuleDelete(id) {
try {
await deleteEngineeringRule(id);
setEngineeringRules(prev => prev.filter(r => r.id !== id));
setRuleDeleteConfirm(null);
} catch (err) {
setRuleMsg({ ok: false, text: err.message });
}
}
return (
<div className={styles.page}>
<div className={styles.header}>
<h1>Paramètres</h1>
</div>
{loading && <p className={styles.muted}>Chargement…</p>}
{error && <p className={styles.error}>{error}</p>}
{!loading && !error && (
<>
{/* ── App Settings ─────────────────────────────────────── */}
<section className={styles.section}>
<h2>Paramètres généraux</h2>
<form onSubmit={handleSettingsSave} className={styles.settingsForm}>
<div className={styles.settingsGrid}>
{SETTINGS_META.map(({ key, label, type, min, step, unit }) => (
<div className={styles.field} key={key}>
<label className={styles.label} htmlFor={`setting-${key}`}>
{label}
{unit && <span className={styles.unit}>{unit}</span>}
</label>
<input
id={`setting-${key}`}
className={styles.input}
type={type}
min={min}
step={step}
value={settingsDraft[key] ?? ''}
onChange={e => handleSettingChange(key, e.target.value)}
/>
</div>
))}
</div>
<div className={styles.formActions}>
<button type="submit" className={styles.btnPrimary} disabled={settingsSaving}>
{settingsSaving ? 'Sauvegarde…' : 'Sauvegarder'}
</button>
<button type="button" className={styles.btnGhost} onClick={handleSettingsReset}>
Annuler
</button>
{settingsMsg && (
<span className={settingsMsg.ok ? styles.success : styles.error}>
{settingsMsg.text}
</span>
)}
</div>
</form>
</section>
{/* ── Engineering Rules ───────────────────────────────────── */}
<section className={styles.section}>
<div className={styles.sectionHeader}>
<h2>Règles APEX 4.0 (ingénierie, ergonomie, légalité)</h2>
<button type="button" className={styles.btnPrimary} onClick={openNewRule}>
+ Nouvelle règle
</button>
</div>
<p className={styles.muted}>
Liste de référence triée par ordre. Chaque règle est modifiable pour ajuster les contraintes du configurateur.
</p>
{ruleMsg && !editingRule && (
<p className={ruleMsg.ok ? styles.success : styles.error}>{ruleMsg.text}</p>
)}
{editingRule && (
<form onSubmit={handleRuleSave} className={styles.supplierForm}>
<h3>{editingRule === 'new' ? 'Nouvelle règle' : `Modifier — ${editingRule.title}`}</h3>
<div className={styles.ruleFormGrid}>
<div className={styles.field}>
<label className={styles.label}>Ordre *</label>
<input
className={styles.input}
type="number"
required
value={ruleDraft.sort_order}
onChange={e => handleRuleDraftChange('sort_order', e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Chapitre *</label>
<input
className={styles.input}
required
placeholder="ex. 5.C"
value={ruleDraft.chapter}
onChange={e => handleRuleDraftChange('chapter', e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Titre *</label>
<input
className={styles.input}
required
placeholder="Titre court de la règle"
value={ruleDraft.title}
onChange={e => handleRuleDraftChange('title', e.target.value)}
/>
</div>
<div className={styles.fieldWide}>
<label className={styles.label}>Détails *</label>
<textarea
className={styles.textarea}
required
rows={6}
placeholder="Contenu détaillé de la règle"
value={ruleDraft.details}
onChange={e => handleRuleDraftChange('details', e.target.value)}
/>
</div>
</div>
<div className={styles.formActions}>
<button type="submit" className={styles.btnPrimary} disabled={ruleSaving}>
{ruleSaving ? 'Sauvegarde…' : 'Sauvegarder'}
</button>
<button type="button" className={styles.btnGhost} onClick={cancelRuleEdit}>
Annuler
</button>
{ruleMsg && (
<span className={ruleMsg.ok ? styles.success : styles.error}>
{ruleMsg.text}
</span>
)}
</div>
</form>
)}
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Ordre</th>
<th>Chapitre</th>
<th>Titre</th>
<th>Détails</th>
<th></th>
</tr>
</thead>
<tbody>
{engineeringRules.length === 0 ? (
<tr>
<td colSpan={5} className={styles.empty}>Aucune règle.</td>
</tr>
) : (
engineeringRules.map(rule => (
<tr key={rule.id} className={editingRule?.id === rule.id ? styles.rowEditing : ''}>
<td>{rule.sort_order}</td>
<td><code>{rule.chapter}</code></td>
<td>{rule.title}</td>
<td className={styles.ruleDetailsCell}>{rule.details}</td>
<td className={styles.actions}>
{ruleDeleteConfirm === rule.id ? (
<>
<span className={styles.confirmText}>Supprimer&nbsp;?</span>
<button
type="button"
className={styles.btnDanger}
onClick={() => handleRuleDelete(rule.id)}
>Oui</button>
<button
type="button"
className={styles.btnGhost}
onClick={() => setRuleDeleteConfirm(null)}
>Non</button>
</>
) : (
<>
<button
type="button"
className={styles.btnGhost}
onClick={() => openEditRule(rule)}
>Modifier</button>
<button
type="button"
className={styles.btnDangerGhost}
onClick={() => setRuleDeleteConfirm(rule.id)}
>Supprimer</button>
</>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
{/* ── SMTP / Email info ─────────────────────────────────── */}
<section className={styles.section}>
<h2>Configuration email (SMTP)</h2>
<p className={styles.muted}>
Ces valeurs sont définies dans le fichier <code>.env</code> du serveur. Redémarrez le serveur après modification.
</p>
<div className={styles.smtpGrid}>
{[
{ var: 'SMTP_HOST', desc: 'Serveur SMTP', example: 'smtp.gmail.com' },
{ var: 'SMTP_PORT', desc: 'Port', example: '587' },
{ var: 'SMTP_USER', desc: 'Utilisateur / adresse expéditeur', example: '[email protected]' },
{ var: 'SMTP_PASS', desc: 'Mot de passe / App Password', example: '••••••••••••' },
{ var: 'SMTP_FROM', desc: 'Nom et adresse affichés', example: '"Apex Configurateur <[email protected]>"' },
].map(({ var: v, desc, example }) => (
<div key={v} className={styles.smtpRow}>
<code className={styles.smtpVar}>{v}</code>
<span className={styles.smtpDesc}>{desc}</span>
<span className={styles.smtpExample}>{example}</span>
</div>
))}
</div>
</section>
{/* ── Suppliers ─────────────────────────────────────────── */}
<section className={styles.section}>
<div className={styles.sectionHeader}>
<h2>Fournisseurs</h2>
<button type="button" className={styles.btnPrimary} onClick={openNewSupplier}>
+ Nouveau fournisseur
</button>
</div>
{supplierMsg && !editingSupplier && (
<p className={supplierMsg.ok ? styles.success : styles.error}>{supplierMsg.text}</p>
)}
{/* Supplier form (inline) */}
{editingSupplier && (
<form onSubmit={handleSupplierSave} className={styles.supplierForm}>
<h3>{editingSupplier === 'new' ? 'Nouveau fournisseur' : `Modifier — ${editingSupplier.name}`}</h3>
<div className={styles.supplierFormGrid}>
<div className={styles.field}>
<label className={styles.label}>Code *</label>
<input
className={styles.input}
required
placeholder="ex. formex"
value={supplierDraft.code}
onChange={e => handleSupplierDraftChange('code', e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Nom *</label>
<input
className={styles.input}
required
placeholder="ex. Formex SA"
value={supplierDraft.name}
onChange={e => handleSupplierDraftChange('name', e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Email commandes *</label>
<input
className={styles.input}
type="email"
required
placeholder="[email protected]"
value={supplierDraft.email}
onChange={e => handleSupplierDraftChange('email', e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Type *</label>
<select
className={styles.input}
value={supplierDraft.type}
onChange={e => handleSupplierDraftChange('type', e.target.value)}
>
{SUPPLIER_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div className={styles.field}>
<label className={styles.label}>Statut</label>
<select
className={styles.input}
value={supplierDraft.active}
onChange={e => handleSupplierDraftChange('active', Number(e.target.value))}
>
<option value={1}>Actif</option>
<option value={0}>Inactif</option>
</select>
</div>
</div>
<div className={styles.formActions}>
<button type="submit" className={styles.btnPrimary} disabled={supplierSaving}>
{supplierSaving ? 'Sauvegarde…' : 'Sauvegarder'}
</button>
<button type="button" className={styles.btnGhost} onClick={cancelSupplierEdit}>
Annuler
</button>
{supplierMsg && (
<span className={supplierMsg.ok ? styles.success : styles.error}>
{supplierMsg.text}
</span>
)}
</div>
</form>
)}
{/* Suppliers table */}
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Code</th>
<th>Nom</th>
<th>Email commandes</th>
<th>Type</th>
<th>Statut</th>
<th></th>
</tr>
</thead>
<tbody>
{suppliers.length === 0 ? (
<tr>
<td colSpan={6} className={styles.empty}>Aucun fournisseur.</td>
</tr>
) : (
suppliers.map(s => (
<tr key={s.id} className={editingSupplier?.id === s.id ? styles.rowEditing : ''}>
<td><code>{s.code}</code></td>
<td>{s.name}</td>
<td>{s.email}</td>
<td>{typeLabel(s.type)}</td>
<td>
<span className={s.active ? styles.badgeActive : styles.badgeInactive}>
{s.active ? 'Actif' : 'Inactif'}
</span>
</td>
<td className={styles.actions}>
{deleteConfirm === s.id ? (
<>
<span className={styles.confirmText}>Supprimer&nbsp;?</span>
<button
type="button"
className={styles.btnDanger}
onClick={() => handleSupplierDelete(s.id)}
>Oui</button>
<button
type="button"
className={styles.btnGhost}
onClick={() => setDeleteConfirm(null)}
>Non</button>
</>
) : (
<>
<button
type="button"
className={styles.btnGhost}
onClick={() => openEditSupplier(s)}
>Modifier</button>
<button
type="button"
className={styles.btnDangerGhost}
onClick={() => setDeleteConfirm(s.id)}
>Supprimer</button>
</>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</>
)}
</div>
);
}
+384
View File
@@ -0,0 +1,384 @@
.page {
padding: 1.5rem 0 4rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
}
.header h1 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: var(--primary);
}
.muted {
color: var(--text-muted);
font-size: 0.9375rem;
margin: 0 0 1rem;
}
.error {
color: #b91c1c;
font-size: 0.9375rem;
margin: 0 0 1rem;
}
.success {
color: #16a34a;
font-size: 0.9375rem;
}
/* ── Sections ─────────────────────────────────── */
.section {
margin-bottom: 3rem;
}
.section h2 {
font-size: 1.125rem;
font-weight: 600;
color: var(--primary);
margin: 0 0 1.25rem;
}
.section h3 {
font-size: 0.9375rem;
font-weight: 600;
color: var(--primary);
margin: 0 0 1rem;
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
}
.sectionHeader h2 {
margin: 0;
}
/* ── Settings Form ────────────────────────────── */
.settingsForm {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.5rem;
}
.settingsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1.25rem 2rem;
margin-bottom: 1.5rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.label {
font-size: 0.875rem;
font-weight: 500;
color: var(--primary);
display: flex;
align-items: baseline;
gap: 0.375rem;
}
.unit {
font-weight: 400;
font-size: 0.75rem;
color: var(--text-muted);
}
.input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.9375rem;
color: var(--primary);
background: #fff;
transition: border-color 0.15s;
width: 100%;
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--accent);
}
/* ── SMTP Info Grid ───────────────────────────── */
.smtpGrid {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
margin-top: 0.75rem;
}
.smtpRow {
display: grid;
grid-template-columns: 160px 1fr 1fr;
align-items: center;
gap: 1rem;
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.875rem;
}
.smtpRow:last-child {
border-bottom: none;
}
.smtpRow:nth-child(odd) {
background: var(--bg);
}
.smtpVar {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.8125rem;
color: var(--accent);
background: transparent;
}
.smtpDesc {
color: var(--primary);
font-weight: 500;
}
.smtpExample {
color: var(--text-muted);
font-style: italic;
}
/* ── Supplier Form ────────────────────────────── */
.supplierForm {
background: var(--bg);
border: 1px solid var(--accent);
border-radius: 10px;
padding: 1.25rem 1.5rem;
margin-bottom: 1.25rem;
}
.supplierFormGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem 2rem;
margin-bottom: 1rem;
}
.ruleFormGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem 2rem;
margin-bottom: 1rem;
}
.fieldWide {
display: flex;
flex-direction: column;
gap: 0.375rem;
grid-column: 1 / -1;
}
.textarea {
padding: 0.625rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.9375rem;
line-height: 1.4;
color: var(--primary);
background: #fff;
resize: vertical;
min-height: 120px;
box-sizing: border-box;
}
.textarea:focus {
outline: none;
border-color: var(--accent);
}
/* ── Form Actions ─────────────────────────────── */
.formActions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
/* ── Buttons ──────────────────────────────────── */
.btnPrimary {
padding: 0.5625rem 1.25rem;
background: var(--accent);
border: none;
border-radius: 6px;
color: #fff;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.btnPrimary:hover:not(:disabled) {
background: var(--primary);
}
.btnPrimary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btnGhost {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
font-size: 0.9375rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
white-space: nowrap;
}
.btnGhost:hover {
border-color: var(--accent);
color: var(--primary);
}
.btnDanger {
padding: 0.4375rem 0.875rem;
background: #b91c1c;
border: none;
border-radius: 6px;
color: #fff;
font-size: 0.875rem;
cursor: pointer;
white-space: nowrap;
}
.btnDanger:hover {
background: #991b1b;
}
.btnDangerGhost {
padding: 0.4375rem 0.875rem;
background: transparent;
border: 1px solid #fca5a5;
border-radius: 6px;
color: #b91c1c;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.btnDangerGhost:hover {
background: #fef2f2;
}
/* ── Suppliers Table ──────────────────────────── */
.tableWrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 8px;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.table th,
.table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.table th {
background: var(--bg);
font-weight: 600;
color: var(--primary);
white-space: nowrap;
}
.table tbody tr:last-child td {
border-bottom: none;
}
.table tbody tr:hover {
background: var(--bg);
}
.rowEditing {
background: #eff6ff !important;
}
.empty {
color: var(--text-muted);
text-align: center;
padding: 2rem;
}
.actions {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: flex-end;
flex-wrap: nowrap;
white-space: nowrap;
}
.confirmText {
font-size: 0.8125rem;
color: #b91c1c;
font-weight: 500;
}
.ruleDetailsCell {
white-space: pre-wrap;
min-width: 360px;
}
/* ── Status Badges ────────────────────────────── */
.badgeActive {
display: inline-block;
padding: 0.2rem 0.6rem;
background: #dcfce7;
color: #15803d;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.badgeInactive {
display: inline-block;
padding: 0.2rem 0.6rem;
background: #f1f5f9;
color: var(--text-muted);
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
+9
View File
@@ -1,8 +1,17 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@shared': path.resolve(__dirname, '../shared'),
},
},
server: {
port: 5173,
proxy: {