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
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;
}
}