Ship configurator updates with catalog DB included for server deploy.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
lejay
2026-10-07 17:13:03 +02:00
co-authored by Cursor
parent 6c0d7e47b8
commit 30921aade6
75 changed files with 15618 additions and 1300 deletions
+5
View File
@@ -8,6 +8,7 @@ import Login from './pages/Login';
import Admin from './pages/Admin';
import Materials from './pages/Materials';
import Settings from './pages/Settings';
import Legal from './pages/Legal';
export default function App() {
return (
@@ -16,6 +17,10 @@ export default function App() {
<Layout>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/mentions-legales" element={<Legal />} />
<Route path="/confidentialite" element={<Legal />} />
<Route path="/conditions-generales" element={<Legal />} />
<Route path="/reglements" element={<Legal />} />
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
<Route path="/configurateur" element={<ProtectedRoute><Configurator /></ProtectedRoute>} />
<Route path="/materiaux" element={<ProtectedRoute><Materials /></ProtectedRoute>} />
+102
View File
@@ -117,6 +117,58 @@ export async function calculatePrice(config) {
return res.json();
}
export async function fetchSavedDesigns() {
const res = await fetch(`${API_BASE}/saved-designs`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function saveDesign(name, config) {
const res = await fetch(`${API_BASE}/saved-designs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, config })
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function updateSavedDesign(id, name, config) {
const res = await fetch(`${API_BASE}/saved-designs/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, config })
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function deleteSavedDesign(id) {
const res = await fetch(`${API_BASE}/saved-designs/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
}
export async function fetchLayoutPresets() {
const res = await fetch(`${API_BASE}/layout-presets`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function saveLayoutPreset(name, config) {
const res = await fetch(`${API_BASE}/layout-presets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, config })
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function deleteLayoutPreset(id) {
const res = await fetch(`${API_BASE}/layout-presets/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
}
export async function fetchOrders() {
const res = await fetch(`${API_BASE}/orders`);
if (!res.ok) throw new Error(await res.text());
@@ -245,6 +297,46 @@ export async function fetchHaefeleMaterials({ search = '', active = null, page =
return res.json();
}
// Leuba Hiag catalog sync API
export async function fetchLeubaHiagTargets() {
const res = await fetch(`${API_BASE}/sync/leubahiag/targets`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export function getLeubaHiagSyncStreamUrl({ targets = [], activate = false, fetchPrices = true } = {}) {
const params = new URLSearchParams();
const cleaned = (targets || []).filter(Boolean);
if (cleaned.length > 0) params.set('targets', cleaned.join(','));
if (activate) params.set('activate', '1');
if (!fetchPrices) params.set('prices', '0');
const query = params.toString();
return query ? `${API_BASE}/sync/leubahiag/stream?${query}` : `${API_BASE}/sync/leubahiag/stream`;
}
export async function triggerLeubaHiagSync(payload) {
const res = await fetch(`${API_BASE}/sync/leubahiag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload || {})
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchLeubaHiagMaterials({ search = '', active = null, page = 1, limit = 25 } = {}) {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (active !== null) params.set('active', active ? '1' : '0');
params.set('page', String(page));
params.set('limit', String(limit));
const res = await fetch(`${API_BASE}/leubahiag/materials?${params.toString()}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// Formex Materials CRUD
export async function fetchFormexMaterials({ search = '', collection = '', category = '', supplier = '', active = null, page = 1, limit = 25 } = {}) {
@@ -294,6 +386,16 @@ export async function updateFormexMaterial(id, data) {
return res.json();
}
export async function setFormexMaterialActive(id, active) {
const res = await fetch(`${API_BASE}/formex/materials/${id}/active`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active: !!active })
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function deleteFormexMaterial(id) {
const res = await fetch(`${API_BASE}/formex/materials/${id}`, {
method: 'DELETE'
+198 -54
View File
@@ -13,7 +13,7 @@ import {
Shape,
Vector3
} from 'three';
import { OrbitControls } from '@react-three/drei';
import { Html, OrbitControls } from '@react-three/drei';
import {
buildTopBottomPlanHoleSpecsMm,
buildVerticalPanelIxconnectFaceHoleSpecsMm,
@@ -21,9 +21,13 @@ import {
buildFinishHabillageHoleSpecsMm,
buildDoorHingeCupHoleSpecsMm,
buildDrawerReinforcementHoleSpecsMm,
buildDrawerRenfortPanelIxconnectSpecsMm
buildDrawerRenfortPanelIxconnectSpecsMm,
buildLegraboxRailHoleSpecsMm,
buildLegraboxFacadeInsertaSpecsMm,
buildAxiloFootHoleSpecsMm,
buildSystem32HoleSpecsMm
} from '@shared/cncPanelHoles.js';
import { GROOVE, getBackGroovePlanOrientation, getBackGrooveInnerEndInsetFromBackEdgeMm } from '@shared/manufacturing.js';
import { GROOVE, getBackGroovePlanOrientation } from '@shared/manufacturing.js';
import styles from './Cnc3DViewer.module.css';
/** Matches Canvas camera defaults so switching items without a saved view resets correctly. */
@@ -118,7 +122,7 @@ function PersistedOrbitControls({ storageKey }) {
makeDefault
enablePan
enableZoom
zoomSpeed={-0.6}
zoomSpeed={0.9}
enableRotate
rotateSpeed={0.6}
screenSpacePanning
@@ -254,9 +258,12 @@ function AxisAlignAnimator({ alignId, alignAxis, fallbackDistance }) {
function parseRawSize(gcode) {
const lines = (gcode || '').split(/\r?\n/);
let fromRaw = null;
for (const raw of lines) {
const line = String(raw || '');
if (!line.startsWith('; SIZE_RAW:')) continue;
const isFinished = line.startsWith('; SIZE:');
const isRaw = line.startsWith('; SIZE_RAW:');
if (!isFinished && !isRaw) continue;
const mx = line.match(/X=([0-9]+(\.[0-9]+)?)/i);
const my = line.match(/Y=([0-9]+(\.[0-9]+)?)/i);
const mt = line.match(/T=([0-9]+(\.[0-9]+)?)/i);
@@ -264,10 +271,33 @@ function parseRawSize(gcode) {
const heightM = (my ? Number(my[1]) : 0) / 1000;
const thickM = (mt ? Number(mt[1]) : 16) / 1000;
if (widthM > 0 && heightM > 0) {
return { widthM, heightM, thickM };
const parsed = { widthM, heightM, thickM };
if (isFinished) return parsed;
fromRaw = parsed;
}
}
return null;
return fromRaw;
}
/**
* Scene size must be the finished panel. SIZE_RAW includes saw surcote (+ banding
* compensation) piled on +X/+Y, which makes right-edge holes (montants, AXILO) look
* too far from the edge even though their coordinates are already in finished mm.
*/
function panelSceneSize(piece, gcode) {
const fromGcode = parseRawSize(gcode);
const widthMm = Number(piece?.widthMm);
const heightMm = Number(piece?.heightMm);
const thicknessMm = Number(piece?.thicknessMm);
if (widthMm > 0 && heightMm > 0) {
const thickMm = thicknessMm > 0 ? thicknessMm : ((fromGcode?.thickM || 0) * 1000) || 19;
return {
widthM: widthMm / 1000,
heightM: heightMm / 1000,
thickM: thickMm / 1000
};
}
return fromGcode;
}
function parseHoles(gcode) {
@@ -301,9 +331,10 @@ function planHoleSpecsToSceneHoles(piece, rawSize, partIndex = 0) {
if (!piece?.machining || !rawSize) return [];
const wMm = rawSize.widthM * 1000;
const hMm = rawSize.heightM * 1000;
const mirrorDepthAxis = ['dessus', 'fond'].includes(piece?.elementType || '');
const mapSpec = (spec) => ({
xM: spec.xMm / 1000,
yM: spec.yMm / 1000,
yM: (mirrorDepthAxis ? hMm - spec.yMm : spec.yMm) / 1000,
dM: spec.diameterMm / 1000,
zM: spec.depthMm / 1000,
type: spec.type,
@@ -312,12 +343,16 @@ function planHoleSpecsToSceneHoles(piece, rawSize, partIndex = 0) {
});
return [
...buildTopBottomPlanHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
...buildAxiloFootHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
...buildSystem32HoleSpecsMm(piece, wMm, hMm).map(mapSpec),
...buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
...buildShelfMobileHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
...buildFinishHabillageHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
...buildDoorHingeCupHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
...buildDrawerReinforcementHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
...buildDrawerRenfortPanelIxconnectSpecsMm(piece, wMm, hMm, partIndex).map(mapSpec)
...buildDrawerRenfortPanelIxconnectSpecsMm(piece, wMm, hMm, partIndex).map(mapSpec),
...buildLegraboxRailHoleSpecsMm(piece, wMm, hMm, partIndex).map(mapSpec),
...buildLegraboxFacadeInsertaSpecsMm(piece, wMm, hMm).map(mapSpec)
];
}
@@ -420,10 +455,38 @@ function ixconnectInbusFaceFromBottomThick(etPiece, h) {
return (
h.type === 'ixconnect_inbus' &&
(etPiece === 'dessus' || etPiece === 'fond') &&
h.face === 'face'
(h.face === 'face' || h.face === 'back')
);
}
/**
* Dessus — entrée en épaisseur :
* - SC habillage (accouplement bandeau) → face extérieure (haut, y=t)
* - jonctions caisse (côtés / séparateurs / inbus) → face caisse (bas, y=0, même côté que la rainure)
*/
function dessusFaceHoleFromBottom(h) {
if (!h || h.face === 'end') return null;
if (h.type === 'ixconnect_finish') return false;
// SC 8/25 finish depth is 12.5 mm — keep on top even if type stayed `ixconnect`.
if (
h.type === 'ixconnect' &&
h.face === 'face' &&
Math.abs((Number(h.zM) || 0) * 1000 - 12.5) < 0.35
) {
return false;
}
if (
h.type === 'tourillon' ||
h.type === 'ixconnect' ||
h.type === 'ixconnect_inbus' ||
h.type === 'ferrure' ||
h.face === 'back'
) {
return true;
}
return null;
}
/** Couleur des faces usinées (rainure) — pas le bois apparent extérieur. */
const GROOVE_CUT_FACE_COLOR = '#0d9488';
@@ -443,7 +506,6 @@ function getBackGrooveSceneParams(rawSize, piece) {
const gw = (m.width ?? GROOVE.back.width) / 1000;
const gd = (m.depth ?? GROOVE.back.depth) / 1000;
const axis = (m.axisFromPanelEdge ?? m.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge) / 1000;
const endInsetM = getBackGrooveInnerEndInsetFromBackEdgeMm() / 1000;
const eps = 0.00025;
/*
@@ -454,9 +516,8 @@ function getBackGrooveSceneParams(rawSize, piece) {
*/
if (orient === 'vertical') {
const xC = w - axis;
// Côtés extérieurs: la rainure ne doit pas aller sous/au-dessus du dessus+fond (19mm chacun) → -2×19mm.
const extraEndInsetM = piece?.elementType === 'cotes' ? (Number(piece?.thicknessMm) || 19) / 1000 : 0;
const zLen = Math.max(0.05, h - 2 * (endInsetM + extraEndInsetM));
// Side-panel back grooves run through the complete panel length.
const zLen = h;
const yTop = t;
const yBot = t - gd;
const min = new Vector3(xC - gw / 2, yBot - eps, h / 2 - zLen / 2);
@@ -472,7 +533,9 @@ function getBackGrooveSceneParams(rawSize, piece) {
return { min, max, innerFaces };
}
const zC = h - axis;
// Les panneaux horizontaux sont affichés avec l'avant côté +Z (repères Gauche/Droite).
// La rainure est cotée depuis le bord arrière : elle se place donc à +axis, pas à h-axis.
const zC = axis;
const et = piece.elementType || '';
const opensFromBottom = et === 'dessus' || et === 'bandeauHaut';
const y0 = 0.0002;
@@ -663,21 +726,26 @@ function buildPanelHoleDiscardUniforms(holes, rawSize, piece) {
const rawDepth = Math.max(0, Number(h.zM) || 0);
let depth = Math.min(t * 0.999, Math.max(0.0008, rawDepth));
if (h.type === 'tourillon') depth = Math.min(depth, 0.013);
const sepFromBottom = ixconnectInbusFaceFromBottomThick(etPiece, h)
? horizontalSeparatorFaceHoleFromBottomThick(etPiece)
: (etPiece === 'dessus' || etPiece === 'fond' || etPiece === 'bandeauHaut') &&
h.face === 'face' &&
!isHorizontalPanelExteriorCoteJunction(h.xM, w) &&
horizontalSeparatorFaceHoleFromBottomThick(etPiece);
const dessusDir = etPiece === 'dessus' ? dessusFaceHoleFromBottom(h) : null;
const sepFromBottom = dessusDir != null
? dessusDir
: ixconnectInbusFaceFromBottomThick(etPiece, h)
? horizontalSeparatorFaceHoleFromBottomThick(etPiece)
: etPiece === 'bandeauHaut' &&
h.face === 'face' &&
!isHorizontalPanelExteriorCoteJunction(h.xM, w) &&
horizontalSeparatorFaceHoleFromBottomThick(etPiece);
// Rayons: perçage face toujours depuis le dessous (face non visible).
const shelfFromBottom =
(etPiece === 'etageres' || etPiece === 'rayonFixe') &&
h.face === 'face' &&
h.type === 'shelf_mobile_face';
let fromBottom = sepFromBottom || shelfFromBottom;
// Habillage finition sur montant : lignes haut/bas 50 mm = entrée face extérieure (FACE=back), opposée à la face caisse.
// Séparateurs : renfort tiroir vers module supérieur (colonne droite) = FACE=back — grande face opposée à celle du module inférieur.
if ((etPiece === 'cotes' || etPiece === 'panneauxInterieurs') && h.face === 'back') fromBottom = true;
// FACE=back désigne toujours la grande face opposée : sous-face pour les panneaux horizontaux,
// face extérieure/opposée pour les montants et séparateurs.
if (h.face === 'back' && dessusDir == null) fromBottom = true;
// Habillage dessus : face intérieure (contre le caisson) = y=0 dans la géométrie onglet (y=t = face extérieure pleine largeur).
if (etPiece === 'habillageFinitionDessus' && h.face === 'face') fromBottom = true;
const y = fromBottom ? depth / 2 + 0.0002 : t - depth / 2 + 0.0002;
cx = h.xM;
cy = fromBottom ? depth / 2 + 0.0002 : t - depth / 2 + 0.0002;
@@ -710,7 +778,7 @@ function patchMaterialPanelDiscard(material, grooveParams, uMin, uMax, holeDisca
const grooveActive = grooveParams ? 1 : 0;
const hc = holeDiscard.count;
const mc = holeDiscard.miterCount || 0;
material.customProgramCacheKey = () => `apex-panel-cut-v12-g${grooveActive}-h${hc}-m${mc}`;
material.customProgramCacheKey = () => `apex-panel-cut-v14-g${grooveActive}-h${hc}-m${mc}`;
material.onBeforeCompile = (shader) => {
shader.uniforms.uGrooveActive = { value: grooveActive };
shader.uniforms.uGrooveMin = { value: uMin };
@@ -899,8 +967,10 @@ function GroovedPanelMesh({ rawSize, piece, materialColor, holes }) {
roughness: 0.72,
metalness: 0.05,
transparent: true,
opacity: 0.72,
depthWrite: false,
opacity: 0.82,
// Occlude underside hole cylinders when viewing the opposite face; openings
// still come from the discard shader on the real entry face.
depthWrite: true,
side: DoubleSide
});
if (!grooveParams && holeDiscard.count === 0) return m;
@@ -1060,6 +1130,40 @@ function GroovedPanelMesh({ rawSize, piece, materialColor, holes }) {
);
}
const FRONT_BACK_LABEL_TYPES = new Set([
'cotes',
'cotesDoublage',
'fileur',
'fileurCotes',
'panneauxInterieurs',
'plintheGauche',
'plintheDroite',
'habillageFinitionGauche',
'habillageFinitionDroite'
]);
function PieceSideLabels({ rawSize, piece }) {
if (!rawSize) return null;
const labelY = rawSize.thickM + Math.max(0.012, rawSize.thickM * 0.75);
const labelZ = rawSize.heightM / 2;
const sideOffset = Math.max(0.07, rawSize.widthM * 0.08);
const labels = FRONT_BACK_LABEL_TYPES.has(piece?.elementType)
? ['Devant', 'Derrière']
: ['Gauche', 'Droite'];
return (
<>
<Html position={[-sideOffset, labelY, labelZ]} center zIndexRange={[2, 1]}>
<span className={styles.sideLabel}>{labels[0]}</span>
</Html>
<Html position={[rawSize.widthM + sideOffset, labelY, labelZ]} center zIndexRange={[2, 1]}>
<span className={styles.sideLabel}>{labels[1]}</span>
</Html>
</>
);
}
function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, alignId, alignAxis, fallbackDistance }) {
/**
* The panel mesh sits in +X/+Z (center at w/2, t/2, h/2). GridHelper is a square in XZ centered on its
@@ -1088,16 +1192,25 @@ function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, align
{rawSize && (
<GroovedPanelMesh rawSize={rawSize} piece={piece} materialColor={materialColor} holes={holes} />
)}
{rawSize && holes.map((h, i) => {
<PieceSideLabels rawSize={rawSize} piece={piece} />
{rawSize && [...holes]
// Draw Inbus after its Ixconnect so the Ø6 core stays visible when coaxial.
.map((h, i) => ({ h, i }))
.sort((a, b) => {
const rank = (t) => (t === 'ixconnect_inbus' ? 1 : 0);
return rank(a.h.type) - rank(b.h.type) || a.i - b.i;
})
.map(({ h, i }) => {
const radius = Math.max(0.0001, Number(h.dM) / 2);
const etPiece = piece?.elementType || '';
const wPanel = rawSize.widthM;
const isInbus = h.type === 'ixconnect_inbus';
const color =
h.type === 'ixconnect'
h.type === 'ixconnect' || h.type === 'ixconnect_finish'
? '#7c3aed'
: String(h.type || '').startsWith('ixconnect_drawer')
? '#ea580c'
: h.type === 'ixconnect_inbus'
: isInbus
? '#a855f7'
: h.type === 'ferrure'
? '#0f766e'
@@ -1106,16 +1219,20 @@ function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, align
: h.type === 'tourillon'
? '#64748b'
: '#ea580c';
/* Dessus/fond : sur face (séparateurs), trio T–IX–T tout gris. Sur chant (montants), comme renfort : violet = Ixconnect seul au milieu, tourillons gris. */
/* Dessus/fond : jonctions caisse → trio gris ; SC habillage (finish) reste violet. */
let holeColor = color;
if ((etPiece === 'dessus' || etPiece === 'fond') && h.face === 'face') {
if (!isHorizontalPanelExteriorCoteJunction(h.xM, wPanel) && h.type === 'ixconnect') {
holeColor = '#64748b';
}
if ((etPiece === 'dessus' || etPiece === 'fond') && h.type === 'ixconnect' && h.face !== 'end') {
const isFinishSc =
etPiece === 'dessus' &&
h.face === 'face' &&
Math.abs((Number(h.zM) || 0) * 1000 - 12.5) < 0.35;
if (!isFinishSc) holeColor = '#64748b';
}
const lip = Math.min(0.00012, Math.max(0.00003, radius * 0.06));
const innerR = Math.max(0.00005, radius - lip);
const outerR = radius + lip;
const faceHoleRenderOrder = isInbus ? 16 : 12;
const ringRenderOrder = isInbus ? 18 : 11;
if (h.face === 'end') {
const w = rawSize.widthM;
@@ -1289,65 +1406,82 @@ function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, align
if (h.type === 'tourillon') depth = Math.min(depth, 0.013);
const forceThroughSystem32 = h.type === 'system32' && etPiece !== 'cotes';
const throughFace = rawDepth >= rawSize.thickM - 1e-5 || forceThroughSystem32;
const sepFromBottom = ixconnectInbusFaceFromBottomThick(etPiece, h)
? horizontalSeparatorFaceHoleFromBottomThick(etPiece)
: (etPiece === 'dessus' || etPiece === 'fond' || etPiece === 'bandeauHaut') &&
h.face === 'face' &&
!isHorizontalPanelExteriorCoteJunction(h.xM, wPanel) &&
horizontalSeparatorFaceHoleFromBottomThick(etPiece);
const dessusDir = etPiece === 'dessus' ? dessusFaceHoleFromBottom(h) : null;
const sepFromBottom = dessusDir != null
? dessusDir
: ixconnectInbusFaceFromBottomThick(etPiece, h)
? horizontalSeparatorFaceHoleFromBottomThick(etPiece)
: etPiece === 'bandeauHaut' &&
h.face === 'face' &&
!isHorizontalPanelExteriorCoteJunction(h.xM, wPanel) &&
horizontalSeparatorFaceHoleFromBottomThick(etPiece);
const shelfFromBottom =
(etPiece === 'etageres' || etPiece === 'rayonFixe') &&
h.face === 'face' &&
h.type === 'shelf_mobile_face';
let fromBottom = sepFromBottom || shelfFromBottom;
if ((etPiece === 'cotes' || etPiece === 'panneauxInterieurs') && h.face === 'back') fromBottom = true;
// FACE=back = entrée par la face opposée (sous-face du fond pour les pieds AXILO ;
// sous-face du dessus pour jonctions caisse côtés/séparateurs).
if (h.face === 'back' && dessusDir == null) fromBottom = true;
// Habillage dessus : Ixconnect sur la face intérieure (y=0), pas la face extérieure visible.
if (etPiece === 'habillageFinitionDessus' && h.face === 'face') fromBottom = true;
const y = throughFace
? rawSize.thickM / 2
: fromBottom
? depth / 2 + 0.0002
: rawSize.thickM - depth / 2 + 0.0002;
const ringY = fromBottom ? -0.00008 : rawSize.thickM + 0.00008;
// Keep entry rings on the real entry face (depth-tested). depthTest:false was
// painting underside openings onto the visible top face through the transparent panel.
const ringY = fromBottom ? -0.00035 : rawSize.thickM + 0.00035;
const ringRot = fromBottom ? [Math.PI / 2, 0, 0] : [-Math.PI / 2, 0, 0];
const ringOppY = fromBottom ? rawSize.thickM + 0.00008 : -0.00008;
const ringOppY = fromBottom ? rawSize.thickM + 0.00035 : -0.00035;
const ringOppRot = fromBottom ? [-Math.PI / 2, 0, 0] : [Math.PI / 2, 0, 0];
return (
<group key={`hole-${i}`}>
<mesh position={[h.xM, y, h.yM]} renderOrder={12}>
<mesh position={[h.xM, y, h.yM]} renderOrder={faceHoleRenderOrder}>
<cylinderGeometry args={[radius, radius, depth, 32, 1, true]} />
<meshStandardMaterial
color={holeColor}
roughness={0.45}
metalness={0.05}
metalness={isInbus ? 0.15 : 0.05}
emissive={isInbus ? holeColor : '#000000'}
emissiveIntensity={isInbus ? 0.25 : 0}
side={DoubleSide}
depthTest
depthWrite
/>
</mesh>
<mesh position={[h.xM, ringY, h.yM]} rotation={ringRot} renderOrder={11}>
<mesh position={[h.xM, ringY, h.yM]} rotation={ringRot} renderOrder={ringRenderOrder}>
<ringGeometry args={[innerR, outerR, 48]} />
<meshStandardMaterial
color={holeColor}
roughness={0.35}
metalness={0.1}
emissive={holeColor}
emissiveIntensity={0.2}
depthTest={false}
emissiveIntensity={isInbus ? 0.45 : 0.2}
depthTest
depthWrite={false}
side={DoubleSide}
polygonOffset
polygonOffsetFactor={-2}
polygonOffsetUnits={-2}
/>
</mesh>
{throughFace && (
<mesh position={[h.xM, ringOppY, h.yM]} rotation={ringOppRot} renderOrder={11}>
<mesh position={[h.xM, ringOppY, h.yM]} rotation={ringOppRot} renderOrder={ringRenderOrder}>
<ringGeometry args={[innerR, outerR, 48]} />
<meshStandardMaterial
color={holeColor}
roughness={0.35}
metalness={0.1}
emissive={holeColor}
emissiveIntensity={0.2}
depthTest={false}
emissiveIntensity={isInbus ? 0.45 : 0.2}
depthTest
depthWrite={false}
side={DoubleSide}
polygonOffset
polygonOffsetFactor={-2}
polygonOffsetUnits={-2}
/>
</mesh>
)}
@@ -1366,10 +1500,20 @@ export default function Cnc3DViewer({
materialColor = '#c4a574',
orbitStorageKey = 'default'
}) {
const rawSize = useMemo(() => parseRawSize(gcode), [gcode]);
const rawSize = useMemo(() => panelSceneSize(piece, gcode), [piece, gcode]);
const holes = useMemo(() => {
const fromG = parseHoles(gcode);
const mirrorDepthAxis = ['dessus', 'fond'].includes(piece?.elementType || '');
const fromG = parseHoles(gcode).map((hole) => (
mirrorDepthAxis && rawSize
? { ...hole, yM: rawSize.heightM - hole.yM }
: hole
));
const fromPlan = planHoleSpecsToSceneHoles(piece, rawSize);
const planIsComplete = ['dessus', 'fond', 'cotes', 'panneauxInterieurs']
.includes(piece?.elementType || '');
// These panels are fully represented by shared machining specs. Combining
// them with raw G-code renders a second, offset copy of the same holes.
if (planIsComplete && fromPlan.length) return fromPlan;
return mergeHoleListsByPosition(fromG, fromPlan);
}, [gcode, piece, rawSize]);
@@ -8,6 +8,24 @@
touch-action: none;
}
.sideLabel {
display: inline-flex;
align-items: center;
padding: 3px 7px;
border: 1px solid rgba(71, 85, 105, 0.35);
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: #334155;
font-size: 0.68rem;
font-weight: 700;
line-height: 1;
letter-spacing: 0.02em;
white-space: nowrap;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.16);
pointer-events: none;
user-select: none;
}
.axisBar {
position: absolute;
left: 8px;
+12 -4
View File
@@ -30,7 +30,7 @@ function getPieceDepthMm(piece, cuttingListConfigDepthMm) {
// 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 depthIsWidth = new Set(['cotes', 'cotesDoublage', 'fileur', 'habillageFinitionGauche', 'habillageFinitionDroite', 'plintheGauche', 'plintheDroite']);
const depthIsHeight = new Set(['dessus', 'fond', 'habillageFinitionDessus']);
if (depthIsWidth.has(t)) return width ?? null;
@@ -186,10 +186,14 @@ export default function CuttingListTable({ cuttingList, onExportMprPiece, cncPro
function buildManufacturingRows(piece) {
const m = piece?.machining || {};
const surcote = piece.surcoteMm ? ` · surcote sur-mesure ${piece.surcoteMm} mm L+l` : '';
const bandingDir = piece.edgeBanding?.direction?.label
? ` · sens de collage: ${piece.edgeBanding.direction.label}`
: '';
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`]
['Dimensions brutes CNC', `${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm${surcote}`],
['Chants', `${piece.edgeBanding?.sides || 0} côté(s) · épaisseur ${piece.edgeBanding?.thickness || 0} mm${bandingDir}`]
];
if (m.system32) {
@@ -417,8 +421,12 @@ export default function CuttingListTable({ cuttingList, onExportMprPiece, cncPro
<td className={styles.surfaceCell}>{piece.surfaceM2.toFixed(3)}</td>
<td className={styles.edgeCell}>
{piece.edgeBanding.type !== 'none' && (
<span className={`${styles.edgeBadge} ${styles[piece.edgeBanding.type]}`}>
<span
className={`${styles.edgeBadge} ${styles[piece.edgeBanding.type]}`}
title={piece.edgeBanding.direction?.label ? `Sens de collage: ${piece.edgeBanding.direction.label}` : undefined}
>
{piece.edgeBanding.sides} côtés
{piece.edgeBanding.direction?.label ? ` · ${piece.edgeBanding.direction.label}` : ''}
</span>
)}
</td>
+31 -7
View File
@@ -1,10 +1,25 @@
import { Link, useNavigate } from 'react-router-dom';
import { useEffect, useRef } from 'react';
import { Link, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import styles from './Layout.module.css';
const navClass = ({ isActive }) => (isActive ? styles.navActive : undefined);
export default function Layout({ children }) {
const { isAdmin, logout } = useAuth();
const navigate = useNavigate();
const headerRef = useRef(null);
useEffect(() => {
const header = headerRef.current;
if (!header) return undefined;
const root = document.documentElement;
const update = () => root.style.setProperty('--header-height', `${header.offsetHeight}px`);
update();
const observer = new ResizeObserver(update);
observer.observe(header);
return () => observer.disconnect();
}, []);
const handleLogout = () => {
logout();
@@ -13,16 +28,16 @@ export default function Layout({ children }) {
return (
<div className={styles.app}>
<header className={styles.header}>
<header ref={headerRef} className={styles.header}>
<Link to="/" className={styles.logo}>SIGNATURE</Link>
<nav>
{isAdmin ? (
<>
<Link to="/">Accueil</Link>
<Link to="/configurateur">Configurateur</Link>
<Link to="/materiaux">Matériaux</Link>
<Link to="/admin">Admin</Link>
<Link to="/parametres">Paramètres</Link>
<NavLink to="/" end className={navClass}>Accueil</NavLink>
<NavLink to="/configurateur" className={navClass}>Configurateur</NavLink>
<NavLink to="/materiaux" className={navClass}>Matériaux</NavLink>
<NavLink to="/admin" className={navClass}>Admin</NavLink>
<NavLink to="/parametres" className={navClass}>Paramètres</NavLink>
<button type="button" className={styles.logoutBtn} onClick={handleLogout}>Déconnexion</button>
</>
) : (
@@ -33,6 +48,15 @@ export default function Layout({ children }) {
<main className={styles.main}>
{children}
</main>
<footer className={styles.footer}>
<span className={styles.footerBrand}>© {new Date().getFullYear()} SIGNATURE</span>
<nav className={styles.footerNav}>
<Link to="/mentions-legales">Mentions légales</Link>
<Link to="/confidentialite">Politique de confidentialité</Link>
<Link to="/conditions-generales">Conditions générales</Link>
<Link to="/reglements">Règlements</Link>
</nav>
</footer>
</div>
);
}
+58 -1
View File
@@ -9,8 +9,15 @@
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: var(--bg-card);
background: rgba(255, 255, 255, 0.9);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
border-bottom: 1px solid var(--border);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
position: -webkit-sticky;
position: sticky;
top: 0;
z-index: 70;
}
.logo {
@@ -40,6 +47,23 @@
color: var(--primary);
}
.header nav a.navActive {
position: relative;
color: var(--primary);
font-weight: 600;
}
.header nav a.navActive::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -0.375rem;
height: 2px;
border-radius: 2px;
background: var(--accent);
}
.header nav .logoutBtn {
background: none;
border: none;
@@ -61,3 +85,36 @@
margin: 0 auto;
padding: 0 1.5rem;
}
.footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1.5rem;
padding: 1rem 1.5rem;
margin-top: 2rem;
background: var(--bg-card);
border-top: 1px solid var(--border);
font-size: 0.82rem;
color: var(--text-muted);
}
.footerBrand {
font-weight: 600;
}
.footerNav {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
}
.footerNav a {
color: var(--text-muted);
font-weight: 500;
}
.footerNav a:hover {
color: var(--primary);
}
+91 -1
View File
@@ -12,7 +12,7 @@ import Icon from './ui/Icon';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const PLANS_TAB_KEY = 'apex.plans.tab';
const VALID_PLAN_TABS = ['assembly', 'side', 'top', 'cutting', 'hardware'];
const VALID_PLAN_TABS = ['assembly', 'side', 'top', 'cutting', 'calepinage', 'hardware'];
function readSavedPlanTab() {
try {
@@ -34,6 +34,8 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId
const [activeTab, setActiveTab] = useState(readSavedPlanTab);
const [plans, setPlans] = useState(null);
const [cuttingList, setCuttingList] = useState(null);
const [cuttingPlan, setCuttingPlan] = useState(null);
const [cuttingPlanError, setCuttingPlanError] = useState(null);
const [hardwareList, setHardwareList] = useState(null);
const [hardwareError, setHardwareError] = useState(null);
const [loading, setLoading] = useState(true);
@@ -128,6 +130,26 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId
setHardwareError('Erreur lors de la génération de la liste de quincaillerie');
}
// Fetch cutting plan (calepinage)
try {
const planRes = await fetch(`${API_BASE}/api/plans/cutting-plan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
const planData = await planRes.json();
if (planRes.ok && planData.success) {
setCuttingPlan(planData.cuttingPlan);
setCuttingPlanError(null);
} else {
setCuttingPlan(null);
setCuttingPlanError(planData.error || 'Plan de coupe indisponible.');
}
} catch {
setCuttingPlan(null);
setCuttingPlanError('Plan de coupe indisponible.');
}
const cncRes = await fetch(`${API_BASE}/api/plans/export-cnc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -246,6 +268,21 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId
a.click();
URL.revokeObjectURL(url);
setExportStatus('Archive Homag MPR (.zip) téléchargée.');
} else if (format === 'excel') {
const res = await fetch(`${API_BASE}/api/plans/export-excel`, {
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 url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'liste-debitage.xlsx';
a.click();
URL.revokeObjectURL(url);
setExportStatus('Liste de débitage exportée en Excel (.xlsx).');
}
} catch (err) {
console.error('Export error:', err);
@@ -303,6 +340,7 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId
{ id: 'side', label: 'Vue de côté' },
{ id: 'top', label: 'Vue de dessus' },
{ id: 'cutting', label: 'Liste de débitage' },
{ id: 'calepinage', label: 'Calepinage' },
{ id: 'hardware', label: 'Quincaillerie' }
];
@@ -384,6 +422,49 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId
/>
)}
{activeTab === 'calepinage' && (
cuttingPlanError ? (
<div className={styles.empty}>{cuttingPlanError}</div>
) : cuttingPlan ? (
<div className={styles.calepinage}>
<div className={styles.calepinageSummary}>
<div className={styles.calepinageStat}>
<span>Panneaux nécessaires</span>
<strong>{cuttingPlan.totalBoards}</strong>
</div>
<div className={styles.calepinageStat}>
<span>Format panneau</span>
<strong>{cuttingPlan.stock?.widthMm} × {cuttingPlan.stock?.heightMm} mm</strong>
</div>
<div className={styles.calepinageStat}>
<span>Surface utilisée</span>
<strong>{cuttingPlan.totalUsedAreaM2} m²</strong>
</div>
<div className={styles.calepinageStat}>
<span>Chute totale</span>
<strong>{cuttingPlan.totalWasteAreaM2} m² ({cuttingPlan.globalWastePercent}%)</strong>
</div>
</div>
<div className={styles.calepinageBoards}>
{cuttingPlan.boards.map((board, i) => (
<div key={i} className={styles.calepinageBoard}>
<div className={styles.calepinageBoardHead}>
<span>Panneau {i + 1} — {board.finishLabel} · {board.thicknessMm} mm</span>
<span>Chute {board.wastePercent}%</span>
</div>
<div
className={styles.calepinageSvg}
dangerouslySetInnerHTML={{ __html: board.svg }}
/>
</div>
))}
</div>
</div>
) : (
<div className={styles.empty}>Plan de coupe en cours de génération…</div>
)
)}
{activeTab === 'hardware' && (
<HardwareListTable
hardwareList={hardwareList}
@@ -417,6 +498,15 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId
Liste texte
</button>
)}
{(activeTab === 'cutting' || activeTab === 'calepinage' || activeTab === 'hardware') && (
<button
className={styles.exportBtn}
onClick={() => handleExport('excel')}
>
<Icon name="download" size={15} />
Export Excel (.xlsx)
</button>
)}
<button
className={styles.exportBtn}
onClick={() => handleExport('pdf')}
@@ -144,6 +144,72 @@
margin: 0 auto;
}
.calepinage {
padding: 16px;
overflow: auto;
}
.calepinageSummary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.calepinageStat {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 14px;
background: var(--surface-2, #f3f4f6);
border: 1px solid var(--border, #e5e7eb);
border-radius: 8px;
}
.calepinageStat span {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted, #6b7280);
}
.calepinageStat strong {
font-size: 1rem;
color: var(--text, #111827);
}
.calepinageBoards {
display: flex;
flex-direction: column;
gap: 24px;
}
.calepinageBoard {
border: 1px solid var(--border, #e5e7eb);
border-radius: 8px;
padding: 12px;
background: #fff;
}
.calepinageBoardHead {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
font-weight: 600;
color: var(--text, #111827);
margin-bottom: 8px;
}
.calepinageSvg {
width: 100%;
overflow: auto;
}
.calepinageSvg svg {
max-width: 100%;
height: auto;
}
.planContainer {
display: flex;
align-items: center;
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useState } from 'react';
import { deleteSavedDesign, fetchSavedDesigns, saveDesign, updateSavedDesign } from '../api/client';
import Icon from './ui/Icon';
import styles from './SavedDesignsModal.module.css';
function errorMessage(error) {
try {
return JSON.parse(error.message)?.error || error.message;
} catch {
return error.message || 'Une erreur est survenue.';
}
}
export default function SavedDesignsModal({ config, onRestore, onClose }) {
const [designs, setDesigns] = useState([]);
const [name, setName] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [updatingId, setUpdatingId] = useState(null);
const [deletingId, setDeletingId] = useState(null);
const [error, setError] = useState('');
const [updateSuccess, setUpdateSuccess] = useState(null);
useEffect(() => {
fetchSavedDesigns()
.then(setDesigns)
.catch((err) => setError(errorMessage(err)))
.finally(() => setLoading(false));
}, []);
const handleSave = async (event) => {
event.preventDefault();
const trimmedName = name.trim();
if (!trimmedName) return;
setSaving(true);
setError('');
setUpdateSuccess(null);
try {
const design = await saveDesign(trimmedName, config);
setDesigns((current) => [design, ...current]);
setName('');
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const handleUpdate = async (design) => {
if (!window.confirm(`Remplacer « ${design.name} » par le design actuellement affiché ?`)) return;
setUpdatingId(design.id);
setError('');
setUpdateSuccess(null);
try {
const updated = await updateSavedDesign(design.id, design.name, config);
setDesigns((current) => [updated, ...current.filter((item) => item.id !== design.id)]);
setUpdateSuccess({ id: design.id, name: design.name });
} catch (err) {
setError(errorMessage(err));
} finally {
setUpdatingId(null);
}
};
const handleDelete = async (design) => {
if (!window.confirm(`Supprimer « ${design.name} » ?`)) return;
setDeletingId(design.id);
setError('');
setUpdateSuccess(null);
try {
await deleteSavedDesign(design.id);
setDesigns((current) => current.filter((item) => item.id !== design.id));
} catch (err) {
setError(errorMessage(err));
} finally {
setDeletingId(null);
}
};
return (
<div className={styles.overlay} role="presentation" onMouseDown={onClose}>
<section
className={styles.modal}
role="dialog"
aria-modal="true"
aria-labelledby="saved-designs-title"
onMouseDown={(event) => event.stopPropagation()}
>
<header className={styles.header}>
<div>
<h2 id="saved-designs-title">Armoires sauvegardées</h2>
<p>Enregistrez le design actuel ou reprenez une armoire existante.</p>
</div>
<button type="button" className={styles.close} onClick={onClose} aria-label="Fermer">
<Icon name="x" size={20} />
</button>
</header>
<form className={styles.saveForm} onSubmit={handleSave}>
<label htmlFor="design-name">Nom du design</label>
<div className={styles.saveRow}>
<input
id="design-name"
type="text"
maxLength={100}
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Ex. Armoire chambre principale"
autoFocus
/>
<button type="submit" disabled={saving || !name.trim()}>
<Icon name="save" size={16} />
{saving ? 'Enregistrement…' : 'Enregistrer'}
</button>
</div>
</form>
{error && <p className={styles.error}>{error}</p>}
{updateSuccess && (
<p className={styles.success} role="status" aria-live="polite">
<span aria-hidden="true">✓</span>
« {updateSuccess.name} » a bien été mise à jour.
</p>
)}
<div className={styles.list}>
{loading ? (
<p className={styles.empty}>Chargement…</p>
) : designs.length === 0 ? (
<p className={styles.empty}>Aucune armoire sauvegardée.</p>
) : (
designs.map((design) => {
const savedConfig = design.config || {};
const date = new Date(`${design.updated_at?.replace(' ', 'T')}Z`);
return (
<article className={styles.design} key={design.id}>
<div className={styles.designInfo}>
<strong>{design.name}</strong>
<span>
{savedConfig.widthMm || '—'} × {savedConfig.heightMm || '—'} × {savedConfig.depthMm || '—'} mm
{' · '}
{(savedConfig.modules || []).length} colonne{(savedConfig.modules || []).length > 1 ? 's' : ''}
</span>
<small>
Sauvegardée {Number.isNaN(date.getTime()) ? '' : date.toLocaleString('fr-CH')}
</small>
</div>
<div className={styles.actions}>
<button
type="button"
className={`${styles.update} ${updateSuccess?.id === design.id ? styles.updateSuccess : ''}`}
disabled={updatingId === design.id}
onClick={() => handleUpdate(design)}
title="Remplacer cette sauvegarde par le design actuel"
>
<Icon name="save" size={16} />
{updatingId === design.id
? 'Mise à jour…'
: updateSuccess?.id === design.id
? 'Mise à jour ✓'
: 'Mettre à jour'}
</button>
<button
type="button"
className={styles.restore}
disabled={!design.config}
onClick={() => {
onRestore(design.config);
onClose();
}}
>
<Icon name="restore" size={16} />
Restaurer
</button>
<button
type="button"
className={styles.delete}
disabled={deletingId === design.id}
onClick={() => handleDelete(design)}
aria-label={`Supprimer ${design.name}`}
title="Supprimer"
>
<Icon name="trash" size={16} />
</button>
</div>
</article>
);
})
)}
</div>
</section>
</div>
);
}
@@ -0,0 +1,235 @@
.overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(15, 23, 42, 0.62);
}
.modal {
width: min(680px, 100%);
max-height: min(760px, 90vh);
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 12px;
background: var(--bg-card);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.3);
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.4rem 1rem;
border-bottom: 1px solid var(--border);
}
.header h2 {
margin: 0;
color: var(--primary);
font-size: 1.15rem;
}
.header p {
margin: 0.3rem 0 0;
color: var(--text-muted);
font-size: 0.8rem;
}
.close {
display: inline-flex;
padding: 0.35rem;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.close:hover { background: var(--bg); color: var(--text); }
.saveForm {
padding: 1rem 1.4rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.saveForm label {
display: block;
margin-bottom: 0.4rem;
color: var(--text);
font-size: 0.78rem;
font-weight: 600;
}
.saveRow {
display: flex;
gap: 0.6rem;
}
.saveRow input {
min-width: 0;
flex: 1;
padding: 0.6rem 0.7rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
font: inherit;
}
.saveRow button,
.restore,
.update {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.6rem 0.85rem;
border: 1px solid var(--action);
border-radius: 6px;
background: var(--action);
color: #fff;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.saveRow button:disabled,
.restore:disabled,
.update:disabled {
opacity: 0.5;
cursor: default;
}
.error {
margin: 0;
padding: 0.7rem 1.4rem;
background: #fef2f2;
color: #b91c1c;
font-size: 0.8rem;
}
.success {
display: flex;
align-items: center;
gap: 0.45rem;
margin: 0;
padding: 0.7rem 1.4rem;
background: #ecfdf5;
color: #047857;
font-size: 0.8rem;
font-weight: 600;
}
.success span {
font-size: 1rem;
line-height: 1;
}
.list {
min-height: 130px;
overflow-y: auto;
padding: 0.6rem 1.4rem 1.2rem;
}
.empty {
padding: 2rem 1rem;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
}
.design {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.85rem 0;
border-bottom: 1px solid var(--border);
}
.designInfo {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.designInfo strong {
overflow: hidden;
color: var(--text);
font-size: 0.9rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.designInfo span,
.designInfo small {
color: var(--text-muted);
font-size: 0.75rem;
}
.actions {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.restore,
.update {
padding: 0.5rem 0.7rem;
background: transparent;
color: var(--action);
}
.restore:hover { background: var(--action); color: #fff; }
.update {
border-color: var(--border);
color: var(--text);
}
.update:hover {
border-color: var(--action);
color: var(--action);
}
.updateSuccess,
.updateSuccess:hover {
border-color: #059669;
background: #ecfdf5;
color: #047857;
}
.delete {
display: inline-flex;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.delete:hover { border-color: #dc2626; color: #dc2626; }
.delete:disabled { opacity: 0.5; cursor: default; }
@media (max-width: 560px) {
.saveRow,
.design {
align-items: stretch;
flex-direction: column;
}
.actions { justify-content: flex-end; }
.restore,
.update { flex: 1; }
}
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useRef, useState } from 'react';
import styles from './SectionNav.module.css';
export default function SectionNav({ sections }) {
const [active, setActive] = useState(sections[0]?.id ?? '');
const navRef = useRef(null);
const isClickScrolling = useRef(false);
const clickTimeout = useRef(null);
useEffect(() => {
const els = sections
.map((s) => document.getElementById(s.id))
.filter(Boolean);
if (els.length === 0) return undefined;
const ratios = new Map();
const observer = new IntersectionObserver(
(entries) => {
if (isClickScrolling.current) return;
for (const entry of entries) {
ratios.set(entry.target.id, entry.intersectionRatio);
}
let best = '';
let bestRatio = -1;
for (const [id, ratio] of ratios) {
if (ratio > bestRatio) {
best = id;
bestRatio = ratio;
}
}
if (best && bestRatio > 0) setActive(best);
},
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1] }
);
for (const el of els) observer.observe(el);
return () => observer.disconnect();
}, [sections]);
useEffect(() => () => clearTimeout(clickTimeout.current), []);
const handleClick = (id) => {
const el = document.getElementById(id);
if (!el) return;
setActive(id);
isClickScrolling.current = true;
const nav = navRef.current;
const stickyBottom = nav ? nav.getBoundingClientRect().bottom : 0;
const top = el.getBoundingClientRect().top + window.scrollY - stickyBottom - 8;
window.scrollTo({ top, behavior: 'smooth' });
clearTimeout(clickTimeout.current);
clickTimeout.current = setTimeout(() => {
isClickScrolling.current = false;
}, 800);
};
return (
<nav ref={navRef} className={styles.sectionNav}>
<div className={styles.inner}>
{sections.map((s) => (
<button
key={s.id}
type="button"
onClick={() => handleClick(s.id)}
className={`${styles.item} ${active === s.id ? styles.itemActive : ''}`}
>
{s.label}
</button>
))}
</div>
</nav>
);
}
@@ -0,0 +1,52 @@
.sectionNav {
position: -webkit-sticky;
position: sticky;
top: var(--header-height);
z-index: 60;
margin: 0 -1.5rem 1.5rem;
background: rgba(255, 255, 255, 0.9);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
border-bottom: 1px solid var(--border);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.inner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 1.5rem;
}
.item {
position: relative;
padding: 0.5rem 0.6rem;
border: none;
background: transparent;
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.25;
white-space: nowrap;
color: var(--text-muted);
transition: color 0.15s;
}
.item:hover {
color: var(--text);
}
.itemActive {
color: var(--action);
}
.itemActive::after {
content: '';
position: absolute;
left: 0.4rem;
right: 0.4rem;
bottom: 0;
height: 2px;
border-radius: 999px;
background: var(--action);
}
+37 -33
View File
@@ -10,6 +10,8 @@ import * as THREE from 'three';
// Default colors for different element types
const DEFAULT_COLORS = {
plintheBas: '#8b7355',
plintheGauche: '#8b7355',
plintheDroite: '#8b7355',
fileur: '#8b7355',
bandeauHaut: '#8b7355',
champs: '#6b5344',
@@ -32,6 +34,8 @@ const DEFAULT_COLORS = {
// Element labels for display
const ELEMENT_LABELS = {
plintheBas: 'Plinthe bas',
plintheGauche: 'Plinthe retour gauche',
plintheDroite: 'Plinthe retour droit',
fileur: 'Fileur',
bandeauHaut: 'Bandeau haut',
champs: 'Champs',
@@ -63,12 +67,27 @@ function getElementColor(elementType, colors) {
return DEFAULT_COLORS[elementType] || '#cccccc';
}
/**
* Cross-origin swatch images (e.g. supplier CDNs) taint the <canvas> we use to
* build WebGL textures, so the texture upload and dominant-color sampling both
* fail and the element falls back to a flat default color. Route absolute URLs
* through our same-origin proxy so the canvas stays clean. Relative URLs
* (locally cached materials under /materials/...) are already same-origin.
*/
function toCanvasSafeUrl(url) {
if (!url) return null;
if (/^https?:\/\//i.test(url)) {
return `/api/material-image?url=${encodeURIComponent(url)}`;
}
return url;
}
/**
* Get image URL for element texture (if available)
*/
function getElementImageUrl(elementType, colors) {
if (colors && colors[elementType] && colors[elementType].imageUrl) {
return colors[elementType].imageUrl;
return toCanvasSafeUrl(colors[elementType].imageUrl);
}
return null;
}
@@ -197,8 +216,10 @@ function loadCroppedTexture(url) {
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
// Clamp (don't repeat): a single swatch is stretched to fill each panel so
// the non-seamless thumbnail edges never tile into a visible grid of squares.
tex.wrapS = THREE.ClampToEdgeWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.colorSpace = THREE.SRGBColorSpace;
tex.minFilter = THREE.LinearMipmapLinearFilter;
tex.magFilter = THREE.LinearFilter;
@@ -281,37 +302,20 @@ function useCroppedTexture(imageUrl, geometryArgs, geometryType) {
}
/**
* Configure texture repeat based on geometry dimensions.
* The cropped swatch represents approximately 0.3m x 0.3m of real material.
* Stretch a single swatch across the whole face instead of tiling it.
*
* The supplier thumbnails are not seamless, so any repeat > 1 produces a visible
* grid of squares at every tile boundary. Filling each panel with exactly one
* swatch (repeat = 1, clamped) removes that grid entirely. The `geometryArgs` /
* `geometryType` arguments are kept for API compatibility (callers pass them and
* the repeat is re-applied when geometry changes).
*/
function configureTextureRepeat(tex, geometryArgs, geometryType) {
if (!tex || !geometryArgs) return;
const TEXTURE_PATCH_SIZE = 0.3; // meters
let widthM, heightM;
switch (geometryType) {
case 'box':
widthM = geometryArgs[0] || 1;
heightM = geometryArgs[1] || 1;
break;
case 'plane':
widthM = geometryArgs[0] || 1;
heightM = geometryArgs[1] || 1;
break;
case 'cylinder':
widthM = 0.3;
heightM = 0.3;
break;
default:
widthM = 1;
heightM = 1;
}
tex.repeat.set(
Math.max(1, widthM / TEXTURE_PATCH_SIZE),
Math.max(1, heightM / TEXTURE_PATCH_SIZE)
);
function configureTextureRepeat(tex /* , geometryArgs, geometryType */) {
if (!tex) return;
tex.wrapS = THREE.ClampToEdgeWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.repeat.set(1, 1);
tex.offset.set(0, 0);
tex.needsUpdate = true;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,225 @@
import { useEffect, useMemo, useState } from 'react';
import { deleteLayoutPreset, fetchLayoutPresets, saveLayoutPreset } from '../../api/client';
import {
minShelvesForColumn, moduleBlockHeightsMm, moduleHangingZoneMm, moduleStackInfo, pickLayoutPreset, resolveShelfPositionsMm
} from '../../constants/wardrobe';
import { proposeLayout } from '../../constants/layoutProposal';
import { drawerStackHeightMm } from '@shared/manufacturing.js';
import Icon from '../ui/Icon';
import styles from './LayoutPresets.module.css';
function errorMessage(error) {
try {
return JSON.parse(error.message)?.error || error.message;
} catch {
return error.message || 'Une erreur est survenue.';
}
}
/** Élévation de face schématique: caisson, blocks, rayons, tiroirs et tringles. */
function LayoutThumbnail({ layout, drawerHeightsMm }) {
const cfg = { plinthHeightMm: 100, ...layout };
const W = Number(cfg.widthMm) || 1;
const H = Number(cfg.heightMm) || 1;
const plinth = Number(cfg.plinthHeightMm) || 0;
const bodyTop = 0;
const bodyBottom = H - plinth;
const yFromCarcass = (mm) => bodyBottom - mm;
const modules = cfg.modules || [];
let x = 0;
return (
<svg className={styles.thumb} viewBox={`-30 -30 ${W + 60} ${H + 60}`} preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<rect x={0} y={bodyBottom} width={W} height={plinth} className={styles.thumbPlinth} />
<rect x={0} y={bodyTop} width={W} height={bodyBottom} className={styles.thumbBody} />
{modules.map((mod, i) => {
const w = Number(mod.widthMm) || 0;
const x0 = x;
x += w;
const heights = moduleBlockHeightsMm(mod, cfg);
const open = Array.isArray(mod.blockDoors) && mod.blockDoors.every((d) => d === false);
const shelfCount = Math.max(minShelvesForColumn(cfg.heightMm, mod, cfg), Number(mod.shelves) || 0);
const shelves = resolveShelfPositionsMm({
shelfCount,
heightMm: cfg.heightMm,
plinthHeightMm: plinth,
shelfPositionsMm: mod.shelfPositionsMm,
stack: moduleStackInfo(mod, cfg)
});
const drawers = Math.max(0, Number(mod.drawers) || 0);
const drawerStack = drawers > 0 ? drawerStackHeightMm(drawers, drawerHeightsMm) : 0;
const drawerH = drawers > 0 ? drawerStack / drawers : 0;
const boundaries = heights.slice(0, -1).reduce((acc, h) => [...acc, (acc[acc.length - 1] || 0) + h], []);
return (
<g key={mod.id || i}>
{open && <rect x={x0} y={bodyTop} width={w} height={bodyBottom} className={styles.thumbOpen} />}
{i > 0 && <line x1={x0} y1={bodyTop} x2={x0} y2={bodyBottom} className={styles.thumbPanel} />}
{boundaries.map((b) => (
<line key={`b${b}`} x1={x0} y1={yFromCarcass(b)} x2={x0 + w} y2={yFromCarcass(b)} className={styles.thumbPanel} />
))}
{shelves.map((p) => (
<line key={`s${p}`} x1={x0 + 18} y1={yFromCarcass(p)} x2={x0 + w - 18} y2={yFromCarcass(p)} className={styles.thumbShelf} />
))}
{Array.from({ length: drawers }, (_, d) => (
<g key={`d${d}`}>
<rect x={x0 + 25} y={yFromCarcass((d + 1) * drawerH)} width={w - 50} height={drawerH - 12} className={styles.thumbDrawer} />
<line x1={x0 + w / 2 - 50} y1={yFromCarcass((d + 0.5) * drawerH)} x2={x0 + w / 2 + 50} y2={yFromCarcass((d + 0.5) * drawerH)} className={styles.thumbHandle} />
</g>
))}
{mod.hasRod && (() => {
const rodY = yFromCarcass(moduleHangingZoneMm(mod, cfg).rodMm);
return <line x1={x0 + 30} y1={rodY} x2={x0 + w - 30} y2={rodY} className={styles.thumbRod} />;
})()}
</g>
);
})}
</svg>
);
}
export default function LayoutPresets({ wc }) {
const { config } = wc;
const [presets, setPresets] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [naming, setNaming] = useState(false);
const [name, setName] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
fetchLayoutPresets()
.then(setPresets)
.catch((err) => setError(errorMessage(err)))
.finally(() => setLoading(false));
}, []);
const autoProposal = useMemo(() => proposeLayout(config), [config]);
const isNiche = config.installationType === 'niche';
const follow = wc.layoutFollow;
const autoActive = !!follow && (follow.mode !== 'preset' || follow.fellBack);
const followedPreset = follow?.mode === 'preset' ? presets.find((p) => p.id === follow.presetId) : null;
const hint = !follow
? 'Colonnes personnalisées: choisissez une disposition pour suivre à nouveau les dimensions.'
: follow.mode === 'preset' && follow.fellBack
? `« ${followedPreset?.name || 'Disposition'} » ne convient pas à ces cotes: proposition auto en attendant.`
: follow.mode === 'preset'
? `« ${followedPreset?.name || 'Disposition'} » s\u2019adapte à vos dimensions (aperçu en transparence).`
: isNiche
? 'La proposition auto s\u2019adapte aux cotes de la niche (aperçu en transparence).'
: 'La proposition auto suit vos dimensions (aperçu en transparence).';
const handleSave = async (event) => {
event.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
setSaving(true);
setError('');
try {
const preset = await saveLayoutPreset(trimmed, pickLayoutPreset(config));
setPresets((current) => [...current, preset]);
setName('');
setNaming(false);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const handleDelete = async (preset) => {
if (!window.confirm(`Retirer la disposition « ${preset.name} » ?`)) return;
setError('');
try {
await deleteLayoutPreset(preset.id);
setPresets((current) => current.filter((p) => p.id !== preset.id));
} catch (err) {
setError(errorMessage(err));
}
};
return (
<section className={styles.presets} aria-labelledby="layout-presets-title">
<div className={styles.header}>
<h3 id="layout-presets-title" className={styles.title}>Dispositions types</h3>
<span className={styles.hint}>{hint}</span>
</div>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.strip}>
<div className={`${styles.card} ${autoActive ? styles.cardActive : ''}`}>
<button
type="button"
className={styles.cardMain}
onClick={wc.enableAutoLayout}
title="Disposition calculée selon les cotes; elle suit les changements de dimensions tant que les colonnes ne sont pas modifiées."
>
<LayoutThumbnail layout={autoProposal} drawerHeightsMm={config.hardware?.drawerHeightsMm} />
<span className={styles.cardName}>
Proposition auto <span className={styles.autoBadge}>{autoActive ? 'Active' : 'Auto'}</span>
</span>
<span className={styles.cardMeta}>
Selon {config.widthMm} × {config.heightMm} mm · {autoProposal.modules.length} col.
</span>
</button>
</div>
{loading && <p className={styles.empty}>Chargement…</p>}
{presets.filter((p) => p.config).map((preset) => {
const cols = (preset.config.modules || []).length;
const active = follow?.mode === 'preset' && follow.presetId === preset.id && !follow.fellBack;
return (
<div key={preset.id} className={`${styles.card} ${active ? styles.cardActive : ''}`}>
<button
type="button"
className={styles.cardMain}
onClick={() => wc.applyLayoutPreset(preset.config, preset.id)}
title={preset.description || preset.name}
>
<LayoutThumbnail layout={preset.config} drawerHeightsMm={config.hardware?.drawerHeightsMm} />
<span className={styles.cardName}>{preset.name}</span>
<span className={styles.cardMeta}>
{preset.config.widthMm} × {preset.config.heightMm} mm · {cols} col.
</span>
</button>
<button
type="button"
className={styles.cardDelete}
onClick={() => handleDelete(preset)}
aria-label={`Retirer ${preset.name}`}
title="Retirer cette disposition"
>
<Icon name="x" size={12} />
</button>
</div>
);
})}
{naming ? (
<form className={`${styles.card} ${styles.saveCard}`} onSubmit={handleSave}>
<input
type="text"
maxLength={100}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Nom de la disposition"
className={styles.saveInput}
autoFocus
onKeyDown={(e) => { if (e.key === 'Escape') setNaming(false); }}
/>
<div className={styles.saveActions}>
<button type="submit" className={styles.saveBtn} disabled={saving || !name.trim()}>
{saving ? 'Enregistrement…' : 'Enregistrer'}
</button>
<button type="button" className={styles.cancelBtn} onClick={() => setNaming(false)}>Annuler</button>
</div>
</form>
) : (
<button type="button" className={`${styles.card} ${styles.addCard}`} onClick={() => setNaming(true)}>
<Icon name="save" size={18} />
<span>Enregistrer la disposition actuelle</span>
</button>
)}
</div>
</section>
);
}
@@ -0,0 +1,258 @@
.presets {
margin-top: 0.5rem;
border: 1px solid var(--border);
background: var(--bg-card);
border-radius: 8px;
padding: 0.55rem 0.6rem 0.6rem;
}
.header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.45rem;
}
.title {
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
}
.hint {
font-size: 0.72rem;
color: var(--text-muted);
}
.error {
margin: 0 0 0.4rem;
font-size: 0.75rem;
color: #b91c1c;
}
.empty {
margin: 0;
font-size: 0.75rem;
color: var(--text-muted);
}
.strip {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.2rem;
scroll-snap-type: x proximity;
}
.card {
position: relative;
flex: 0 0 132px;
scroll-snap-align: start;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg);
transition: border-color 0.15s, background 0.15s;
}
.card:hover {
border-color: color-mix(in srgb, var(--action) 50%, var(--border));
}
.cardActive {
border-color: var(--action);
background: color-mix(in srgb, var(--action) 8%, var(--bg));
}
.cardMain {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.2rem;
width: 100%;
padding: 0.45rem 0.45rem 0.4rem;
border: none;
background: none;
cursor: pointer;
text-align: left;
color: inherit;
}
.cardName {
font-size: 0.75rem;
font-weight: 600;
line-height: 1.2;
}
.autoBadge {
display: inline-block;
margin-left: 0.2rem;
padding: 0.05rem 0.3rem;
border-radius: 3px;
background: var(--action);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
vertical-align: middle;
}
.cardMeta {
font-size: 0.68rem;
color: var(--text-muted);
}
.cardDelete {
position: absolute;
top: 4px;
right: 4px;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--bg-card);
color: var(--text-muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.card:hover .cardDelete,
.cardDelete:focus-visible {
opacity: 1;
}
.cardDelete:hover {
color: #b91c1c;
border-color: #fecaca;
}
.thumb {
width: 100%;
height: 78px;
display: block;
}
.thumbBody {
fill: color-mix(in srgb, var(--bg-card) 80%, #c4a574);
stroke: var(--text-muted);
stroke-width: 1.4;
vector-effect: non-scaling-stroke;
}
.thumbPlinth {
fill: var(--text-muted);
opacity: 0.45;
}
.thumbOpen {
fill: var(--bg);
opacity: 0.7;
}
.thumbPanel {
stroke: var(--text-muted);
stroke-width: 1.4;
vector-effect: non-scaling-stroke;
}
.thumbShelf {
stroke: var(--text-muted);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.thumbDrawer {
fill: var(--bg-card);
stroke: var(--text-muted);
stroke-width: 0.9;
vector-effect: non-scaling-stroke;
}
.thumbHandle {
stroke: var(--text-muted);
stroke-width: 1.2;
vector-effect: non-scaling-stroke;
}
.thumbRod {
stroke: var(--action);
stroke-width: 1.6;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.addCard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 128px;
padding: 0.5rem;
border-style: dashed;
color: var(--text-muted);
font-size: 0.72rem;
text-align: center;
cursor: pointer;
}
.addCard:hover {
color: var(--action);
}
.saveCard {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.4rem;
flex-basis: 170px;
padding: 0.5rem;
}
.saveInput {
width: 100%;
padding: 0.3rem 0.4rem;
border: 1px solid var(--border);
border-radius: 5px;
font-size: 0.75rem;
background: var(--bg-card);
color: var(--text);
}
.saveActions {
display: flex;
gap: 0.3rem;
}
.saveBtn,
.cancelBtn {
flex: 1;
padding: 0.3rem 0.4rem;
border-radius: 5px;
font-size: 0.7rem;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border);
}
.saveBtn {
background: var(--action);
border-color: var(--action);
color: #fff;
}
.saveBtn:disabled {
opacity: 0.55;
cursor: default;
}
.cancelBtn {
background: var(--bg-card);
color: var(--text);
}
@@ -1,14 +1,15 @@
import { useCallback, useEffect } from 'react';
import WardrobePreview from '../WardrobePreview';
import DesignTip from '../DesignTip';
import { resolveShelfPositionsMm, minShelvesForStructuralHeight } from '../../constants/wardrobe';
import LayoutPresets from './LayoutPresets';
import { resolveShelfPositionsMm, minShelvesForStructuralHeight, moduleStackInfo, moduleHangingZoneMm, ROD_CLEAR_HEIGHT_MM, STRUCTURAL_WINDOW_HALF_MM } 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.',
1: 'Glissez les poignees L/H/P : elles suivent le curseur. Echap annule, F recadre la vue.',
2: 'Tirez les separateurs (aimantes sur largeurs egales, Maj = au mm, Alt = sans aimant, Echap = annuler) 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.',
@@ -24,6 +25,9 @@ export default function PreviewArea({ wc, ui }) {
const out = [];
for (let i = 0; i < mods.length; i++) {
const mod = mods[i];
// Colonne empilée: la jonction bas/haut joue le rôle de rayon structurel.
const stack = moduleStackInfo(mod, wc.config);
if (stack) continue;
const shelfCount = Math.max(0, Number(mod?.shelves) || 0);
const positions = resolveShelfPositionsMm({
shelfCount,
@@ -31,14 +35,19 @@ export default function PreviewArea({ wc, ui }) {
plinthHeightMm: plinthMm,
shelfPositionsMm: mod?.shelfPositionsMm
});
const hasStructural = positions.some((p) => p >= midMm - 200 && p <= midMm + 200);
const hasStructural = positions.some((p) => p >= midMm - STRUCTURAL_WINDOW_HALF_MM && p <= midMm + STRUCTURAL_WINDOW_HALF_MM);
if (!hasStructural) out.push(`Colonne ${i + 1}: placer un rayon dans la zone structurelle (±20 cm).`);
}
return out;
})();
const hangingWarnings = ui.activeStep !== 2 ? [] : (wc.config.modules || []).flatMap((mod, i) => {
const zone = moduleHangingZoneMm(mod, wc.config);
if (!zone || zone.clearMm >= ROD_CLEAR_HEIGHT_MM.short) return [];
return [`Colonne ${i + 1}: penderie de ${zone.clearMm} mm sous tringle (min. ${ROD_CLEAR_HEIGHT_MM.short} mm vestes, ${ROD_CLEAR_HEIGHT_MM.long} mm manteaux). Empilez la colonne ou retirez un rayon.`];
});
const handleSetDimension = useCallback((key, valueMm) => {
wc.setDimension(key, valueMm);
}, [wc.setDimension]);
wc.setDimensionWithoutUndo(key, valueMm);
}, [wc.setDimensionWithoutUndo]);
useEffect(() => {
const onKeyDown = (e) => {
@@ -115,10 +124,13 @@ export default function PreviewArea({ wc, ui }) {
colors={wc.config.colors}
modules={wc.config.modules}
onElementClick={colorPickEnabled ? (elementType, label) => ui.openColorPicker(elementType, label) : undefined}
onModuleClick={ui.handleModuleClick}
onUpdateModule={wc.handleColumnUpdate}
onModuleClick={ui.activeStep === 2 ? ui.handleModuleClick : undefined}
onUpdateModule={wc.handleColumnUpdateWithoutUndo}
onResizeModules={wc.handleResizeModules}
selectedModuleIndex={ui.expandedColumn}
selectedModuleIndex={ui.activeStep === 2 ? ui.expandedColumn : null}
selectedBlockIndex={ui.expandedBlock}
selectedHorizontalSpanId={ui.activeStep === 2 ? ui.selectedHorizontalSpanId : null}
onHorizontalSpanClick={ui.activeStep === 2 ? ui.handleHorizontalSpanSelect : undefined}
onSetDimension={handleSetDimension}
onBeginUndoableGesture={wc.pushUndoSnapshot}
canUndo3d={wc.canUndo}
@@ -130,10 +142,11 @@ export default function PreviewArea({ wc, ui }) {
</div>
<div className={styles.infoZone}>
<div className={styles.infoLine}>{infoByStep[ui.activeStep] || 'Manipulez la vue pour controler la configuration.'}</div>
{structuralWarnings.map((msg) => (
{[...structuralWarnings, ...hangingWarnings].map((msg) => (
<div key={msg} className={styles.warnLine}>{msg}</div>
))}
</div>
{ui.activeStep === 1 && <LayoutPresets wc={wc} />}
<DesignTip config={wc.config} />
</div>
);
@@ -1,15 +1,47 @@
import { useState, useRef, useCallback } from 'react';
import { useState, useRef, useCallback, useEffect } from 'react';
import {
COLUMN_TYPES,
maxShelfGapMm,
SHELF_GAP_MM,
minShelvesForStructuralHeight,
minShelvesForColumn,
moduleStackInfo,
moduleBlockHeightsMm,
moduleBlockBacks,
moduleBlockDoors,
moduleBottomBlockMinMm,
minBlocksForBody,
maxBlocksForBody,
resolveShelfPositionsMm,
system32ShelfPositionsMm,
SYSTEM32_PITCH_MM
SYSTEM32_PITCH_MM,
STRUCTURAL_WINDOW_HALF_MM,
STACK_BLOCK_MM
} from '../../constants/wardrobe';
import {
getModuleVerticalBoundariesMm,
HORIZONTAL_SPAN_DOOR_MODES,
resolveHorizontalSpan,
synchronizeModuleSpanBoundaries,
validateHorizontalSpans
} from '@shared/manufacturing.js';
import s from './steps.module.css';
const HORIZONTAL_SPAN_DOOR_OPTIONS = [
{ value: HORIZONTAL_SPAN_DOOR_MODES.none, label: 'Sans porte' },
{ value: HORIZONTAL_SPAN_DOOR_MODES.perColumn, label: 'Deux portes, charnières extérieures' },
{ value: HORIZONTAL_SPAN_DOOR_MODES.single, label: 'Une porte pleine largeur' }
];
function horizontalSpanDoorLabel(doorMode) {
return HORIZONTAL_SPAN_DOOR_OPTIONS.find((option) => option.value === doorMode)?.label
|| HORIZONTAL_SPAN_DOOR_OPTIONS[0].label;
}
function horizontalSpanBackLabel(hasBack) {
return hasBack ? 'Avec dos' : 'Sans dos';
}
/** 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;
@@ -26,11 +58,19 @@ export default function StepColumns({ wc, ui }) {
const {
config, numColumns,
handleNumColumnsChange, handleAIOptimize, handleColumnTypeChange,
handleColumnLock, handleColumnUpdate,
handleColumnLock, handleColumnUpdate, setAllBacks, setAllBlockCounts,
aiOptimizing, aiOptimizeMsg, setAiOptimizeMsg, totalModuleWidth,
reorderModules, pushUndoSnapshot,
createHorizontalSpan, updateHorizontalSpan, deleteHorizontalSpan,
addHorizontalSpanUpperBlock, resizeHorizontalSpanUpperBlockHeight,
getHorizontalSpanUpperBlock,
} = wc;
const { expandedColumn, setExpandedColumn } = ui;
const {
expandedColumn, setExpandedColumn, expandedBlock, setExpandedBlock,
selectedHorizontalSpanId, setSelectedHorizontalSpanId,
horizontalSpanDraft, setHorizontalSpanDraft,
horizontalSpanMessage, setHorizontalSpanMessage,
} = ui;
const [draggingIndex, setDraggingIndex] = useState(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
@@ -81,60 +121,210 @@ export default function StepColumns({ wc, ui }) {
suppressCardClickRef.current = false;
return;
}
setSelectedHorizontalSpanId(null);
setHorizontalSpanDraft(null);
setHorizontalSpanMessage('');
setExpandedBlock(0);
setExpandedColumn(isActive ? null : i);
}, [setExpandedColumn]);
}, [
setExpandedBlock,
setExpandedColumn,
setHorizontalSpanDraft,
setHorizontalSpanMessage,
setSelectedHorizontalSpanId,
]);
// When a block is selected (3D click or card), bring its options into view.
const detailRef = useRef(null);
useEffect(() => {
if (expandedColumn === null) return;
detailRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, [expandedColumn]);
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));
const drawerDepthBase = Math.max(
350,
Math.round(Math.max(400, Math.min(650, config.depthMm - 10)) / 50) * 50
);
/** Empilage PAR COLONNE — modèle N blocks: mod.blockHeightsMm / mod.blockBacks. */
const modHeights = (m) => moduleBlockHeightsMm(m, config);
const modBacks = (m) => moduleBlockBacks(m, config);
const backStates = config.modules.map((m) => modBacks(m).every(Boolean));
const noBackStates = config.modules.map((m) => modBacks(m).every((b) => !b));
const allWithBack = backStates.length > 0 && backStates.every(Boolean);
const noneWithBack = noBackStates.every(Boolean);
const mixedBack = !allWithBack && !noneWithBack;
/** Nombre de colonnes non limité: seule la largeur borne (min. 300 mm par colonne). */
const maxColumns = Math.max(1, Math.floor((config.widthMm || 2400) / 300));
/** Empilage vertical: bornes (caisse hors plinthe). Hauteur > panneau max → blocks imposés. */
const bodyHmm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 0);
const stackMinMm = STACK_BLOCK_MM.min;
const minBlocks = minBlocksForBody(bodyHmm);
const maxBlocks = maxBlocksForBody(bodyHmm);
const blockCounts = config.modules.map((m) => modHeights(m).length);
const anyStacked = blockCounts.some((n) => n >= 2);
const allSameCount = blockCounts.length > 0 && blockCounts.every((n) => n === blockCounts[0]);
const mixedStacked = anyStacked && !(allSameCount && blockCounts[0] >= 2);
const horizontalSpans = Array.isArray(config.horizontalSpans) ? config.horizontalSpans : [];
const resolvedSpans = horizontalSpans
.map((span) => resolveHorizontalSpan(span, config))
.filter(Boolean);
const selectedSpan = horizontalSpans.find(
(span) => String(span.id) === String(selectedHorizontalSpanId)
) || null;
const selectedResolvedSpan = selectedSpan ? resolveHorizontalSpan(selectedSpan, config) : null;
const selectedUpperBlock = selectedSpan
? getHorizontalSpanUpperBlock(selectedSpan.id)
: null;
const beginHorizontalSpan = (moduleIndex, blockIndex) => {
if (config.modules.length < 2) {
setHorizontalSpanMessage('Ajoutez au moins deux colonnes pour créer un block horizontal.');
return;
}
setSelectedHorizontalSpanId(null);
setHorizontalSpanMessage('');
setHorizontalSpanDraft({ startModuleIndex: moduleIndex, startBlockIndex: blockIndex, endModuleIndex: null });
};
const cancelHorizontalSpan = () => {
setHorizontalSpanDraft(null);
setHorizontalSpanMessage('');
};
const confirmHorizontalSpan = () => {
const startIndex = Number(horizontalSpanDraft?.startModuleIndex);
const blockIndex = Number(horizontalSpanDraft?.startBlockIndex);
const endIndex = Number(horizontalSpanDraft?.endModuleIndex);
const startModule = config.modules[startIndex];
if (!startModule || !Number.isInteger(endIndex) || endIndex === startIndex || !config.modules[endIndex]) {
setHorizontalSpanMessage('Choisissez une colonne de fin adjacente (au moins deux colonnes au total).');
return;
}
const boundaries = getModuleVerticalBoundariesMm(startModule, config);
const bottomMm = boundaries[blockIndex];
const topMm = boundaries[blockIndex + 1];
if (!Number.isFinite(bottomMm) || !Number.isFinite(topMm)) {
setHorizontalSpanMessage('Le block de départ sélectionné n’existe plus.');
return;
}
const firstIndex = Math.min(startIndex, endIndex);
const lastIndex = Math.max(startIndex, endIndex);
const includedModules = config.modules.slice(firstIndex, lastIndex + 1);
const moduleIds = includedModules.map((module) => module.id);
if (bottomMm === 0) {
const drawerIndex = includedModules.findIndex((module) => (Number(module.drawers) || 0) > 0);
if (drawerIndex >= 0) {
setHorizontalSpanMessage(
`Impossible : retirez d’abord les tiroirs de la colonne ${firstIndex + drawerIndex + 1}, car ils occupent le block bas.`
);
return;
}
}
// Preview the synchronized model, then turn off every door band covered
// by the horizontal opening before validating the candidate.
const synchronized = synchronizeModuleSpanBoundaries(config, moduleIds, bottomMm, topMm);
const moduleIdSet = new Set(moduleIds.map(String));
const modulesWithOpenBands = synchronized.modules.map((module) => {
if (!moduleIdSet.has(String(module.id))) return module;
const edges = getModuleVerticalBoundariesMm(module, synchronized);
const doors = moduleBlockDoors(module, synchronized, edges.length - 1);
return {
...module,
blockDoors: doors.map((hasDoor, index) => (
edges[index] >= bottomMm - 0.01 && edges[index + 1] <= topMm + 0.01 ? false : hasDoor
))
};
});
const draftSpan = {
id: '__horizontal-span-preview__',
moduleIds,
bottomMm,
topMm,
hasBack: Boolean(moduleBlockBacks(startModule, config)[blockIndex]),
doorMode: HORIZONTAL_SPAN_DOOR_MODES.none,
type: 'open'
};
const candidate = {
...synchronized,
modules: modulesWithOpenBands,
horizontalSpans: [...horizontalSpans, draftSpan]
};
const issue = validateHorizontalSpans(candidate).find((item) => item.spanId === draftSpan.id);
if (issue) {
setHorizontalSpanMessage(issue.message);
return;
}
const spanId = createHorizontalSpan({ ...draftSpan, id: undefined });
setSelectedHorizontalSpanId(spanId);
setHorizontalSpanDraft(null);
setHorizontalSpanMessage('Block horizontal créé sans porte. Configurez ses portes dans les paramètres du block horizontal.');
};
const selectHorizontalSpan = (spanId) => {
ui.handleHorizontalSpanSelect?.(spanId);
};
const removeHorizontalSpan = (spanId) => {
if (!deleteHorizontalSpan(spanId)) return;
setSelectedHorizontalSpanId(null);
setHorizontalSpanMessage('Block horizontal supprimé. Les découpes de colonnes sont conservées.');
};
const addUpperBlock = () => {
const result = addHorizontalSpanUpperBlock(selectedSpan?.id);
if (!result?.ok) {
setHorizontalSpanMessage(result?.message || 'Impossible d’ajouter le block supérieur.');
return;
}
setHorizontalSpanMessage('Block supérieur de 300 mm ajouté et synchronisé sur toutes les colonnes couvertes.');
};
const resizeUpperBlock = (heightMm) => {
const result = resizeHorizontalSpanUpperBlockHeight(selectedSpan?.id, heightMm);
if (!result?.ok) {
setHorizontalSpanMessage(result?.message || 'Impossible de redimensionner le block supérieur.');
return;
}
setHorizontalSpanMessage('');
};
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.
Cliquez sur un block dans la vue 3D (fa&ccedil;ade, int&eacute;rieur ou dos) pour ouvrir ses options ici.
{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>
<span className={s.stepDescExtra}> Glissez-d&eacute;posez les cartes ci-dessous pour changer l&apos;ordre des colonnes (gauche &rarr; droite = identique &agrave; 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 */}
{/* Column count — non limité, borné par la largeur (min 300 mm par colonne) */}
<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 className={s.counter}>
<button
type="button"
className={s.counterBtn}
onClick={() => handleNumColumnsChange(Math.max(1, numColumns - 1))}
disabled={numColumns <= 1}
>&minus;</button>
<span className={s.counterValue}>{numColumns}</span>
<button
type="button"
className={s.counterBtn}
onClick={() => handleNumColumnsChange(Math.min(maxColumns, numColumns + 1))}
disabled={numColumns >= maxColumns}
>+</button>
</div>
<button
type="button"
@@ -145,6 +335,9 @@ export default function StepColumns({ wc, ui }) {
{aiOptimizing ? '...' : '\u2728 Optimiser'}
</button>
</div>
{numColumns >= maxColumns && (
<p className={s.hint}>Maximum {maxColumns} colonnes pour {config.widthMm} mm de largeur (min. 300 mm par colonne).</p>
)}
{aiOptimizeMsg && (
<div className={s.aiMsg}>
@@ -187,27 +380,288 @@ export default function StepColumns({ wc, ui }) {
<span className={s.colCardIcon}>{typeDef.icon}</span>
<span className={s.colCardType}>{typeDef.label}</span>
<span className={s.colCardWidth}>{mod.widthMm} mm</span>
<span className={s.colCardWidth}>
{(() => {
const backs = modBacks(mod);
const n = backs.length;
const withBack = backs.filter(Boolean).length;
const dosTxt = withBack === n ? 'avec dos' : withBack === 0 ? 'sans dos' : `dos ${withBack}/${n}`;
return n >= 2 ? `${n} blocks \u00b7 ${dosTxt}` : dosTxt;
})()}
</span>
{resolvedSpans
.filter((resolved) => resolved.moduleIndexes.includes(i))
.map((resolved) => (
<span
key={resolved.span.id}
className={`${s.spanMembership} ${String(selectedHorizontalSpanId) === String(resolved.span.id) ? s.spanMembershipActive : ''}`}
role="button"
tabIndex={0}
onClick={(event) => {
event.stopPropagation();
selectHorizontalSpan(resolved.span.id);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
selectHorizontalSpan(resolved.span.id);
}
}}
>
Horizontal {resolved.bottomMm}–{resolved.topMm} · {horizontalSpanBackLabel(resolved.span.hasBack)} · {horizontalSpanDoorLabel(resolved.span.doorMode)}
</span>
))}
</button>
);
})}
</div>
{horizontalSpans.length > 0 && (
<div className={s.spanPanel}>
<div className={s.spanPanelTitle}>Blocks horizontaux</div>
<div className={s.spanList}>
{resolvedSpans.map((resolved, index) => (
<button
key={resolved.span.id}
type="button"
className={`${s.spanListItem} ${String(selectedHorizontalSpanId) === String(resolved.span.id) ? s.spanListItemActive : ''}`}
onClick={() => selectHorizontalSpan(resolved.span.id)}
>
Horizontal {index + 1} · colonnes {resolved.moduleIndexes[0] + 1} à {resolved.moduleIndexes[resolved.moduleIndexes.length - 1] + 1}
<small>
{resolved.bottomMm}–{resolved.topMm} mm · {horizontalSpanBackLabel(resolved.span.hasBack)} · {horizontalSpanDoorLabel(resolved.span.doorMode)}
</small>
</button>
))}
</div>
{selectedSpan && selectedResolvedSpan && (
<div className={s.spanEditor}>
<span>
Colonnes {selectedResolvedSpan.moduleIndexes[0] + 1} à {selectedResolvedSpan.moduleIndexes[selectedResolvedSpan.moduleIndexes.length - 1] + 1}
</span>
<fieldset className={s.spanDoorChoices}>
<legend>Dos du block horizontal</legend>
{[false, true].map((hasBack) => (
<button
key={String(hasBack)}
type="button"
className={`${s.spanDoorChoice} ${selectedSpan.hasBack === hasBack ? s.spanDoorChoiceActive : ''}`}
aria-pressed={selectedSpan.hasBack === hasBack}
onClick={() => {
if (selectedSpan.hasBack !== hasBack) {
updateHorizontalSpan(selectedSpan.id, { hasBack });
}
}}
>
{horizontalSpanBackLabel(hasBack)}
</button>
))}
</fieldset>
<fieldset className={s.spanDoorChoices}>
<legend>Portes du block horizontal</legend>
{(() => {
const activeDoorMode = selectedSpan.doorMode ?? HORIZONTAL_SPAN_DOOR_MODES.none;
const hasDoor = activeDoorMode !== HORIZONTAL_SPAN_DOOR_MODES.none;
return (
<>
<button
type="button"
className={`${s.spanDoorChoice} ${!hasDoor ? s.spanDoorChoiceActive : ''}`}
aria-pressed={!hasDoor}
onClick={() => {
if (hasDoor) {
updateHorizontalSpan(selectedSpan.id, { doorMode: HORIZONTAL_SPAN_DOOR_MODES.none });
}
}}
>
Sans porte
</button>
<button
type="button"
className={`${s.spanDoorChoice} ${hasDoor ? s.spanDoorChoiceActive : ''}`}
aria-pressed={hasDoor}
onClick={() => {
if (!hasDoor) {
updateHorizontalSpan(selectedSpan.id, { doorMode: HORIZONTAL_SPAN_DOOR_MODES.perColumn });
}
}}
>
Avec porte
</button>
{hasDoor && (
<>
<small>Configuration des portes</small>
{HORIZONTAL_SPAN_DOOR_OPTIONS.slice(1).map((option) => (
<button
key={option.value}
type="button"
className={`${s.spanDoorChoice} ${activeDoorMode === option.value ? s.spanDoorChoiceActive : ''}`}
aria-pressed={activeDoorMode === option.value}
onClick={() => {
if (activeDoorMode !== option.value) {
updateHorizontalSpan(selectedSpan.id, { doorMode: option.value });
}
}}
>
{option.label}
</button>
))}
</>
)}
<small>
Ces portes appartiennent au block horizontal. Les portes individuelles des blocks couverts restent désactivées.
</small>
</>
);
})()}
</fieldset>
{selectedUpperBlock?.ok && selectedUpperBlock.exists ? (
<div className={s.detailRow}>
<span className={s.detailLabel}>Block supérieur synchronisé</span>
<div className={s.dimSliderRow} style={{ flex: 1 }}>
<input
type="range"
className={s.gapRange}
min={selectedUpperBlock.minHeightMm}
max={selectedUpperBlock.maxHeightMm}
step={selectedUpperBlock.stepMm}
value={selectedUpperBlock.heightMm}
onChange={(event) => resizeUpperBlock(Number(event.target.value))}
/>
<span className={s.counterValue}>{selectedUpperBlock.heightMm} mm</span>
</div>
</div>
) : (
<>
<button
type="button"
className={s.optimizeBtn}
onClick={addUpperBlock}
disabled={!selectedUpperBlock?.ok || !selectedUpperBlock.canAdd}
title={!selectedUpperBlock?.canAdd
? 'Le block horizontal doit mesurer au moins 600 mm.'
: undefined}
>
Ajouter un block au-dessus (300 mm)
</button>
{!selectedUpperBlock?.ok && (
<small className={s.warnText}>{selectedUpperBlock?.message}</small>
)}
{selectedUpperBlock?.ok && !selectedUpperBlock.canAdd && (
<small className={s.hint}>
Hauteur insuffisante : conservez au moins 300 mm pour le block horizontal et 300 mm pour le nouveau block.
</small>
)}
</>
)}
<small className={s.hint}>
Cette limite est commune à toutes les colonnes couvertes. Sa hauteur se règle ici par pas de 50 mm.
</small>
<button
type="button"
className={s.spanDeleteBtn}
onClick={() => removeHorizontalSpan(selectedSpan.id)}
>
Supprimer
</button>
</div>
)}
</div>
)}
{horizontalSpanMessage && (
<div className={horizontalSpanMessage.startsWith('Block ') ? s.spanSuccess : s.warnText}>
{horizontalSpanMessage}
<button type="button" className={s.aiDismiss} onClick={() => setHorizontalSpanMessage('')}>&times;</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 colHeights = modHeights(mod);
const colBacks = modBacks(mod);
const colDoors = moduleBlockDoors(mod, config, colHeights.length);
const colCount = colHeights.length;
const drawerType = ['hidden', 'hidden_with_handle'].includes(mod.drawerType)
? 'hidden'
: mod.drawerType === 'visible'
? 'visible'
: ['hidden', 'hidden_with_handle'].includes(hw.drawerType)
? 'hidden'
: 'visible';
const drawerDepth = Math.max(350, drawerDepthBase - (drawerType === 'hidden' ? 40 : 0));
const bottomBlockMinMm = moduleBottomBlockMinMm(mod, config);
const colMaxBlocks = Math.max(
minBlocks,
Math.min(maxBlocks, 1 + Math.floor((bodyHmm - bottomBlockMinMm) / stackMinMm))
);
const modStackedHere = colCount >= 2;
const activeBlockIndex = Math.min(Math.max(0, expandedBlock || 0), colCount - 1);
const columnResolvedSpans = resolvedSpans.filter((resolved) =>
resolved.moduleIndexes.includes(i)
);
const activeResolvedSpan = columnResolvedSpans.find((resolved) =>
resolved.coveredBlocks.some(
(covered) =>
covered.moduleIndex === i &&
covered.blockIndexes.includes(activeBlockIndex)
)
);
const columnHasBottomSpan = columnResolvedSpans.some(
(resolved) => resolved.bottomMm <= 0.5
);
const modStackInfo = moduleStackInfo(mod, config);
const blockLabel = (k) =>
colCount <= 1
? ''
: k === 0
? `Block 1 (bas)`
: k === colCount - 1
? `Block ${colCount} (haut)`
: `Block ${k + 1}`;
const setBlockCount = (n) => {
const count = Math.min(colMaxBlocks, Math.max(minBlocks, n));
const even = bodyHmm / count;
setExpandedBlock((current) => Math.min(current, count - 1));
handleColumnUpdate(i, {
blockHeightsMm: count <= 1 ? [bodyHmm] : Array.from({ length: count }, () => even)
});
};
const setBlockHeight = (k, valueMm) => {
const next = colHeights.slice();
next[k] = Number(valueMm);
handleColumnUpdate(i, { blockHeightsMm: next });
};
const setBlockBack = (k, value) => {
const next = colBacks.slice();
next[k] = value;
handleColumnUpdate(i, {
blockBacks: next,
hasBack: next[0],
hasBackTop: next[next.length - 1]
});
};
const setBlockDoor = (k, value) => {
const next = colDoors.slice();
next[k] = value;
handleColumnUpdate(i, { blockDoors: next });
};
const shelfN = mod.shelves || 0;
const shelfPositions = resolveShelfPositionsMm({
shelfCount: shelfN,
heightMm: config.heightMm,
plinthHeightMm: config.plinthHeightMm ?? 100,
shelfPositionsMm: mod.shelfPositionsMm
shelfPositionsMm: mod.shelfPositionsMm,
stack: modStackInfo
});
const minShelves = minShelvesStructural;
const bodyHmm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 0);
// Colonne empilée: la jonction est structurelle → pas de rayon minimum imposé.
const minShelves = minShelvesForColumn(config.heightMm, mod, config);
const bodyMidMm = bodyHmm / 2;
const shelfSlots = system32ShelfPositionsMm(config.heightMm, config.plinthHeightMm ?? 100);
const shelfSlots = system32ShelfPositionsMm(config.heightMm, config.plinthHeightMm ?? 100, modStackInfo);
const structuralIdx =
minShelves > 0 && shelfPositions.length > 0
? shelfPositions.reduce((bestIdx, posMm, idx, arr) => {
@@ -218,7 +672,7 @@ export default function StepColumns({ wc, ui }) {
: null;
const hasShelfInStructuralWindow =
minShelves <= 0 ||
shelfPositions.some((p) => p >= bodyMidMm - 200 && p <= bodyMidMm + 200);
shelfPositions.some((p) => p >= bodyMidMm - STRUCTURAL_WINDOW_HALF_MM && p <= bodyMidMm + STRUCTURAL_WINDOW_HALF_MM);
const gapMax = Math.min(
SHELF_GAP_MM.max,
maxShelfGapMm(config.heightMm, config.plinthHeightMm ?? 100, shelfN)
@@ -232,16 +686,29 @@ export default function StepColumns({ wc, ui }) {
handleColumnUpdate(i, { shelfPositionsMm: next });
};
return (
<div className={s.colDetail}>
<div className={s.colDetail} ref={detailRef}>
<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>
<span className={s.colDetailTitle}>
Colonne {i + 1} &mdash; {typeDef.icon} {typeDef.label}
{modStackedHere && ` · ${blockLabel(activeBlockIndex)}`}
</span>
<div className={s.colDetailHeaderBtns}>
<button
type="button"
className={`${s.lockBtn} ${mod.locked ? s.isLocked : ''}`}
onClick={() => handleColumnLock(i)}
>
{mod.locked ? '\u{1F512} Verrouill\u00e9e' : '\u{1F513} Verrouiller'}
</button>
<button
type="button"
className={s.lockBtn}
onClick={() => setExpandedColumn(null)}
title="Fermer les options de ce block"
>
&times;
</button>
</div>
</div>
{/* Type tiles */}
@@ -277,6 +744,11 @@ export default function StepColumns({ wc, ui }) {
>+</button>
</div>
</div>
{columnResolvedSpans.length > 0 && (
<p className={s.hint} style={{ marginTop: 0 }}>
La colonne reste modifiable. Les limites couvertes communes se règlent dans l’éditeur du block horizontal.
</p>
)}
{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.
@@ -380,7 +852,8 @@ export default function StepColumns({ wc, ui }) {
type="button"
className={s.counterBtn}
onClick={() => handleColumnUpdate(i, { drawers: Math.min(6, (mod.drawers || 0) + 1) })}
disabled={(mod.drawers || 0) >= 6}
disabled={(mod.drawers || 0) >= 6 || columnHasBottomSpan}
title={columnHasBottomSpan ? 'Un block horizontal occupe le block bas de cette colonne.' : undefined}
>+</button>
</div>
</div>
@@ -396,8 +869,8 @@ export default function StepColumns({ wc, ui }) {
<button
key={item.key}
type="button"
className={`${s.colCountBtn} ${hw.drawerType === item.key ? s.colCountActive : ''}`}
onClick={() => wc.setHardware('drawerType', item.key)}
className={`${s.colCountBtn} ${drawerType === item.key ? s.colCountActive : ''}`}
onClick={() => handleColumnUpdate(i, { drawerType: item.key })}
>
{item.label}
</button>
@@ -405,7 +878,7 @@ export default function StepColumns({ wc, ui }) {
</div>
</div>
<p className={s.hint} style={{ marginTop: 0 }}>
Manufacture depuis le bas. Profondeur utile: {drawerDepth} mm (réglage global).
Manufacture depuis le bas. Profondeur utile: {drawerDepth} mm pour ce block.
</p>
</>
)}
@@ -421,14 +894,287 @@ export default function StepColumns({ wc, ui }) {
{mod.hasRod ? 'Oui' : 'Non'}
</button>
</div>
{/* Empilage vertical (par colonne): nombre de blocks en hauteur */}
<div className={s.detailRow}>
<span className={s.detailLabel}>Blocks en hauteur</span>
<div className={s.counter}>
<button
type="button"
className={s.counterBtn}
onClick={() => setBlockCount(colCount - 1)}
disabled={colCount <= minBlocks || columnResolvedSpans.length > 0}
title={minBlocks >= 2 && colCount <= minBlocks ? `Hauteur > ${STACK_BLOCK_MM.max} mm : min. ${minBlocks} blocks` : undefined}
>&minus;</button>
<span className={s.counterValue}>{colCount}</span>
<button
type="button"
className={s.counterBtn}
onClick={() => setBlockCount(colCount + 1)}
disabled={colCount >= colMaxBlocks || columnResolvedSpans.length > 0}
title={colCount >= colMaxBlocks ? `Max. ${colMaxBlocks} blocks (hauteur des tiroirs incluse)` : undefined}
>+</button>
</div>
</div>
{modStackedHere && (
<div className={s.blockPicker} role="tablist" aria-label="Block à configurer">
{colHeights.map((h, k) => (
<button
key={`block-tab-${k}`}
type="button"
role="tab"
aria-selected={activeBlockIndex === k}
className={`${s.blockPickerBtn} ${activeBlockIndex === k ? s.blockPickerBtnActive : ''}`}
onClick={() => setExpandedBlock(k)}
>
<span>{blockLabel(k)}</span>
<small>{h} mm</small>
</button>
))}
</div>
)}
<div className={s.horizontalSpanCreator}>
{horizontalSpanDraft?.startModuleIndex === i &&
horizontalSpanDraft?.startBlockIndex === activeBlockIndex ? (
<>
<div className={s.blockSettingsTitle}>Créer un block horizontal</div>
<p className={s.hint}>
Block de départ : colonne {i + 1}, {Math.round(getModuleVerticalBoundariesMm(mod, config)[activeBlockIndex])}–
{Math.round(getModuleVerticalBoundariesMm(mod, config)[activeBlockIndex + 1])} mm.
Choisissez la colonne de fin ; toutes les colonnes intermédiaires seront incluses.
</p>
<div className={s.spanEndChoices}>
{config.modules.map((candidate, candidateIndex) => candidateIndex === i ? null : (
<button
key={candidate.id}
type="button"
className={`${s.colCountBtn} ${horizontalSpanDraft.endModuleIndex === candidateIndex ? s.colCountActive : ''}`}
onClick={() => setHorizontalSpanDraft((draft) => ({ ...draft, endModuleIndex: candidateIndex }))}
>
Colonne {candidateIndex + 1}
</button>
))}
</div>
<div className={s.spanCreatorActions}>
<button type="button" className={s.optimizeBtn} onClick={confirmHorizontalSpan}>
Créer le block horizontal
</button>
<button type="button" className={s.lockBtn} onClick={cancelHorizontalSpan}>
Annuler
</button>
</div>
</>
) : (
<button
type="button"
className={s.horizontalSpanStartBtn}
onClick={() => beginHorizontalSpan(i, activeBlockIndex)}
>
Créer un block horizontal depuis ce block
</button>
)}
</div>
{modStackedHere && (
<>
<div className={s.blockSettingsTitle}>Param&egrave;tres de {blockLabel(activeBlockIndex).toLowerCase()}</div>
{activeBlockIndex < colCount - 1 ? (
<div className={s.detailRow}>
<span className={s.detailLabel}>Hauteur</span>
<div className={s.dimSliderRow} style={{ flex: 1 }}>
<input
type="range"
className={s.gapRange}
min={activeBlockIndex === 0 ? bottomBlockMinMm : stackMinMm}
max={Math.min(STACK_BLOCK_MM.max, bodyHmm - stackMinMm * (colCount - 1))}
step={STACK_BLOCK_MM.step}
value={colHeights[activeBlockIndex]}
onChange={(e) => setBlockHeight(activeBlockIndex, Number(e.target.value))}
disabled={columnResolvedSpans.length > 0}
/>
<span className={s.counterValue}>{colHeights[activeBlockIndex]} mm</span>
</div>
</div>
) : (
<div className={s.detailRow}>
<span className={s.detailLabel}>Hauteur</span>
<span className={s.counterValue}>{colHeights[activeBlockIndex]} mm (reste)</span>
</div>
)}
<p className={s.hint} style={{ marginTop: 0 }}>
{colCount} blocks empil&eacute;s ({colHeights.join(' + ')} mm, caisse hors plinthe).
Les jonctions (doubles panneaux) jouent le r&ocirc;le de rayon structurel pour cette colonne.
{(mod.drawers || 0) > 0 && ` Le block bas reste à ${bottomBlockMinMm} mm minimum pour contenir les tiroirs.`}
</p>
</>
)}
{/* Dos par block */}
{modStackedHere ? (
<>
<div className={s.detailRow}>
<span className={s.detailLabel}>Dos</span>
<button
type="button"
className={`${s.toggleBtn} ${colBacks[activeBlockIndex] ? s.toggleOn : ''}`}
onClick={() => setBlockBack(activeBlockIndex, !colBacks[activeBlockIndex])}
disabled={Boolean(activeResolvedSpan)}
title={activeResolvedSpan ? 'Modifiez le dos depuis les paramètres du block horizontal.' : undefined}
>
{colBacks[activeBlockIndex] ? 'Avec dos' : 'Sans dos'}
</button>
</div>
<p className={s.hint} style={{ marginTop: 0 }}>
Block avec dos = caisson block (dos 8 mm rainur&eacute;). Sans dos: block si côté ou rayonnage, sinon séparations. Block haut sans dos &rarr; dessus de doublage obligatoire.
</p>
</>
) : (
<>
<div className={s.detailRow}>
<span className={s.detailLabel}>Dos (panneau arri&egrave;re)</span>
<button
type="button"
className={`${s.toggleBtn} ${colBacks[0] ? s.toggleOn : ''}`}
onClick={() => setBlockBack(0, !colBacks[0])}
disabled={Boolean(activeResolvedSpan)}
title={activeResolvedSpan ? 'Modifiez le dos depuis les paramètres du block horizontal.' : undefined}
>
{colBacks[0] ? 'Avec dos' : 'Sans dos'}
</button>
</div>
<p className={s.hint} style={{ marginTop: 0 }}>
{colBacks[0]
? 'Avec dos: cette zone est un caisson block (dos 8 mm rainuré, montants propres).'
: 'Sans dos: caisson block si cette colonne est un côté ou un rayonnage, sinon séparations (pas de rainure dos).'}
</p>
</>
)}
{/* Porte par block (bloc ou partie de bloc sans porte) */}
{config.doorType !== 'sliding' && (
modStackedHere ? (
<>
<div className={s.detailRow}>
<span className={s.detailLabel}>Porte</span>
<button
type="button"
className={`${s.toggleBtn} ${colDoors[activeBlockIndex] ? s.toggleOn : ''}`}
onClick={() => setBlockDoor(activeBlockIndex, !colDoors[activeBlockIndex])}
disabled={Boolean(activeResolvedSpan)}
title={activeResolvedSpan ? 'Configurez la porte depuis les paramètres du block horizontal.' : undefined}
>
{colDoors[activeBlockIndex] ? 'Avec porte' : 'Sans porte'}
</button>
</div>
{!colDoors[activeBlockIndex] && (
<p className={s.hint} style={{ marginTop: 0 }}>
Block sans porte = niche ouverte (pas de charni&egrave;res ni poign&eacute;e sur ce block).
</p>
)}
</>
) : (
<div className={s.detailRow}>
<span className={s.detailLabel}>Porte</span>
<button
type="button"
className={`${s.toggleBtn} ${colDoors[0] ? s.toggleOn : ''}`}
onClick={() => setBlockDoor(0, !colDoors[0])}
disabled={Boolean(activeResolvedSpan)}
title={activeResolvedSpan ? 'Configurez la porte depuis les paramètres du block horizontal.' : undefined}
>
{colDoors[0] ? 'Avec porte' : 'Sans porte (niche ouverte)'}
</button>
</div>
)
)}
</div>
);
})()}
{expandedColumn === null && (
<div className={s.detailPlaceholder}>
S&eacute;lectionnez un block (vue 3D ou cartes ci-dessus) pour afficher ses options : type, &eacute;tag&egrave;res, tiroirs, tringle, dos&hellip;
</div>
)}
{/* Global settings, collapsed by default to keep block options in view */}
<details className={s.globalSettings}>
<summary className={s.globalSettingsSummary}>R&eacute;glages g&eacute;n&eacute;raux (dos, empilage)</summary>
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Dos (panneau arrière) &mdash; par zone</label>
<div className={s.colCountBtns}>
{[
{ hasBack: true, label: 'Avec dos (toutes)' },
{ hasBack: false, label: 'Sans dos (toutes)' }
].map((m) => (
<button
key={m.label}
type="button"
className={`${s.colCountBtn} ${(m.hasBack ? allWithBack : noneWithBack) ? s.colCountActive : ''}`}
onClick={() => setAllBacks(m.hasBack)}
>
{m.label}
</button>
))}
</div>
{mixedBack ? (
<p className={s.hint}>Configuration mixte: dos ou rayonnage = caisson block (dos 8 mm rainuré si présent, vis de jonction), le reste en séparations — les côtés restent des montants block. Réglez le dos de chaque zone dans les options de son block.</p>
) : allWithBack ? (
<p className={s.hint}>Avec dos (8 mm rainuré) &rarr; assemblage block (x caisses): vis de jonction + perçages 3 milieux et +100/-100 mm (30 mm face/dos). Modifiable par zone dans les options du block.</p>
) : (
<p className={s.hint}>Sans dos &rarr; block seulement pour les côtés et le rayonnage, sinon séparations (pas de rainure dos). Modifiable par zone dans les options du block.</p>
)}
</div>
{/* Empilage vertical: blocks en hauteur */}
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Blocks en hauteur (empilage vertical) &mdash; par colonne</label>
<div className={s.detailRow}>
<span className={s.detailLabel}>Nombre de blocks (toutes)</span>
<div className={s.counter}>
<button
type="button"
className={s.counterBtn}
onClick={() => setAllBlockCounts(Math.max(minBlocks, (allSameCount ? blockCounts[0] : Math.max(...blockCounts, 1)) - 1))}
disabled={!allSameCount || blockCounts[0] <= minBlocks || horizontalSpans.length > 0}
title={horizontalSpans.length > 0 ? 'Réglez les limites couvertes depuis le block horizontal.' : undefined}
>&minus;</button>
<span className={s.counterValue}>{allSameCount ? blockCounts[0] : 'mixte'}</span>
<button
type="button"
className={s.counterBtn}
onClick={() => setAllBlockCounts(Math.min(maxBlocks, (allSameCount ? blockCounts[0] : Math.max(...blockCounts, 1)) + 1))}
disabled={(allSameCount ? blockCounts[0] : Math.max(...blockCounts, 1)) >= maxBlocks || horizontalSpans.length > 0}
title={horizontalSpans.length > 0 ? 'Réglez les limites couvertes depuis le block horizontal.' : undefined}
>+</button>
</div>
</div>
<p className={s.hint}>
{minBlocks >= 2
? `Hauteur > ${STACK_BLOCK_MM.max} mm (panneau max) : au moins ${minBlocks} blocks empilés par colonne. `
: ''}
{mixedStacked
? 'Configuration mixte : réglez le nombre de blocks et leurs hauteurs par colonne dans ses options. '
: ''}
{anyStacked
? 'Habillage latéral obligatoire seulement si la colonne d’extrémité est empilée. Chaque block a son option dos (options du block).'
: `Min. ${stackMinMm} mm par block, max. ${maxBlocks} blocks pour cette hauteur.`}
{horizontalSpans.length > 0
? ' Les limites couvertes restent synchronisées et se règlent depuis l’éditeur du block horizontal.'
: ''}
</p>
</div>
</details>
<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>}
{hasTallCabinet && config.modules.some((m) => modHeights(m).length < 2) && (
<p className={s.warnText}>Armoire &gt; 2m: rayon au milieu obligatoire avec assemblage RAZENTZAB (colonnes non empil&eacute;es).</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>
)}
@@ -1,10 +1,16 @@
import s from './steps.module.css';
import {
AXILO_TARGET_HEIGHTS_MM,
DIMENSION_RANGES,
outerColumnStacked,
moduleBlockBacks
} from '../../constants/wardrobe.js';
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' },
{ key: 'widthMm', label: 'Largeur totale', ...DIMENSION_RANGES.widthMm, unit: 'mm' },
{ key: 'heightMm', label: 'Hauteur', ...DIMENSION_RANGES.heightMm, unit: 'mm' },
{ key: 'depthMm', label: 'Profondeur', ...DIMENSION_RANGES.depthMm, unit: 'mm' },
{ key: 'plinthHeightMm', label: 'Plinthe', ...DIMENSION_RANGES.plinthHeightMm, unit: 'mm' },
];
export default function StepDimensions({ wc }) {
@@ -15,13 +21,26 @@ export default function StepDimensions({ wc }) {
const sideRightEnabled = config.sideFinishRightEnabled ?? sideDefaultsEnabled;
const topFinishEnabled = config.topFinishEnabled !== false;
// Habillages obligatoires:
// - colonne d'extrémité empilée → habillage de ce côté seulement
// - une zone du dessus (niveau haut si colonne empilée, sinon zone unique) sans dos → dessus de doublage
const moduleCount = (config.modules || []).length;
const sideLeftDoublageMandatory = outerColumnStacked(config.modules, config, 'left');
const sideRightDoublageMandatory = outerColumnStacked(config.modules, config, 'right');
const topDoublageMandatory =
moduleCount > 0 &&
(config.modules || []).some((m) => {
const backs = moduleBlockBacks(m, config);
return !backs[backs.length - 1];
});
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);
const usableWidth = Math.max(DIMENSION_RANGES.widthMm.min, minNicheWidth - gap * 2);
const usableDepth = Math.max(DIMENSION_RANGES.depthMm.min, minNicheDepth - gap * 2);
const usableHeight = Math.max(DIMENSION_RANGES.heightMm.min, minNicheHeight - gap);
return (
<div className={s.stepSection}>
@@ -84,7 +103,23 @@ export default function StepDimensions({ wc }) {
{/* Dimension sliders */}
<div className={s.dimGrid}>
{DIMS.map(({ key, label, min, max, step, unit }) => (
{DIMS.map(({ key, label, min, max, step, unit }) => key === 'plinthHeightMm' ? (
<div key={key} className={`${s.dimField} ${s.dimFieldFull}`}>
<span className={s.dimLabel}>Hauteur cible pieds AXILO 78</span>
<div className={s.dimSliderRow}>
<select
className={s.dimValue}
value={config[key]}
onChange={(e) => set(key, Number(e.target.value))}
>
{AXILO_TARGET_HEIGHTS_MM.map((heightMm) => (
<option key={heightMm} value={heightMm}>{heightMm} mm</option>
))}
</select>
<span className={s.dimUnit}>Plinthe débitée à {Math.max(0, Number(config[key]) - 5)} mm</span>
</div>
</div>
) : (
<div key={key} className={s.dimField}>
<span className={s.dimLabel}>{label}</span>
<div className={s.dimSliderRow}>
@@ -118,29 +153,47 @@ export default function StepDimensions({ wc }) {
<div className={s.finishSelector}>
<button
type="button"
className={`${s.finishOption} ${sideLeftEnabled ? s.finishOptionActive : ''}`}
className={`${s.finishOption} ${sideLeftDoublageMandatory ? s.finishOptionMandatory : sideLeftEnabled ? s.finishOptionActive : ''}`}
disabled={sideLeftDoublageMandatory}
title={sideLeftDoublageMandatory ? 'Obligatoire: colonne gauche empilée (joints visibles)' : undefined}
onClick={() => set('sideFinishLeftEnabled', !sideLeftEnabled)}
>
<span className={s.finishOptionLabel}>C&ocirc;t&eacute; gauche</span>
<span className={s.finishOptionState}>{sideLeftEnabled ? 'Actif' : 'Retir&eacute;'}</span>
<span className={s.finishOptionState}>{sideLeftEnabled ? (sideLeftDoublageMandatory ? 'Obligatoire' : 'Actif') : 'Retir&eacute;'}</span>
</button>
<button
type="button"
className={`${s.finishOption} ${sideRightEnabled ? s.finishOptionActive : ''}`}
className={`${s.finishOption} ${sideRightDoublageMandatory ? s.finishOptionMandatory : sideRightEnabled ? s.finishOptionActive : ''}`}
disabled={sideRightDoublageMandatory}
title={sideRightDoublageMandatory ? 'Obligatoire: colonne droite empilée (joints visibles)' : undefined}
onClick={() => set('sideFinishRightEnabled', !sideRightEnabled)}
>
<span className={s.finishOptionLabel}>C&ocirc;t&eacute; droit</span>
<span className={s.finishOptionState}>{sideRightEnabled ? 'Actif' : 'Retir&eacute;'}</span>
<span className={s.finishOptionState}>{sideRightEnabled ? (sideRightDoublageMandatory ? 'Obligatoire' : 'Actif') : 'Retir&eacute;'}</span>
</button>
<button
type="button"
className={`${s.finishOption} ${topFinishEnabled ? s.finishOptionActive : ''}`}
className={`${s.finishOption} ${topDoublageMandatory ? s.finishOptionMandatory : topFinishEnabled ? s.finishOptionActive : ''}`}
disabled={topDoublageMandatory}
title={topDoublageMandatory ? 'Obligatoire: une zone du dessus est sans dos' : undefined}
onClick={() => set('topFinishEnabled', !topFinishEnabled)}
>
<span className={s.finishOptionLabel}>Dessus</span>
<span className={s.finishOptionState}>{topFinishEnabled ? 'Actif' : 'Retir&eacute;'}</span>
<span className={s.finishOptionState}>{topFinishEnabled ? (topDoublageMandatory ? 'Obligatoire' : 'Actif') : 'Retir&eacute;'}</span>
</button>
</div>
{(sideLeftDoublageMandatory || sideRightDoublageMandatory || topDoublageMandatory) && (
<p className={s.finishMandatoryNotice} role="status">
{sideLeftDoublageMandatory && sideRightDoublageMandatory
? 'Colonnes d\'extrémité empilées : habillages gauche et droit obligatoires. '
: sideLeftDoublageMandatory
? 'Colonne gauche empilée : habillage gauche obligatoire. '
: sideRightDoublageMandatory
? 'Colonne droite empilée : habillage droit obligatoire. '
: ''}
{topDoublageMandatory && 'Une zone du dessus (niveau haut) sans dos : dessus de doublage obligatoire.'}
</p>
)}
</div>
)}
@@ -1,8 +1,19 @@
import { COLUMN_TYPES } from '../../constants/wardrobe';
import { COLUMN_TYPES, moduleBlockHeightsMm, moduleBlockBacks, moduleBlockDoors } from '../../constants/wardrobe';
import { HORIZONTAL_SPAN_DOOR_MODES, resolveHorizontalSpan } from '@shared/manufacturing.js';
import { submitOrder } from '../../api/client';
import Icon from '../ui/Icon';
import s from './steps.module.css';
const HORIZONTAL_SPAN_DOOR_LABELS = {
[HORIZONTAL_SPAN_DOOR_MODES.none]: 'sans porte',
[HORIZONTAL_SPAN_DOOR_MODES.perColumn]: 'deux portes, charnières extérieures',
[HORIZONTAL_SPAN_DOOR_MODES.single]: 'une porte pleine largeur'
};
function horizontalSpanDoorLabel(doorMode) {
return HORIZONTAL_SPAN_DOOR_LABELS[doorMode] || HORIZONTAL_SPAN_DOOR_LABELS[HORIZONTAL_SPAN_DOOR_MODES.none];
}
export default function StepFinalize({ wc, ui }) {
const { config, priceResult, serviceOptions, selectedFinish, totalModuleWidth } = wc;
const { orderForm, setOrderForm, orderSent, setOrderSent, setActiveStep } = ui;
@@ -22,7 +33,32 @@ export default function StepFinalize({ wc, ui }) {
// Build summary lines
const installLabel = config.installationType === 'free' ? 'Libre' : 'Niche';
const doorLabel = config.doorType === 'hinged' ? 'Battantes' : `Coulissantes (${config.slidingPanelsCount} vantaux) [legacy]`;
// Portes par block: blocks sans porte = niches ouvertes.
const doorTotals = config.modules.reduce(
(acc, m) => {
const doors = moduleBlockDoors(m, config);
acc.total += doors.length;
acc.withDoor += doors.filter(Boolean).length;
return acc;
},
{ total: 0, withDoor: 0 }
);
const resolvedHorizontalSpans = (config.horizontalSpans || [])
.map((span) => resolveHorizontalSpan(span, config))
.filter(Boolean);
const regularDoorLabel =
config.doorType !== 'hinged'
? `Coulissantes (${config.slidingPanelsCount} vantaux) [legacy]`
: doorTotals.withDoor === doorTotals.total
? `Battantes (${doorTotals.withDoor})`
: doorTotals.withDoor === 0
? 'Sans portes (niches ouvertes)'
: `Battantes — ${doorTotals.withDoor}/${doorTotals.total} blocks avec porte`;
const doorLabel = resolvedHorizontalSpans.length > 0
? `${regularDoorLabel} · Blocks horizontaux : ${resolvedHorizontalSpans
.map((resolved) => horizontalSpanDoorLabel(resolved.span.doorMode))
.join(', ')}`
: regularDoorLabel;
const roomLabel = {
other: 'Autre',
bathroom: 'Salle de bain',
@@ -34,6 +70,34 @@ export default function StepFinalize({ wc, ui }) {
return `${t.icon} ${t.label} (${m.widthMm}mm)`;
}).join(', ');
// Empilage PAR COLONNE — N blocks: zones = somme des blocks de chaque colonne.
const modHeightsList = config.modules.map((m) => moduleBlockHeightsMm(m, config));
const modBacksList = config.modules.map((m, i) => moduleBlockBacks(m, config, modHeightsList[i].length));
const stackedCount = modHeightsList.filter((hs) => hs.length >= 2).length;
const zoneCount = modHeightsList.reduce((sum, hs) => sum + hs.length, 0);
const backCount = modBacksList.reduce((sum, backs) => sum + backs.filter(Boolean).length, 0);
const backSummary =
backCount === 0
? 'Sans dos (côtés et rayonnage en block, reste en séparations)'
: backCount === zoneCount
? 'Avec dos partout (caissons block)'
: `Mixte — ${backCount}/${zoneCount} zone${backCount > 1 ? 's' : ''} avec dos (caissons block) ; sans dos: block si côté ou rayonnage, sinon séparations`;
const stackSignatures = [...new Set(modHeightsList.filter((hs) => hs.length >= 2).map((hs) => hs.join(' + ')))];
const stackSummary =
stackedCount === 0
? '1 (pleine hauteur)'
: stackedCount === config.modules.length && stackSignatures.length === 1
? `${modHeightsList[0].length} empilés — ${stackSignatures[0]} mm (toutes les colonnes)`
: stackedCount === config.modules.length
? 'Empilage sur toutes les colonnes (blocks et hauteurs par colonne)'
: `Mixte — ${stackedCount}/${config.modules.length} colonne${stackedCount > 1 ? 's' : ''} empilée${stackedCount > 1 ? 's' : ''}, reste pleine hauteur`;
const horizontalSpanSummaries = resolvedHorizontalSpans
.map((resolved) => (
`colonnes ${resolved.moduleIndexes[0] + 1}–${resolved.moduleIndexes[resolved.moduleIndexes.length - 1] + 1}, ` +
`${resolved.bottomMm}–${resolved.topMm} mm, ${resolved.span.hasBack ? 'avec dos' : 'sans dos'}, ` +
horizontalSpanDoorLabel(resolved.span.doorMode)
));
return (
<div className={s.stepSection}>
<h2 className={s.stepTitle}>R&eacute;capitulatif &amp; Commande</h2>
@@ -67,6 +131,15 @@ export default function StepFinalize({ wc, ui }) {
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(2)}>modifier</button>
</span>
</div>
{horizontalSpanSummaries.length > 0 && (
<div className={s.summaryRow}>
<span className={s.summaryLabel}>Blocks horizontaux ({horizontalSpanSummaries.length})</span>
<span className={s.summaryValue}>
{horizontalSpanSummaries.join(' · ')}
<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}>
@@ -113,17 +186,27 @@ export default function StepFinalize({ wc, ui }) {
<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>
<span>Structure / Renforts{backCount > 0 ? ' / Dos' : ''} :</span>
<strong>19 mm / 16 mm{backCount > 0 ? ' / 8 mm' : ''}</strong>
</div>
<div className={s.panelRow}>
<span>Dos (par zone) :</span>
<strong>{backSummary}</strong>
</div>
<div className={s.panelRow}>
<span>Blocks en hauteur (par colonne) :</span>
<strong>{stackSummary}</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>
{backCount > 0 && (
<div className={s.panelRow}>
<span>Rainure dos :</span>
<strong>9&times;10.5 mm, &agrave; 16 mm du bord{backCount < zoneCount ? ' (zones avec dos uniquement)' : ''}</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>
@@ -134,7 +217,9 @@ export default function StepFinalize({ wc, ui }) {
</div>
<div className={s.panelRow}>
<span>Plinthe :</span>
<strong>AXILO&trade; 78</strong>
<strong>
AXILO&trade; 78 — pieds {config.plinthHeightMm} mm, panneau {Math.max(0, Number(config.plinthHeightMm) - 5)} mm
</strong>
</div>
{priceResult?.details?.reinforcementCount > 0 && (
<div className={s.panelRow}>
@@ -3,9 +3,14 @@ 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 drawerModules = config.modules.filter(m => m.drawers > 0);
const hasDrawers = drawerModules.length > 0;
const allDrawersVisible = hasDrawers && drawerModules.every(
m => (m.drawerType || config.hardware?.drawerType || 'visible') === 'visible'
);
// Blum Legrabox : hauteurs de côté N/M/K/C/F (66/90/128/177/240 mm).
const standardHeights = [66, 90, 128, 177, 240];
const legraboxSystemByHeight = { 66: 'N', 90: 'M', 128: 'K', 177: 'C', 240: 'F' };
const currentHeights = config.hardware?.drawerHeightsMm || [90, 128, 177, 240];
return (
@@ -24,7 +29,7 @@ export default function StepHardware({ wc }) {
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}
disabled={allDrawersVisible}
>
{h.image_url ? (
<img src={h.image_url} alt={h.name} />
@@ -36,7 +41,7 @@ export default function StepHardware({ wc }) {
</button>
))}
</div>
{drawerType === 'visible' && hasDrawers && (
{allDrawersVisible && (
<p className={s.hint}>Tiroir visible avec coulisse Blum Motion: poignée non requise.</p>
)}
</div>
@@ -96,12 +101,12 @@ export default function StepHardware({ wc }) {
setHardware('drawerHeightsMm', next.slice(0, 4));
}}
>
{mm} mm
{legraboxSystemByHeight[mm]} — {mm} mm
</button>
);
})}
</div>
<p className={s.hint}>4 hauteurs standard max (hypothèse codée en dur pour cette version).</p>
<p className={s.hint}>Hauteurs de côté Blum Legrabox (N/M/K/C/F) — 4 hauteurs max par colonne.</p>
</div>
</div>
)}
@@ -1,7 +1,18 @@
import { STEPS } from '../../constants/wardrobe';
import Icon from '../ui/Icon';
import styles from './StepperBar.module.css';
export default function StepperBar({ activeStep, onStepChange, totalChf, touched }) {
export default function StepperBar({
activeStep,
onStepChange,
onOpenSavedDesigns,
canUndo,
canRedo,
onUndo,
onRedo,
totalChf,
touched
}) {
return (
<div className={styles.stepperBar}>
{STEPS.map(({ id, label }) => (
@@ -20,6 +31,33 @@ export default function StepperBar({ activeStep, onStepChange, totalChf, touched
</button>
))}
<span className={styles.spacer} />
<div className={styles.historyActions} aria-label="Historique des modifications">
<button
type="button"
className={styles.historyBtn}
onClick={onUndo}
disabled={!canUndo}
title="Annuler la dernière action (Ctrl+Z)"
aria-label="Annuler la dernière action"
>
<Icon name="undo" size={16} />
<span>Annuler</span>
</button>
<button
type="button"
className={styles.historyBtn}
onClick={onRedo}
disabled={!canRedo}
title="Rétablir la dernière action (Ctrl+Y)"
aria-label="Rétablir la dernière action"
>
<Icon name="redo" size={16} />
</button>
</div>
<button type="button" className={styles.savedDesignsBtn} onClick={onOpenSavedDesigns}>
<Icon name="save" size={15} />
<span>Mes armoires</span>
</button>
<div className={styles.priceBadge}>
<span className={styles.priceLabel}>CHF</span>
{(totalChf || 0).toLocaleString('fr-CH', { minimumFractionDigits: 0 })}.&ndash;
@@ -3,11 +3,14 @@
align-items: center;
gap: 0.25rem;
padding: 0.5rem 1rem;
background: var(--bg-card);
background: rgba(255, 255, 255, 0.9);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
border-bottom: 1px solid var(--border);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
position: -webkit-sticky;
position: sticky;
top: 0;
top: var(--header-height);
z-index: 60;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
@@ -67,6 +70,52 @@
.spacer { flex: 1; min-width: 0.5rem; }
.historyActions {
display: inline-flex;
flex-shrink: 0;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.historyBtn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.55rem;
border: 0;
background: var(--bg-card);
color: var(--text);
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
}
.historyBtn + .historyBtn { border-left: 1px solid var(--border); }
.historyBtn:hover:not(:disabled) { background: var(--bg); color: var(--action); }
.historyBtn:disabled { color: var(--text-muted); cursor: not-allowed; opacity: 0.45; }
.savedDesignsBtn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
padding: 0.45rem 0.7rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
}
.savedDesignsBtn:hover {
border-color: var(--action);
color: var(--action);
}
.priceBadge {
display: flex;
align-items: center;
@@ -92,5 +141,8 @@
.stepperBar { padding: 0.4rem 0.5rem; gap: 0.15rem; }
.step { padding: 0.4rem 0.6rem; font-size: 0.75rem; }
.stepLabel { display: none; }
.historyBtn span { display: none; }
.savedDesignsBtn span { display: none; }
.savedDesignsBtn { padding: 0.4rem 0.5rem; }
.priceBadge { font-size: 0.8125rem; padding: 0.35rem 0.65rem; }
}
@@ -97,6 +97,9 @@
flex-direction: column;
gap: 0.25rem;
}
.dimFieldFull {
grid-column: 1 / -1;
}
.dimLabel {
font-size: 0.8125rem;
font-weight: 500;
@@ -298,6 +301,22 @@
.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); }
.spanMembership {
display: block;
margin-top: 0.3rem;
padding: 0.18rem 0.25rem;
border-radius: 4px;
background: color-mix(in srgb, #0ea5e9 12%, var(--bg));
color: #0369a1;
font-size: 0.56rem;
font-weight: 700;
line-height: 1.2;
cursor: pointer;
}
.spanMembershipActive {
background: #0ea5e9;
color: #fff;
}
.colLockIcon {
position: absolute;
top: 0.2rem;
@@ -320,6 +339,61 @@
justify-content: space-between;
margin-bottom: 0.75rem;
}
.colDetailHeaderBtns {
display: flex;
align-items: center;
gap: 0.35rem;
}
/* Placeholder shown while no block is selected */
.detailPlaceholder {
padding: 0.85rem 1rem;
border: 1px dashed var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text-muted);
font-size: 0.8rem;
line-height: 1.4;
margin-bottom: 0.75rem;
}
/* Global settings accordion (dos par zone, empilage) */
.globalSettings {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
margin: 0.25rem 0 0.75rem;
}
.globalSettingsSummary {
cursor: pointer;
padding: 0.6rem 0.85rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
user-select: none;
list-style: none;
}
.globalSettingsSummary::-webkit-details-marker { display: none; }
.globalSettingsSummary::before {
content: '\25B8';
display: inline-block;
margin-right: 0.45rem;
color: var(--text-muted);
transition: transform 0.15s;
}
.globalSettings[open] > .globalSettingsSummary::before {
transform: rotate(90deg);
}
.globalSettings[open] > .globalSettingsSummary {
border-bottom: 1px solid var(--border);
margin-bottom: 0.6rem;
}
.globalSettings > .hardwareGroup {
padding: 0 0.85rem;
}
.globalSettings > .hardwareGroup:last-child {
padding-bottom: 0.75rem;
}
.colDetailTitle {
font-size: 0.9375rem;
font-weight: 600;
@@ -337,6 +411,180 @@
.lockBtn:hover { border-color: var(--text); }
.isLocked { background: var(--bg); }
.blockPicker {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
gap: 0.4rem;
margin: 0.35rem 0 0.75rem;
}
.blockPickerBtn {
display: grid;
gap: 0.15rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg);
color: var(--text);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
text-align: left;
transition: border-color 0.15s, background 0.15s;
}
.blockPickerBtn:hover { border-color: var(--action); }
.blockPickerBtn small {
color: var(--text-muted);
font-size: 0.65rem;
font-weight: 500;
}
.blockPickerBtnActive {
border-color: var(--action);
background: color-mix(in srgb, var(--action) 10%, var(--bg));
color: var(--action);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--action) 30%, transparent);
}
.blockSettingsTitle {
margin: 0.2rem 0 0.25rem;
padding-top: 0.65rem;
border-top: 1px solid var(--border);
color: var(--text);
font-size: 0.8rem;
font-weight: 700;
}
.spanPanel {
padding: 0.75rem;
border: 1px solid color-mix(in srgb, #0ea5e9 35%, var(--border));
border-radius: 8px;
background: color-mix(in srgb, #0ea5e9 5%, var(--bg-card));
margin-bottom: 0.75rem;
}
.spanPanelTitle {
margin-bottom: 0.45rem;
color: var(--text);
font-size: 0.82rem;
font-weight: 700;
}
.spanList {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.spanListItem {
display: grid;
gap: 0.1rem;
padding: 0.4rem 0.55rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
font-size: 0.72rem;
text-align: left;
cursor: pointer;
}
.spanListItem small { color: var(--text-muted); }
.spanListItemActive {
border-color: #0ea5e9;
box-shadow: 0 0 0 1px color-mix(in srgb, #0ea5e9 30%, transparent);
}
.spanEditor {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.6rem;
padding-top: 0.6rem;
border-top: 1px solid var(--border);
font-size: 0.75rem;
}
.spanDoorChoices {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.35rem;
width: 100%;
min-width: 0;
margin: 0;
padding: 0.6rem;
border: 1px solid color-mix(in srgb, #0ea5e9 30%, var(--border));
border-radius: 7px;
}
.spanDoorChoices legend {
padding: 0 0.25rem;
color: var(--text);
font-size: 0.75rem;
font-weight: 700;
}
.spanDoorChoices small {
grid-column: 1 / -1;
color: var(--text-muted);
font-size: 0.68rem;
line-height: 1.35;
}
.spanDoorChoice {
min-width: 0;
padding: 0.45rem 0.4rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
font-size: 0.7rem;
font-weight: 600;
line-height: 1.25;
cursor: pointer;
}
.spanDoorChoice:hover {
border-color: #0ea5e9;
}
.spanDoorChoiceActive {
border-color: #0ea5e9;
background: color-mix(in srgb, #0ea5e9 12%, var(--bg-card));
color: #0369a1;
box-shadow: 0 0 0 1px color-mix(in srgb, #0ea5e9 25%, transparent);
}
.spanDeleteBtn {
padding: 0.3rem 0.6rem;
border: 1px solid #ef4444;
border-radius: 5px;
background: transparent;
color: #dc2626;
font-size: 0.75rem;
cursor: pointer;
}
.spanSuccess {
display: flex;
gap: 0.5rem;
align-items: flex-start;
padding: 0.45rem 0.6rem;
border: 1px solid color-mix(in srgb, #22c55e 35%, var(--border));
border-radius: 6px;
background: color-mix(in srgb, #22c55e 10%, var(--bg-card));
color: var(--text);
font-size: 0.78rem;
margin: 0.4rem 0 0.75rem;
}
.horizontalSpanCreator {
margin: 0.55rem 0 0.75rem;
}
.horizontalSpanStartBtn {
width: 100%;
padding: 0.5rem 0.7rem;
border: 1px dashed #0ea5e9;
border-radius: 7px;
background: color-mix(in srgb, #0ea5e9 6%, var(--bg-card));
color: #0369a1;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
}
.spanEndChoices,
.spanCreatorActions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.spanCreatorActions .optimizeBtn { margin-left: 0; }
/* Type selector tiles */
.typeTiles {
display: flex;
@@ -840,17 +1088,30 @@
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
}
.finishOption:hover {
.finishOption:hover:not(:disabled) {
border-color: var(--action);
transform: translateY(-1px);
}
.finishOption:disabled {
cursor: not-allowed;
opacity: 1;
transform: none;
}
.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);
}
.finishOptionMandatory,
.finishOptionMandatory:hover {
border-color: var(--warning);
background: color-mix(in srgb, var(--warning) 16%, var(--bg-card));
box-shadow: 0 8px 18px color-mix(in srgb, var(--warning) 22%, transparent);
}
.finishOptionLabel {
font-size: 0.75rem;
font-weight: 600;
@@ -865,6 +1126,26 @@
color: var(--text-muted);
}
.finishOptionMandatory .finishOptionLabel,
.finishOptionMandatory .finishOptionState {
color: #92400e;
}
.finishOptionMandatory .finishOptionState {
font-weight: 700;
}
.finishMandatoryNotice {
font-size: 0.75rem;
line-height: 1.35;
color: #92400e;
background: color-mix(in srgb, var(--warning) 12%, var(--bg-card));
border: 1px solid color-mix(in srgb, var(--warning) 35%, var(--border));
border-radius: 8px;
padding: 0.45rem 0.6rem;
margin: 0.5rem 0 0;
}
.ruleGrid3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -916,6 +1197,7 @@
.cardToggle { flex-direction: column; }
.columnStrip { gap: 0.25rem; }
.ruleGrid3 { grid-template-columns: 1fr; }
.spanDoorChoices { grid-template-columns: 1fr; }
}
/* Compact mode for desktop split-panel (keeps everything visible) */
+112
View File
@@ -30,6 +30,16 @@ export default function Icon({ name, size = 16, className = '' }) {
<path d="M3 21v-5h5" />
</svg>
);
case 'fit':
return (
<svg {...props}>
<path d="M3 8V5a2 2 0 0 1 2-2h3" />
<path d="M16 3h3a2 2 0 0 1 2 2v3" />
<path d="M21 16v3a2 2 0 0 1-2 2h-3" />
<path d="M8 21H5a2 2 0 0 1-2-2v-3" />
<rect x="8" y="8" width="8" height="8" rx="1" />
</svg>
);
case 'eye':
return (
<svg {...props}>
@@ -37,6 +47,23 @@ export default function Icon({ name, size = 16, className = '' }) {
<circle cx="12" cy="12" r="3" />
</svg>
);
case 'door':
return (
<svg {...props}>
<path d="M5 21V3h14v18" />
<path d="M3 21h18" />
<circle cx="15" cy="12" r="1" fill="currentColor" stroke="none" />
</svg>
);
case 'door-off':
return (
<svg {...props}>
<path d="M5 21V3h14v18" />
<path d="M3 21h18" />
<circle cx="15" cy="12" r="1" fill="currentColor" stroke="none" />
<path d="M3 3l18 18" />
</svg>
);
case 'download':
return (
<svg {...props}>
@@ -45,6 +72,34 @@ export default function Icon({ name, size = 16, className = '' }) {
<path d="M12 15V3" />
</svg>
);
case 'save':
return (
<svg {...props}>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" />
<path d="M17 21v-8H7v8M7 3v5h8" />
</svg>
);
case 'restore':
return (
<svg {...props}>
<path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
<path d="M3 3v5h5M12 7v5l3 2" />
</svg>
);
case 'undo':
return (
<svg {...props}>
<path d="M9 7 4 12l5 5" />
<path d="M4 12h9a7 7 0 0 1 7 7" />
</svg>
);
case 'redo':
return (
<svg {...props}>
<path d="m15 7 5 5-5 5" />
<path d="M20 12h-9a7 7 0 0 0-7 7" />
</svg>
);
case 'trash':
return (
<svg {...props}>
@@ -75,6 +130,63 @@ export default function Icon({ name, size = 16, className = '' }) {
<path d="M5 3v3M3.5 4.5h3M19 16v5M16.5 18.5h5" />
</svg>
);
case 'coins':
return (
<svg {...props}>
<circle cx="8" cy="8" r="6" />
<path d="M18.09 10.37A6 6 0 1 1 10.34 18" />
<path d="M7 6h1v4" />
<path d="m16.71 13.88.7.71-2.82 2.82" />
</svg>
);
case 'layers':
return (
<svg {...props}>
<path d="m12 2 10 5-10 5L2 7z" />
<path d="m2 17 10 5 10-5" />
<path d="m2 12 10 5 10-5" />
</svg>
);
case 'sliding':
return (
<svg {...props}>
<rect x="3" y="4" width="18" height="16" rx="1" />
<path d="M12 4v16" />
<path d="m7 10-2 2 2 2" />
<path d="m17 10 2 2-2 2" />
</svg>
);
case 'drawer':
return (
<svg {...props}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 12h18" />
<path d="M10 7.5h4M10 16.5h4" />
</svg>
);
case 'handle':
return (
<svg {...props}>
<path d="M6 15V10a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v5" />
<path d="M3 15h6M15 15h6" />
</svg>
);
case 'groove':
return (
<svg {...props}>
<rect x="3" y="6" width="18" height="12" rx="1" />
<path d="M10 6v6h4V6" />
</svg>
);
case 'clearance':
return (
<svg {...props}>
<path d="M4 4v16M20 4v16" />
<path d="M8 12h8" />
<path d="m10 9-3 3 3 3" />
<path d="m14 9 3 3-3 3" />
</svg>
);
case 'x':
return (
<svg {...props}>
+140
View File
@@ -0,0 +1,140 @@
import { PANEL } from '@shared/manufacturing.js';
import {
carcassBodyMm, minBlocksForBody, minShelvesForStructuralHeight,
ROD_CLEAR_HEIGHT_MM, ROD_OFFSET_FROM_TOP_MM, STACK_BLOCK_MM
} from './wardrobe';
/** Largeur visée par colonne (mm); les colonnes tiroirs restent ≤ 800 mm. */
const TARGET_COLUMN_MM = 750;
const MIN_COLUMN_MM = 400;
/** Block de penderie empilé: ≈ 1710 mm sous tringle (manteaux longs). */
const HANGING_BLOCK_MM = 1800;
/** Profondeur en dessous de laquelle les cintres ne passent pas (règle 4.B). */
const MIN_HANGING_DEPTH_MM = 400;
/** Hauteur minimale d'armoire pour une tringle (validation serveur). */
const MIN_ROD_WARDROBE_HEIGHT_MM = 1200;
const SHELF_PITCH_MM = 400;
const rodClearForBlockMm = (blockMm) => blockMm - PANEL.structure * 2 - ROD_OFFSET_FROM_TOP_MM;
function columnCount(widthMm) {
if (widthMm < MIN_COLUMN_MM * 2) return 1;
let n = Math.max(1, Math.ceil(widthMm / TARGET_COLUMN_MM));
while (n > 1 && widthMm / n < MIN_COLUMN_MM) n -= 1;
return n;
}
/** Penderies aux extrémités, rangements au centre (tiroirs au milieu). */
function columnTypes(n, hanging) {
if (!hanging) {
return Array.from({ length: n }, (_, i) => (i % 2 === 1 ? 'tiroirs' : 'rayonnage'));
}
if (n === 1) return ['penderie'];
if (n === 2) return ['penderie', 'rayonnage'];
if (n === 3) return ['penderie', 'rayonnage', 'tiroirs'];
const inner = Array.from({ length: n - 2 }, (_, i) => (i % 2 === 0 ? 'tiroirs' : 'rayonnage'));
return ['penderie', ...inner, 'penderie'];
}
/**
* Découpe verticale: au-delà de 2000 mm une colonne mono-block exige un rayon structurel à mi-hauteur,
* ce qui coupe la penderie → penderie empilée (block bas de penderie + surmeuble).
*/
function hangingBlocksMm(bodyMm, heightMm) {
const needsSplit = minShelvesForStructuralHeight(heightMm) > 0 || minBlocksForBody(bodyMm) > 1;
if (!needsSplit) return [bodyMm];
// Seul le dernier block absorbe le reste: les autres sont au pas de 50 mm (normalisation des blocks).
const step = STACK_BLOCK_MM.step;
const bottomMm = Math.min(HANGING_BLOCK_MM, Math.floor((bodyMm - STACK_BLOCK_MM.min) / step) * step);
const topMm = bodyMm - bottomMm;
if (topMm > STACK_BLOCK_MM.max) return null;
return [bottomMm, topMm];
}
/** Rangements: un seul block tant que le panneau le permet, sinon même jonction que les penderies. */
function storageBlocksMm(bodyMm, hangingBlocks) {
if (minBlocksForBody(bodyMm) <= 1) return [bodyMm];
if (hangingBlocks) return hangingBlocks;
const bottomMm = Math.round(bodyMm / 2 / STACK_BLOCK_MM.step) * STACK_BLOCK_MM.step;
return [bottomMm, bodyMm - bottomMm];
}
/** Rayons répartis tous les ~400 mm sur la zone de rangement de chaque block. */
function shelvesFor(blocksMm, reservedBottomMm = 0) {
return blocksMm.reduce((sum, blockMm, i) => {
const usable = blockMm - (i === 0 ? reservedBottomMm : 0);
return sum + Math.max(0, Math.round(usable / SHELF_PITCH_MM) - 1);
}, 0);
}
/**
* Disposition proposée automatiquement à partir des cotes (largeur, hauteur, profondeur).
* Retourne une structure compatible `applyLayoutPreset` (modules avec largeurs sommant à widthMm).
*/
export function proposeLayout(config) {
const widthMm = Number(config.widthMm) || 0;
const heightMm = Number(config.heightMm) || 0;
const bodyMm = carcassBodyMm(config);
const depthMm = Number(config.depthMm) || 0;
const hasBack = config.hasBack === true;
const drawerType = config.hardware?.drawerType ?? 'visible';
const hangingBlocks = hangingBlocksMm(bodyMm, heightMm);
const canHang =
depthMm >= MIN_HANGING_DEPTH_MM &&
heightMm >= MIN_ROD_WARDROBE_HEIGHT_MM &&
hangingBlocks != null &&
rodClearForBlockMm(hangingBlocks[0]) >= ROD_CLEAR_HEIGHT_MM.short;
const n = columnCount(widthMm);
const types = columnTypes(n, canHang && widthMm / n >= MIN_COLUMN_MM);
const storageBlocks = storageBlocksMm(bodyMm, hangingBlocks);
const structuralMin = (blocks) => (blocks.length < 2 ? minShelvesForStructuralHeight(heightMm) : 0);
const baseWidth = Math.floor(widthMm / n);
return {
widthMm,
heightMm,
depthMm,
plinthHeightMm: config.plinthHeightMm,
hasBack,
stackEnabled: canHang && hangingBlocks.length > 1,
horizontalSpans: [],
modules: types.map((type, i) => {
const isHanging = type === 'penderie';
const blockHeightsMm = isHanging ? hangingBlocks : storageBlocks;
const drawers = type === 'tiroirs' ? (bodyMm >= 900 ? 3 : 2) : 0;
const reservedMm = drawers > 0 ? 160 * drawers : 0;
const shelves = isHanging
? 0
: Math.max(structuralMin(blockHeightsMm), shelvesFor(blockHeightsMm, reservedMm));
return {
id: `auto-c${i + 1}`,
type,
moduleCode: type,
widthMm: i === n - 1 ? widthMm - baseWidth * (n - 1) : baseWidth,
minWidth: 300,
maxWidth: type === 'tiroirs' ? 1000 : 1200,
position: i,
locked: false,
shelves,
drawers,
hasRod: isHanging,
drawerType,
hasBack,
hasBackTop: hasBack,
blockHeightsMm: blockHeightsMm.slice()
};
})
};
}
/** Empreinte des choix structurants d'une disposition (sert à détecter une personnalisation). */
export function layoutModulesSignature(modules) {
return (modules || [])
.map((m) => [
m.type, m.widthMm, m.shelves ?? 0, m.drawers ?? 0, m.hasRod ? 1 : 0,
(Array.isArray(m.blockHeightsMm) ? m.blockHeightsMm : []).map(Math.round).join('/')
].join(':'))
.join('|');
}
+417 -14
View File
@@ -1,4 +1,10 @@
import { PANEL } from '@shared/manufacturing.js';
import {
PANEL,
PLINTH,
drawerStackHeightMm,
MAX_PANEL_DIMENSION_MM,
MAX_PANEL_SHORT_SIDE_MM
} from '@shared/manufacturing.js';
/** Inner carcass height (mm): floor to ceiling inside side panels. */
export function innerCarcassHeightMm(heightMm, plinthHeightMm) {
@@ -15,18 +21,178 @@ export function maxShelfGapMm(heightMm, plinthHeightMm, shelfCount) {
return Math.floor((H - n * t) / (n - 1));
}
/**
* Single source of truth for wardrobe envelope dimension ranges (mm).
* Width max = CNC length; depth max = CNC short side; beyond width max the system couples another armoire.
*/
export const DIMENSION_RANGES = {
widthMm: { min: 300, max: MAX_PANEL_DIMENSION_MM, step: 50 },
// > MAX_PANEL_DIMENSION_MM possible uniquement via l'empilage vertical (chaque block ≤ panneau max).
heightMm: { min: 300, max: 4000, step: 50 },
depthMm: { min: 300, max: MAX_PANEL_SHORT_SIDE_MM, step: 10 },
plinthHeightMm: { min: 60, max: 200, step: 5 },
};
export const AXILO_TARGET_HEIGHTS_MM = [...PLINTH.targetHeightsMm];
export const SHELF_GAP_MM = { min: 80, max: 520, step: 10 };
/** Empilage vertical: bornes de hauteur de chaque block (caisse, hors plinthe). max = panneau max. */
export const STACK_BLOCK_MM = { min: 300, max: MAX_PANEL_DIMENSION_MM, step: 50 };
export const SYSTEM32_PITCH_MM = 32;
export const SYSTEM32_FIRST_HOLE_MM = 51;
/** Above this carcass height (mm), at least one shelf per column is required (rigidité / RAZENTZAB). */
export const STRUCTURAL_MIN_SHELF_HEIGHT_MM = 2000;
/** Half-width (mm) of the structural shelf window around carcass mid-height. */
export const STRUCTURAL_WINDOW_HALF_MM = 200;
/** Minimum shelf count for a column at this height (0 or 1). */
export function minShelvesForStructuralHeight(heightMm) {
return (Number(heightMm) || 0) > STRUCTURAL_MIN_SHELF_HEIGHT_MM ? 1 : 0;
}
/** Hauteur de caisse (hors plinthe). */
export function carcassBodyMm(config) {
return (Number(config?.heightMm) || 0) - (Number(config?.plinthHeightMm) || 0);
}
/** Nombre minimum de blocks pour cette hauteur de caisse (panneau max par block). */
export function minBlocksForBody(bodyMm) {
return Math.max(1, Math.ceil((Number(bodyMm) || 0) / STACK_BLOCK_MM.max));
}
/** Nombre maximum de blocks pour cette hauteur de caisse (min 300 mm par block). */
export function maxBlocksForBody(bodyMm) {
return Math.max(1, Math.floor((Number(bodyMm) || 0) / STACK_BLOCK_MM.min));
}
/** Hauteur minimale du block bas pour contenir tous les tiroirs de la colonne. */
export function moduleBottomBlockMinMm(mod, config) {
const drawerCount = Math.max(0, Number(mod?.drawers) || 0);
if (drawerCount <= 0) return STACK_BLOCK_MM.min;
const drawerHeightsMm = config?.hardware?.drawerHeightsMm;
const drawerStackMm = drawerStackHeightMm(drawerCount, drawerHeightsMm);
return Math.max(
STACK_BLOCK_MM.min,
Math.ceil(drawerStackMm / STACK_BLOCK_MM.step) * STACK_BLOCK_MM.step
);
}
/**
* Empilage vertical PAR COLONNE — modèle N blocks.
* Canonique: `mod.blockHeightsMm` (hauteurs de caisse des blocks, bas → haut).
* Legacy 2 blocks: `mod.stacked` + `mod.stackBottomHeightMm` (défauts config.stackEnabled / config.stackBottomHeightMm).
* Retourne des hauteurs normalisées: chaque block dans [min, max], somme = hauteur de caisse.
*/
export function moduleBlockHeightsMm(mod, config) {
const bodyH = carcassBodyMm(config);
const min = STACK_BLOCK_MM.min;
const bottomMin = moduleBottomBlockMinMm(mod, config);
if (!(bodyH >= min * 2)) return [Math.max(0, bodyH)];
const maxBlocks = maxBlocksForBody(bodyH);
const minBlocks = minBlocksForBody(bodyH);
let raw = Array.isArray(mod?.blockHeightsMm)
? mod.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0)
: null;
if (!raw || raw.length === 0) {
// Legacy: flags 2 blocks (stacked + stackBottomHeightMm).
const stacked = mod && typeof mod.stacked === 'boolean' ? mod.stacked : config?.stackEnabled === true;
if (stacked && maxBlocks >= 2) {
const b = Number(mod?.stackBottomHeightMm ?? config?.stackBottomHeightMm);
const bottom = Math.min(Math.max(min, bodyH - min), Math.max(min, Number.isFinite(b) ? b : 1000));
raw = [bottom, bodyH - bottom];
} else {
raw = [bodyH];
}
}
// Empilage obligatoire au-delà du panneau max: forcer le nombre minimum de blocks.
let count = Math.min(Math.max(raw.length, minBlocks), maxBlocks);
// Si les tiroirs ne tiennent pas dans le block bas, réduire le nombre de
// blocks facultatifs afin de leur réserver la hauteur nécessaire.
while (count > minBlocks && bodyH - min * (count - 1) < bottomMin) count -= 1;
if (count <= 1) return [bodyH];
if (raw.length !== count) {
// Nombre de blocks imposé/écrêté → répartition égale.
const even = bodyH / count;
raw = Array.from({ length: count }, () => even);
}
const out = [];
let remaining = bodyH;
for (let i = 0; i < count - 1; i++) {
const blocksAfter = count - i - 1;
const maxH = Math.min(STACK_BLOCK_MM.max, remaining - min * blocksAfter);
const minH = i === 0 ? bottomMin : min;
const stepped = Math.round((Number(raw[i]) || minH) / STACK_BLOCK_MM.step) * STACK_BLOCK_MM.step;
const h = Math.min(maxH, Math.max(minH, stepped));
out.push(h);
remaining -= h;
}
out.push(remaining);
return out;
}
/** Porte par block (bas → haut). Défaut: porte sur chaque block (`bloc ou partie de bloc sans porte`). */
export function moduleBlockDoors(mod, config, blockCount = null) {
const n = blockCount ?? moduleBlockHeightsMm(mod, config).length;
const arr = Array.isArray(mod?.blockDoors) ? mod.blockDoors : null;
return Array.from({ length: n }, (_, i) => (arr && typeof arr[i] === 'boolean' ? arr[i] : true));
}
/** Dos par block (bas → haut). Legacy: hasBack (block 1 / défaut), hasBackTop (dernier block). */
export function moduleBlockBacks(mod, config, blockCount = null) {
const n = blockCount ?? moduleBlockHeightsMm(mod, config).length;
const arr = Array.isArray(mod?.blockBacks) ? mod.blockBacks : null;
const base = typeof mod?.hasBack === 'boolean' ? mod.hasBack : config?.hasBack === true;
return Array.from({ length: n }, (_, i) => {
if (arr && typeof arr[i] === 'boolean') return arr[i];
if (i === n - 1 && n >= 2 && typeof mod?.hasBackTop === 'boolean') return mod.hasBackTop;
return base;
});
}
/** Colonne empilée = au moins 2 blocks en hauteur. */
export function moduleStacked(mod, config) {
return moduleBlockHeightsMm(mod, config).length >= 2;
}
/**
* Habillage latéral obligatoire seulement si la colonne d'extrémité est empilée
* (joints visibles sur cette face). Un côté en un seul block reste optionnel.
*/
export function outerColumnStacked(modules, config, side) {
const mods = Array.isArray(modules) ? modules.filter(Boolean) : [];
if (mods.length === 0) return config?.stackEnabled === true;
const mod = side === 'right' ? mods[mods.length - 1] : mods[0];
return moduleStacked(mod, config);
}
/**
* Infos empilage d'une colonne pour rayons / rendu: null (1 block) ou
* { heightsMm, boundariesMm } — boundariesMm = hauteurs cumulées des jonctions (depuis le bas de caisse).
*/
export function moduleStackInfo(mod, config) {
const heightsMm = moduleBlockHeightsMm(mod, config);
if (heightsMm.length < 2) return null;
const boundariesMm = [];
let acc = 0;
for (let i = 0; i < heightsMm.length - 1; i++) {
acc += heightsMm[i];
boundariesMm.push(acc);
}
return { heightsMm, boundariesMm };
}
/** Colonne empilée → les jonctions (doubles panneaux) jouent le rôle de rayon structurel. */
export function minShelvesForColumn(heightMm, mod, config) {
if (mod && moduleStacked(mod, config)) return 0;
return minShelvesForStructuralHeight(heightMm);
}
/**
* Shelf center Y (meters), wardrobe group space — matches WardrobePreview formulas.
* @param {number|null|undefined} shelfGapMm — clear opening between shelf faces; null = equal division (default).
@@ -72,25 +238,190 @@ export function shelfCenterYsMeters({
);
}
export function system32ShelfPositionsMm(heightMm, plinthHeightMm) {
/**
* Rangées System 32 disponibles (mm depuis le bas de caisse).
* `stack` optionnel ({ boundariesMm }): colonne empilée → chaque block a ses propres rangées,
* aucune position ne traverse une jonction (double panneau horizontal).
*/
export function system32ShelfPositionsMm(heightMm, plinthHeightMm, stack = null) {
const bodyH = (Number(heightMm) || 0) - (Number(plinthHeightMm) || 0);
if (!(bodyH > 0)) return [];
const minPos = SYSTEM32_FIRST_HOLE_MM;
const maxPos = bodyH - SYSTEM32_FIRST_HOLE_MM;
if (maxPos < minPos) return [];
const count = Math.floor((maxPos - minPos) / SYSTEM32_PITCH_MM) + 1;
return Array.from({ length: Math.max(0, count) }, (_, i) => minPos + i * SYSTEM32_PITCH_MM);
const rangeSlots = (fromMm, toMm) => {
const minPos = fromMm + SYSTEM32_FIRST_HOLE_MM;
const maxPos = toMm - SYSTEM32_FIRST_HOLE_MM;
if (maxPos < minPos) return [];
const count = Math.floor((maxPos - minPos) / SYSTEM32_PITCH_MM) + 1;
return Array.from({ length: Math.max(0, count) }, (_, i) => minPos + i * SYSTEM32_PITCH_MM);
};
const boundaries = (Array.isArray(stack?.boundariesMm) ? stack.boundariesMm : [])
.map(Number)
.filter((v) => Number.isFinite(v) && v > 0 && v < bodyH)
.sort((a, b) => a - b);
if (boundaries.length > 0) {
const edges = [0, ...boundaries, bodyH];
const slots = [];
for (let i = 0; i + 1 < edges.length; i++) {
slots.push(...rangeSlots(edges[i], edges[i + 1]));
}
return slots;
}
return rangeSlots(0, bodyH);
}
function shelfIndexValidRange(indexes, slots, shelfIdx, shelfCount) {
const low = Math.max(shelfIdx, shelfIdx > 0 ? indexes[shelfIdx - 1] + 1 : 0);
const high = Math.min(
slots.length - (shelfCount - shelfIdx),
shelfIdx < shelfCount - 1 ? indexes[shelfIdx + 1] - 1 : slots.length - 1
);
return [low, high];
}
function applyShelfIndexOrdering(indexes, slots, shelfCount) {
const next = [...indexes];
for (let i = 0; i < shelfCount; i++) {
const minIdx = i;
next[i] = Math.max(minIdx, Math.min(slots.length - (shelfCount - i), next[i]));
}
for (let i = shelfCount - 2; i >= 0; i--) {
next[i] = Math.min(next[i], next[i + 1] - 1);
}
return next;
}
/**
* Répartit les rayons dans les ouvertures de chaque block, puis les centre dans
* leur ouverture sur le pas System 32. Les jonctions sont ainsi traitées comme
* des séparations structurelles, et non comme des positions autour desquelles
* les rayons peuvent se regrouper.
*/
function distributedShelfIndexes(indexCount, slots, heightMm, plinthHeightMm, stack) {
const bodyH = (Number(heightMm) || 0) - (Number(plinthHeightMm) || 0);
const boundaries = (Array.isArray(stack?.boundariesMm) ? stack.boundariesMm : [])
.map(Number)
.filter((v) => Number.isFinite(v) && v > 0 && v < bodyH)
.sort((a, b) => a - b);
if (boundaries.length === 0) {
return Array.from({ length: indexCount }, (_, i) => {
const t = ((i + 1) * (slots.length + 1)) / (indexCount + 1);
return Math.max(0, Math.min(slots.length - 1, Math.round(t) - 1));
});
}
const edges = [0, ...boundaries, bodyH];
const segmentCounts = Array(edges.length - 1).fill(0);
// À chaque ajout, subdiviser l'ouverture actuellement la plus haute.
for (let shelfIdx = 0; shelfIdx < indexCount; shelfIdx++) {
let bestSegment = 0;
let largestOpening = -Infinity;
for (let segmentIdx = 0; segmentIdx < segmentCounts.length; segmentIdx++) {
const segmentHeight = edges[segmentIdx + 1] - edges[segmentIdx];
const openingHeight = segmentHeight / (segmentCounts[segmentIdx] + 1);
if (openingHeight > largestOpening) {
largestOpening = openingHeight;
bestSegment = segmentIdx;
}
}
segmentCounts[bestSegment] += 1;
}
const targets = [];
for (let segmentIdx = 0; segmentIdx < segmentCounts.length; segmentIdx++) {
const count = segmentCounts[segmentIdx];
const fromMm = edges[segmentIdx];
const segmentHeight = edges[segmentIdx + 1] - fromMm;
for (let i = 0; i < count; i++) {
targets.push(fromMm + ((i + 1) * segmentHeight) / (count + 1));
}
}
return targets.map((targetMm) => {
let bestIdx = 0;
let bestDist = Infinity;
for (let i = 0; i < slots.length; i++) {
const distance = Math.abs(slots[i] - targetMm);
if (distance < bestDist) {
bestDist = distance;
bestIdx = i;
}
}
return bestIdx;
});
}
/** Snap the shelf with the smallest move so one lands in the structural window (System 32). */
function ensureStructuralShelfInIndexes(indexes, slots, heightMm, plinthHeightMm, shelfCount) {
if ((Number(heightMm) || 0) <= STRUCTURAL_MIN_SHELF_HEIGHT_MM || shelfCount <= 0) return indexes;
const bodyMidMm = ((Number(heightMm) || 0) - (Number(plinthHeightMm) || 0)) / 2;
const winMin = bodyMidMm - STRUCTURAL_WINDOW_HALF_MM;
const winMax = bodyMidMm + STRUCTURAL_WINDOW_HALF_MM;
if (indexes.some((idx) => slots[idx] >= winMin && slots[idx] <= winMax)) return indexes;
const structuralSlotIndices = [];
for (let si = 0; si < slots.length; si++) {
if (slots[si] >= winMin && slots[si] <= winMax) structuralSlotIndices.push(si);
}
if (structuralSlotIndices.length === 0) return indexes;
let bestMove = null;
let bestCost = Infinity;
for (let i = 0; i < shelfCount; i++) {
const [low, high] = shelfIndexValidRange(indexes, slots, i, shelfCount);
for (const si of structuralSlotIndices) {
if (si < low || si > high) continue;
const cost = Math.abs(slots[indexes[i]] - slots[si]);
if (cost < bestCost) {
bestCost = cost;
bestMove = { shelfIdx: i, slotIdx: si };
}
}
}
if (!bestMove) {
let closestShelf = 0;
let closestDist = Infinity;
for (let i = 0; i < shelfCount; i++) {
const d = Math.abs(slots[indexes[i]] - bodyMidMm);
if (d < closestDist) {
closestDist = d;
closestShelf = i;
}
}
const [low, high] = shelfIndexValidRange(indexes, slots, closestShelf, shelfCount);
let bestSlot = null;
let bestSlotDist = Infinity;
for (const si of structuralSlotIndices) {
if (si < low || si > high) continue;
const d = Math.abs(slots[si] - bodyMidMm);
if (d < bestSlotDist) {
bestSlotDist = d;
bestSlot = si;
}
}
if (bestSlot != null) bestMove = { shelfIdx: closestShelf, slotIdx: bestSlot };
}
if (!bestMove) return indexes;
const next = [...indexes];
next[bestMove.shelfIdx] = bestMove.slotIdx;
return applyShelfIndexOrdering(next, slots, shelfCount);
}
export function resolveShelfPositionsMm({
shelfCount,
heightMm,
plinthHeightMm,
shelfPositionsMm
shelfPositionsMm,
stack = null
}) {
const n = Math.max(0, Number(shelfCount) || 0);
if (n <= 0) return [];
const slots = system32ShelfPositionsMm(heightMm, plinthHeightMm);
const slots = system32ShelfPositionsMm(heightMm, plinthHeightMm, stack);
if (slots.length <= 0) return [];
if (n >= slots.length) return slots.slice(0, n);
@@ -113,10 +444,7 @@ export function resolveShelfPositionsMm({
if (Array.isArray(shelfPositionsMm) && shelfPositionsMm.length === n) {
indexes = shelfPositionsMm.map(toNearestIndex);
} else {
indexes = Array.from({ length: n }, (_, i) => {
const t = ((i + 1) * (slots.length + 1)) / (n + 1);
return Math.max(0, Math.min(slots.length - 1, Math.round(t) - 1));
});
indexes = distributedShelfIndexes(n, slots, heightMm, plinthHeightMm, stack);
}
for (let i = 0; i < n; i++) {
@@ -128,9 +456,59 @@ export function resolveShelfPositionsMm({
indexes[i] = Math.min(indexes[i], indexes[i + 1] - 1);
}
// Colonne empilée: la jonction bas/haut est déjà structurelle → pas de rayon structurel imposé.
if (!stack) {
indexes = ensureStructuralShelfInIndexes(indexes, slots, heightMm, plinthHeightMm, n);
}
return indexes.map((idx) => slots[idx]);
}
/** Penderie — hauteurs libres sous tringle (règle 4.B): vestes courtes / manteaux longs. */
export const ROD_CLEAR_HEIGHT_MM = { short: 1100, long: 1600 };
/** Axe de tringle sous le dessus du compartiment de penderie. */
export const ROD_OFFSET_FROM_TOP_MM = 50;
/**
* Compartiment de penderie d'une colonne avec tringle: le block le plus bas offrant la hauteur
* vestes courtes (accessible), à défaut le plus haut; entre le dernier obstacle (fond, tiroirs, rayon)
* et le dessus du block. Hauteurs en mm depuis le bas de caisse; `clearMm` = hauteur libre sous la tringle.
*/
export function moduleHangingZoneMm(mod, config) {
if (!mod?.hasRod) return null;
const T = PANEL.structure;
const heights = moduleBlockHeightsMm(mod, config);
const rawClear = (h) => h - 2 * T - ROD_OFFSET_FROM_TOP_MM;
let blockIndex = heights.findIndex((h) => rawClear(h) >= ROD_CLEAR_HEIGHT_MM.short);
if (blockIndex < 0) {
blockIndex = 0;
heights.forEach((h, i) => { if (h > heights[blockIndex]) blockIndex = i; });
}
const blockBottomMm = heights.slice(0, blockIndex).reduce((sum, h) => sum + h, 0);
const topMm = blockBottomMm + heights[blockIndex] - T;
let floorMm = blockBottomMm + T;
const drawers = Math.max(0, Number(mod.drawers) || 0);
if (blockIndex === 0 && drawers > 0) {
floorMm += drawerStackHeightMm(drawers, config?.hardware?.drawerHeightsMm);
}
const shelfCount = Math.max(minShelvesForColumn(config?.heightMm, mod, config), Number(mod.shelves) || 0);
const shelves = resolveShelfPositionsMm({
shelfCount,
heightMm: config?.heightMm,
plinthHeightMm: config?.plinthHeightMm ?? 100,
shelfPositionsMm: mod.shelfPositionsMm,
stack: moduleStackInfo(mod, config)
});
for (const shelfMm of shelves) {
if (shelfMm > floorMm && shelfMm < topMm) floorMm = shelfMm + T / 2;
}
const rodMm = topMm - ROD_OFFSET_FROM_TOP_MM;
return { blockIndex, floorMm, topMm, rodMm, clearMm: Math.max(0, Math.round(rodMm - floorMm)) };
}
// Column element types -- single source of truth
export const COLUMN_TYPES = [
{ key: 'penderie', label: 'Penderie', icon: '\u{1F454}', shelves: 1, drawers: 0, hasRod: true, defaultWidth: 600, minWidth: 400, maxWidth: 1200 },
@@ -193,7 +571,19 @@ export const DEFAULT_CONFIG = {
},
sanitaryGapMm: 30,
modules: [],
columnMode: 'separations', // separations | block
// Ouvertures horizontales couvrant plusieurs colonnes contiguës.
horizontalSpans: [],
// Dos (panneau arrière) optionnel, réglable PAR ZONE (mod.hasBack = zone basse, mod.hasBackTop = zone haute si empilage).
// Construction: dos → caisson block ; sinon côté (montant extérieur) ou rayonnage → block ; sinon séparations.
// `hasBack` global = défaut pour les nouvelles colonnes / sessions legacy.
hasBack: false,
columnMode: 'separations', // separations | block — block si TOUTES les zones ont un dos
// Empilage vertical PAR COLONNE — N blocks: mod.blockHeightsMm (hauteurs bas → haut) + mod.blockBacks.
// Legacy 2 blocks: mod.stacked + mod.stackBottomHeightMm; les valeurs globales ci-dessous = DÉFAUTS.
// Règles: colonne d'extrémité empilée → habillage de ce côté obligatoire; dernier block sans dos → dessus de doublage;
// hauteur de caisse > panneau max (MAX_PANEL_DIMENSION_MM) → empilage obligatoire (nombre de blocks minimum imposé).
stackEnabled: false,
stackBottomHeightMm: 1000, // hauteur caisse du block bas (hors plinthe, legacy 2 blocks)
colors: { ...DEFAULT_COLORS },
hardware: {
handleVariantId: null,
@@ -218,6 +608,19 @@ export const DEFAULT_CONFIG = {
serviceOptionIds: []
};
/** Champs de config qui constituent une disposition type (structure, sans matériaux ni quincaillerie). */
export const LAYOUT_PRESET_KEYS = [
'widthMm', 'heightMm', 'depthMm', 'plinthHeightMm',
'hasBack', 'stackEnabled', 'stackBottomHeightMm',
'modules', 'horizontalSpans'
];
export function pickLayoutPreset(config) {
return Object.fromEntries(
LAYOUT_PRESET_KEYS.filter((key) => config?.[key] !== undefined).map((key) => [key, config[key]])
);
}
export const STEPS = [
{ id: 1, key: 'espace', label: 'Espace' },
{ id: 2, key: 'colonnes', label: 'Colonnes' },
+35 -3
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
const SESSION_STEP_KEY = 'apex_wardrobe_step';
const PLANS_OPEN_KEY = 'apex.plans.open';
@@ -22,6 +22,13 @@ export default function useConfiguratorUI() {
try { return Number(sessionStorage.getItem(SESSION_STEP_KEY)) || 1; } catch { return 1; }
});
const [expandedColumn, setExpandedColumn] = useState(null);
const [expandedBlock, setExpandedBlock] = useState(0);
const [selectedHorizontalSpanId, setSelectedHorizontalSpanId] = useState(null);
const [horizontalSpanDraft, setHorizontalSpanDraft] = useState(null);
const [horizontalSpanMessage, setHorizontalSpanMessage] = useState('');
const activeStepRef = useRef(activeStep);
activeStepRef.current = activeStep;
useEffect(() => {
try { sessionStorage.setItem(SESSION_STEP_KEY, String(activeStep)); } catch {}
@@ -67,10 +74,30 @@ export default function useConfiguratorUI() {
const [orderSent, setOrderSent] = useState(false);
const [lastOrderId, setLastOrderId] = useState(null);
// Matériaux (4) and Finaliser (6) use the 3D click to color a piece.
const staysOnColorStep = () => activeStepRef.current === 4 || activeStepRef.current === 6;
// Module click from 3D view
const handleModuleClick = useCallback((moduleIndex) => {
setExpandedColumn(prev => prev === moduleIndex ? null : moduleIndex);
const handleModuleClick = useCallback((moduleIndex, blockIndex = 0) => {
if (staysOnColorStep()) return;
setSelectedHorizontalSpanId(null);
setHorizontalSpanDraft(null);
setHorizontalSpanMessage('');
setExpandedBlock(blockIndex);
setExpandedColumn(prev => (
prev === moduleIndex && expandedBlock === blockIndex ? null : moduleIndex
));
setActiveStep(2); // jump to columns step when clicking a module
}, [expandedBlock]);
// Kept as a dedicated callback so the 3D preview can select spans later
// without changing the existing module-click contract.
const handleHorizontalSpanSelect = useCallback((spanId) => {
if (staysOnColorStep()) return;
setSelectedHorizontalSpanId(spanId ?? null);
setHorizontalSpanDraft(null);
setHorizontalSpanMessage('');
setActiveStep(2);
}, []);
// Element click from 3D view
@@ -101,6 +128,10 @@ export default function useConfiguratorUI() {
return {
activeStep, setActiveStep,
expandedColumn, setExpandedColumn,
expandedBlock, setExpandedBlock,
selectedHorizontalSpanId, setSelectedHorizontalSpanId,
horizontalSpanDraft, setHorizontalSpanDraft,
horizontalSpanMessage, setHorizontalSpanMessage,
showMaterialPicker, setShowMaterialPicker,
showMaterialModal, setShowMaterialModal,
showColorPicker, setShowColorPicker,
@@ -118,6 +149,7 @@ export default function useConfiguratorUI() {
orderSent, setOrderSent,
lastOrderId, setLastOrderId,
handleModuleClick,
handleHorizontalSpanSelect,
openColorPicker,
closeColorPicker,
openMaterialDetail,
+559 -63
View File
@@ -2,14 +2,107 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { fetchFinishes, fetchServiceOptions, calculatePrice } from '../api/client';
import {
COLUMN_TYPES, DEFAULT_CYCLE, DEFAULT_COLORS, DEFAULT_CONFIG, maxShelfGapMm, SHELF_GAP_MM,
minShelvesForStructuralHeight, resolveShelfPositionsMm
minShelvesForStructuralHeight, minShelvesForColumn, moduleStackInfo, resolveShelfPositionsMm,
moduleBlockHeightsMm, moduleBlockBacks, moduleBlockDoors, carcassBodyMm, maxBlocksForBody,
outerColumnStacked, pickLayoutPreset, moduleHangingZoneMm, ROD_CLEAR_HEIGHT_MM,
DIMENSION_RANGES
} from '../constants/wardrobe';
import {
addUpperBlockAboveHorizontalSpan,
getModuleVerticalBoundariesMm,
getHorizontalSpanUpperBlockState,
normalizeAxiloTargetHeightMm,
normalizeHorizontalSpan,
reconcileHorizontalSpans,
resizeHorizontalSpanUpperBlock,
synchronizeModuleSpanBoundaries
} from '@shared/manufacturing.js';
import { proposeLayout, layoutModulesSignature } from '../constants/layoutProposal';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const SESSION_KEY = 'apex_wardrobe_config';
const SESSION_COLS_KEY = 'apex_wardrobe_numcols';
const MAX_CONFIG_UNDO = 50;
function createHorizontalSpanId() {
if (globalThis.crypto?.randomUUID) return `span-${globalThis.crypto.randomUUID()}`;
return `span-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function sameNumberArray(left, right) {
return (
Array.isArray(left) &&
Array.isArray(right) &&
left.length === right.length &&
left.every((value, index) => Number(value) === Number(right[index]))
);
}
/*
* Disposition « suivie »: tant que les colonnes correspondent à l'empreinte `autoLayoutSignature`
* (aucune personnalisation), elles sont recalculées à chaque changement de cotes selon `layoutFollow`:
* { mode: 'auto' } → proposition automatique; { mode: 'preset', presetId, modules } → disposition type
* remise à l'échelle, ou proposition automatique (fellBack) si elle ne respecte plus les règles.
*/
/** Pré-normalise blocks et rayons comme les effets de cohérence, pour une empreinte stable. */
function fitLayoutModules(c, sourceModules) {
const sourceWidthMm = sourceModules.reduce((sum, m) => sum + (Number(m.widthMm) || 0), 0) || c.widthMm;
const scale = c.widthMm / sourceWidthMm;
let allocatedMm = 0;
return sourceModules.map((module, index) => {
const isLast = index === sourceModules.length - 1;
const widthMm = isLast ? c.widthMm - allocatedMm : Math.round((Number(module.widthMm) || 0) * scale);
allocatedMm += widthMm;
const base = { ...module, widthMm, position: index, shelfPositionsMm: undefined };
const sized = { ...base, blockHeightsMm: moduleBlockHeightsMm(base, c) };
return { ...sized, shelves: Math.max(minShelvesForColumn(c.heightMm, sized, c), Number(module.shelves) || 0) };
});
}
function layoutFits(c, modules) {
return modules.every((m) => {
const typeDef = COLUMN_TYPES.find((t) => t.key === m.type);
if (typeDef && (m.widthMm < typeDef.minWidth || m.widthMm > typeDef.maxWidth)) return false;
if (!m.hasRod) return true;
const zone = moduleHangingZoneMm(m, c);
return c.depthMm >= 400 && zone.clearMm >= ROD_CLEAR_HEIGHT_MM.short;
});
}
function withLayout(c, rawModules, layoutFollow) {
const modules = rawModules.map((module) => {
const sized = { ...module, blockHeightsMm: moduleBlockHeightsMm(module, c) };
return { ...sized, shelves: Math.max(minShelvesForColumn(c.heightMm, sized, c), Number(module.shelves) || 0) };
});
return {
...c,
modules,
stackEnabled: modules.some((m) => (m.blockHeightsMm || []).length >= 2),
horizontalSpans: [],
layoutFollow,
autoLayoutSignature: layoutModulesSignature(modules)
};
}
function withAutoLayout(c) {
return withLayout(c, proposeLayout(c).modules, { mode: 'auto' });
}
function withFollowedLayout(c) {
const follow = c.layoutFollow;
if (follow?.mode === 'preset' && Array.isArray(follow.modules)) {
const modules = fitLayoutModules(c, follow.modules);
if (layoutFits(c, modules)) return withLayout(c, modules, { ...follow, fellBack: false });
return withLayout(c, proposeLayout(c).modules, { ...follow, fellBack: true });
}
return withAutoLayout(c);
}
function isLayoutFollowing(c) {
return !!c.autoLayoutSignature && layoutModulesSignature(c.modules) === c.autoLayoutSignature;
}
function loadSavedConfig() {
try {
const raw = sessionStorage.getItem(SESSION_KEY);
@@ -33,6 +126,8 @@ export default function useWardrobeConfig() {
const pushUndoSnapshot = useCallback(() => {
const snap = JSON.parse(JSON.stringify(configRef.current));
const latest = undoPastRef.current[undoPastRef.current.length - 1];
if (latest && JSON.stringify(latest) === JSON.stringify(snap)) return;
undoPastRef.current.push(snap);
if (undoPastRef.current.length > MAX_CONFIG_UNDO) undoPastRef.current.shift();
undoFutureRef.current = [];
@@ -43,6 +138,7 @@ export default function useWardrobeConfig() {
if (undoPastRef.current.length === 0) return;
const prev = undoPastRef.current.pop();
undoFutureRef.current.push(JSON.parse(JSON.stringify(configRef.current)));
configRef.current = prev;
setConfig(prev);
setUndoRevision((n) => n + 1);
}, []);
@@ -51,9 +147,79 @@ export default function useWardrobeConfig() {
if (undoFutureRef.current.length === 0) return;
const next = undoFutureRef.current.pop();
undoPastRef.current.push(JSON.parse(JSON.stringify(configRef.current)));
configRef.current = next;
setConfig(next);
setUndoRevision((n) => n + 1);
}, []);
const setConfigUndoable = useCallback((updater) => {
pushUndoSnapshot();
setConfig(updater);
}, [pushUndoSnapshot]);
/** Une armoire restaurée est figée (pas de suivi des cotes), sauf `buildFollowed` (disposition type). */
const restoreConfig = useCallback((savedConfig, buildFollowed = null) => {
if (!savedConfig || typeof savedConfig !== 'object' || Array.isArray(savedConfig)) return false;
const legacyBack = savedConfig.hasBack ?? (savedConfig.columnMode === 'block');
const restored = {
...DEFAULT_CONFIG,
...savedConfig,
horizontalSpans: Array.isArray(savedConfig.horizontalSpans) ? savedConfig.horizontalSpans : [],
modules: (savedConfig.modules || []).map((module) => {
const hasBack = typeof module.hasBack === 'boolean' ? module.hasBack : legacyBack;
return {
...module,
hasBack,
hasBackTop: typeof module.hasBackTop === 'boolean' ? module.hasBackTop : hasBack
};
}),
nicheMeasurements: {
...DEFAULT_CONFIG.nicheMeasurements,
...(savedConfig.nicheMeasurements || {})
},
hardware: {
...DEFAULT_CONFIG.hardware,
...(savedConfig.hardware || {})
},
colors: {
...DEFAULT_CONFIG.colors,
...(savedConfig.colors || {})
}
};
restored.horizontalSpans = reconcileHorizontalSpans(restored.horizontalSpans, restored);
const next = buildFollowed
? buildFollowed(restored)
: { ...restored, layoutFollow: null, autoLayoutSignature: null };
pushUndoSnapshot();
configRef.current = next;
setConfig(next);
setNumColumns(Math.max(1, next.modules.length || 1));
return true;
}, [pushUndoSnapshot]);
/**
* Applique une disposition type (structure seule) en conservant matériaux, quincaillerie et type de pose.
* En niche, les cotes sont imposées par la niche: les largeurs de colonnes sont remises à l'échelle.
* La disposition suit ensuite les changements de cotes tant que les colonnes ne sont pas personnalisées.
*/
const applyLayoutPreset = useCallback((layout, presetId = null) => {
if (!layout || !Array.isArray(layout.modules) || layout.modules.length === 0) return false;
const current = configRef.current;
const structure = pickLayoutPreset(layout);
if (current.installationType === 'niche') {
delete structure.widthMm;
delete structure.heightMm;
delete structure.depthMm;
}
const follow = { mode: 'preset', presetId, modules: layout.modules, fellBack: false };
return restoreConfig(
{ ...current, ...structure },
(restored) => withLayout(restored, fitLayoutModules(restored, layout.modules), follow)
);
}, [restoreConfig]);
const [finishes, setFinishes] = useState([]);
const [serviceOptions, setServiceOptions] = useState([]);
const [priceResult, setPriceResult] = useState(null);
@@ -78,18 +244,35 @@ export default function useWardrobeConfig() {
// Keep extended configuration schema compatible with older saved sessions.
useEffect(() => {
setConfig((c) => ({
...DEFAULT_CONFIG,
...c,
nicheMeasurements: {
...DEFAULT_CONFIG.nicheMeasurements,
...(c.nicheMeasurements || {})
},
hardware: {
...DEFAULT_CONFIG.hardware,
...(c.hardware || {})
}
}));
setConfig((c) => {
// Legacy sessions: global hasBack derived from the chosen assembly mode,
// then seeded on each column (dos is now per colonne).
const legacyBack = c.hasBack ?? (c.columnMode === 'block');
const next = {
...DEFAULT_CONFIG,
...c,
hasBack: legacyBack,
horizontalSpans: Array.isArray(c.horizontalSpans) ? c.horizontalSpans : [],
modules: (c.modules || []).map((m) => {
const hb = typeof m.hasBack === 'boolean' ? m.hasBack : legacyBack;
return {
...m,
hasBack: hb,
hasBackTop: typeof m.hasBackTop === 'boolean' ? m.hasBackTop : hb
};
}),
nicheMeasurements: {
...DEFAULT_CONFIG.nicheMeasurements,
...(c.nicheMeasurements || {})
},
hardware: {
...DEFAULT_CONFIG.hardware,
...(c.hardware || {})
}
};
next.horizontalSpans = reconcileHorizontalSpans(next.horizontalSpans, next);
return next;
});
// Run once at startup for legacy session payloads.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -104,10 +287,11 @@ export default function useWardrobeConfig() {
setConfig((c) => {
const h = c.heightMm;
const pl = c.plinthHeightMm ?? 100;
const minS = minShelvesForStructuralHeight(h);
const list = c.modules || [];
let touched = false;
const modules = list.map((mod) => {
// Colonne empilée: la jonction bas/haut est structurelle → pas de rayon minimum imposé.
const minS = minShelvesForColumn(h, mod, c);
const nRaw = mod.shelves || 0;
const n = Math.max(minS, nRaw);
let nextMod = mod;
@@ -119,7 +303,8 @@ export default function useWardrobeConfig() {
shelfCount: n,
heightMm: h,
plinthHeightMm: pl,
shelfPositionsMm: nextMod.shelfPositionsMm
shelfPositionsMm: nextMod.shelfPositionsMm,
stack: moduleStackInfo(mod, c)
});
const nextShelfPositions = n > 0 ? resolvedShelfPositions : [];
const prevShelfPositions = Array.isArray(nextMod.shelfPositionsMm) ? nextMod.shelfPositionsMm : [];
@@ -174,6 +359,94 @@ export default function useWardrobeConfig() {
next = { ...next, doorType: 'hinged', slidingPanelsCount: 2 };
}
// Empilage vertical PAR COLONNE — modèle N blocks (mod.blockHeightsMm / mod.blockBacks).
// config.stackEnabled / config.stackBottomHeightMm = défauts legacy pour colonnes sans valeur.
// Hauteur > panneau max → nombre minimum de blocks imposé par colonne.
let backTouched = false;
const backModules = (Array.isArray(next.modules) ? next.modules : []).map((mod) => {
const heights = moduleBlockHeightsMm(mod, next);
const backs = moduleBlockBacks(mod, next, heights.length);
const doors = moduleBlockDoors(mod, next, heights.length);
const stacked = heights.length >= 2;
const prevHeights = Array.isArray(mod.blockHeightsMm) ? mod.blockHeightsMm : null;
const blockGeometryChanged = !sameNumberArray(prevHeights, heights);
const prevBacks = Array.isArray(mod.blockBacks) ? mod.blockBacks : null;
const prevDoors = Array.isArray(mod.blockDoors) ? mod.blockDoors : null;
const same =
prevHeights && prevHeights.length === heights.length &&
prevHeights.every((v, k) => v === heights[k]) &&
prevBacks && prevBacks.length === backs.length &&
prevBacks.every((v, k) => v === backs[k]) &&
prevDoors && prevDoors.length === doors.length &&
prevDoors.every((v, k) => v === doors[k]) &&
mod.hasBack === backs[0] &&
mod.hasBackTop === backs[backs.length - 1] &&
mod.stacked === stacked &&
mod.stackBottomHeightMm === heights[0];
if (same) return mod;
backTouched = true;
return {
...mod,
blockHeightsMm: heights,
blockBacks: backs,
blockDoors: doors,
// Miroirs legacy (anciens chemins de code / configs sauvegardées).
hasBack: backs[0],
hasBackTop: backs[backs.length - 1],
stacked,
stackBottomHeightMm: heights[0],
shelfPositionsMm: blockGeometryChanged
? resolveShelfPositionsMm({
shelfCount: mod.shelves ?? 0,
heightMm: next.heightMm,
plinthHeightMm: next.plinthHeightMm ?? 100,
shelfPositionsMm: undefined,
stack: heights.length >= 2
? moduleStackInfo({ ...mod, blockHeightsMm: heights }, next)
: null
})
: mod.shelfPositionsMm
};
});
const allBack =
backModules.length > 0 &&
backModules.every((m) => (m.blockBacks || []).every(Boolean));
const expectedColumnMode = allBack ? 'block' : 'separations';
let backModeChanged = backTouched || next.columnMode !== expectedColumnMode;
if (backModeChanged) {
next = { ...next, modules: backModules, columnMode: expectedColumnMode };
}
// Habillages obligatoires (installation libre):
// - colonne d'extrémité empilée → habillage de ce côté seulement
// - une zone du dessus (dernier block) sans dos → dessus de doublage obligatoire
if (next.installationType === 'free' && backModules.length > 0) {
const sideLeftMandatory = outerColumnStacked(backModules, next, 'left');
const sideRightMandatory = outerColumnStacked(backModules, next, 'right');
const topDoublageMandatory = backModules.some((m) => {
const backs = m.blockBacks || [];
return backs.length === 0 ? !m.hasBack : !backs[backs.length - 1];
});
const sideDefaults = next.sideFinishesEnabled !== false;
const patch = {};
if (sideLeftMandatory) {
if (next.exteriorFinishesEnabled === false) patch.exteriorFinishesEnabled = true;
if ((next.sideFinishLeftEnabled ?? sideDefaults) === false) patch.sideFinishLeftEnabled = true;
}
if (sideRightMandatory) {
if (next.exteriorFinishesEnabled === false) patch.exteriorFinishesEnabled = true;
if ((next.sideFinishRightEnabled ?? sideDefaults) === false) patch.sideFinishRightEnabled = true;
}
if (topDoublageMandatory) {
if (next.exteriorFinishesEnabled === false) patch.exteriorFinishesEnabled = true;
if (next.topFinishEnabled === false) patch.topFinishEnabled = true;
}
if (Object.keys(patch).length > 0) {
next = { ...next, ...patch };
backModeChanged = true;
}
}
const modules = Array.isArray(next.modules) ? [...next.modules] : [];
const rawDrawerType = next.hardware?.drawerType || 'visible';
const drawerType =
@@ -183,22 +456,29 @@ export default function useWardrobeConfig() {
// Drawers constraints: from bottom, depth 400..650 by 50, and drawer columns max 1000 mm.
const constrainedModules = modules.map((mod) => {
const hasDrawers = (mod.drawers || 0) > 0 || mod.type === 'tiroirs';
if (!hasDrawers) return mod;
const nextMaxWidth = Math.min(1000, mod.maxWidth || 1200);
const rawModuleDrawerType = mod.drawerType ?? rawDrawerType;
const moduleDrawerType =
rawModuleDrawerType === 'hidden' || rawModuleDrawerType === 'hidden_with_handle'
? 'hidden'
: 'visible';
const nextMaxWidth = hasDrawers ? Math.min(1000, mod.maxWidth || 1200) : mod.maxWidth;
// Keep same reference when nothing changes to avoid render loops/flicker.
if (mod.maxWidth === nextMaxWidth) return mod;
if (mod.maxWidth === nextMaxWidth && mod.drawerType === moduleDrawerType) return mod;
return {
...mod,
maxWidth: nextMaxWidth
maxWidth: nextMaxWidth,
drawerType: moduleDrawerType
};
});
const modulesChanged = constrainedModules.some((m, i) => m !== modules[i]);
const sanitizedHandleBore = sanitizeStep(next.hardware?.handleBoreHeightMm, 600, 1600, 10);
const sanitizedDrawerHeights = (next.hardware?.drawerHeightsMm || [90, 128, 177, 240]).slice(0, 4);
const allowedHeights = [60, 90, 128, 177, 240];
// Blum Legrabox : hauteurs de côté N/M/K/C/F. Héritage : 60 mm → 66 mm (système N).
const allowedHeights = [66, 90, 128, 177, 240];
const fixedDrawerHeights = sanitizedDrawerHeights.map((h) => {
return allowedHeights.includes(Number(h)) ? Number(h) : 128;
const v = Number(h) === 60 ? 66 : Number(h);
return allowedHeights.includes(v) ? v : 128;
});
const drawerDepthStepMm = sanitizeStep(next.depthMm - 10, 400, 650, 50);
@@ -211,14 +491,17 @@ export default function useWardrobeConfig() {
const minNicheDepth = Math.min(niche.depthTopMm || next.depthMm, niche.depthMidMm || next.depthMm, niche.depthBottomMm || next.depthMm);
const minNicheHeight = Math.min(niche.heightLeftMm || next.heightMm, niche.heightRightMm || next.heightMm);
const plinthHeightMm = normalizeAxiloTargetHeightMm(next.plinthHeightMm);
let widthMm = next.widthMm;
let depthMm = next.depthMm;
let heightMm = next.heightMm;
if (next.installationType === 'niche') {
const gap = Number(next.sanitaryGapMm || 30);
widthMm = Math.max(400, minNicheWidth - gap * 2);
depthMm = Math.max(400, minNicheDepth - gap * 2);
heightMm = Math.max(1800, minNicheHeight - gap * 2);
widthMm = Math.max(DIMENSION_RANGES.widthMm.min, minNicheWidth - gap * 2);
depthMm = Math.max(DIMENSION_RANGES.depthMm.min, minNicheDepth - gap * 2);
// The wardrobe sits on the floor: keep one sanitary gap above, not one above and below.
heightMm = Math.max(DIMENSION_RANGES.heightMm.min, minNicheHeight - gap);
}
const hardware = {
@@ -230,10 +513,12 @@ export default function useWardrobeConfig() {
};
const changed =
backModeChanged ||
modulesChanged ||
widthMm !== next.widthMm ||
depthMm !== next.depthMm ||
heightMm !== next.heightMm ||
plinthHeightMm !== next.plinthHeightMm ||
sanitizedHandleBore !== (next.hardware?.handleBoreHeightMm || 1000) ||
effectiveDrawerDepthMm !== next.effectiveDrawerDepthMm ||
handleType !== (next.hardware?.handleType || 'standard') ||
@@ -248,11 +533,12 @@ export default function useWardrobeConfig() {
widthMm,
depthMm,
heightMm,
plinthHeightMm,
hardware,
effectiveDrawerDepthMm
};
});
}, [config.installationType, config.nicheMeasurements, config.sanitaryGapMm, config.doorType, config.depthMm, config.heightMm, config.hardware]);
}, [config.installationType, config.nicheMeasurements, config.sanitaryGapMm, config.doorType, config.depthMm, config.heightMm, config.plinthHeightMm, config.hardware, config.hasBack, config.columnMode, config.modules, config.exteriorFinishesEnabled, config.sideFinishesEnabled, config.sideFinishLeftEnabled, config.sideFinishRightEnabled, config.topFinishEnabled, config.stackEnabled, config.stackBottomHeightMm]);
// Fetch initial data
useEffect(() => {
@@ -296,21 +582,18 @@ export default function useWardrobeConfig() {
}, [config, loading, updatePrice]);
// Simple setter helper
const set = useCallback((key, value) => setConfig((c) => ({ ...c, [key]: value })), []);
const set = useCallback((key, value) => setConfigUndoable((c) => ({ ...c, [key]: value })), [setConfigUndoable]);
const setDimension = useCallback((key, valueMm) => {
const ranges = {
widthMm: { min: 400, max: 5000, step: 50 },
heightMm: { min: 1800, max: 2800, step: 50 },
depthMm: { min: 400, max: 800, step: 10 },
};
const rules = ranges[key];
const applyDimension = useCallback((key, valueMm) => {
const rules = DIMENSION_RANGES[key];
if (!rules) return;
const nextRaw = Number(valueMm);
if (!Number.isFinite(nextRaw)) return;
const nextStepped = Math.round(nextRaw / rules.step) * rules.step;
const next = Math.min(rules.max, Math.max(rules.min, nextStepped));
const next = key === 'plinthHeightMm'
? normalizeAxiloTargetHeightMm(nextRaw)
: Math.min(rules.max, Math.max(rules.min, nextStepped));
setConfig((c) => {
const patch = { [key]: next };
@@ -329,8 +612,9 @@ export default function useWardrobeConfig() {
niche.depthMidMm = nicheOuter;
niche.depthBottomMm = nicheOuter;
} else if (key === 'heightMm') {
niche.heightLeftMm = nicheOuter;
niche.heightRightMm = nicheOuter;
const nicheHeight = next + gap;
niche.heightLeftMm = nicheHeight;
niche.heightRightMm = nicheHeight;
}
patch.nicheMeasurements = niche;
}
@@ -340,6 +624,11 @@ export default function useWardrobeConfig() {
});
}, []);
const setDimension = useCallback((key, valueMm) => {
pushUndoSnapshot();
applyDimension(key, valueMm);
}, [applyDimension, pushUndoSnapshot]);
// Column generation
const generateColumns = useCallback((nCols, totalW, existingModules = []) => {
const lockedWidth = existingModules
@@ -369,16 +658,25 @@ export default function useWardrobeConfig() {
locked: false,
shelves: typeDef.shelves,
drawers: typeDef.drawers,
hasRod: typeDef.hasRod
hasRod: typeDef.hasRod,
drawerType: existing?.drawerType ?? configRef.current?.hardware?.drawerType ?? 'visible',
hasBack: existing?.hasBack ?? (configRef.current?.hasBack === true),
hasBackTop: existing?.hasBackTop ?? existing?.hasBack ?? (configRef.current?.hasBack === true),
stacked: existing?.stacked ?? (configRef.current?.stackEnabled === true),
stackBottomHeightMm: existing?.stackBottomHeightMm ?? configRef.current?.stackBottomHeightMm ?? 1000
};
});
}, []);
const handleNumColumnsChange = useCallback((newNum) => {
pushUndoSnapshot();
setNumColumns(newNum);
const modules = generateColumns(newNum, config.widthMm, config.modules);
setConfig(c => ({ ...c, modules }));
}, [config.widthMm, config.modules, generateColumns]);
setConfig((c) => {
const next = { ...c, modules };
return { ...next, horizontalSpans: reconcileHorizontalSpans(c.horizontalSpans, next) };
});
}, [config.widthMm, config.modules, generateColumns, pushUndoSnapshot]);
// AI layout optimization
const handleAIOptimize = useCallback(async () => {
@@ -406,13 +704,15 @@ export default function useWardrobeConfig() {
if (!res.ok) throw new Error('API error');
const data = await res.json();
if (data.modules && Array.isArray(data.modules)) {
pushUndoSnapshot();
setConfig(c => {
const newModules = data.modules.map((m, i) => ({
...c.modules[i],
...m,
id: c.modules[i]?.id || `col-${i}`
}));
return { ...c, modules: newModules };
const next = { ...c, modules: newModules };
return { ...next, horizontalSpans: reconcileHorizontalSpans(c.horizontalSpans, next) };
});
setNumColumns(data.modules.length);
}
@@ -424,13 +724,14 @@ export default function useWardrobeConfig() {
} finally {
setAiOptimizing(false);
}
}, [config]);
}, [config, pushUndoSnapshot]);
const handleColumnTypeChange = useCallback((index, typeKey) => {
pushUndoSnapshot();
const typeDef = COLUMN_TYPES.find(t => t.key === typeKey) || COLUMN_TYPES[0];
setConfig(c => {
const modules = [...c.modules];
const minS = minShelvesForStructuralHeight(c.heightMm);
const minS = minShelvesForColumn(c.heightMm, modules[index], c);
const shelves = Math.max(minS, typeDef.shelves);
modules[index] = {
...modules[index],
@@ -442,15 +743,58 @@ export default function useWardrobeConfig() {
};
return { ...c, modules };
});
}, []);
}, [pushUndoSnapshot]);
/** Applique le dos (ou son absence) à toutes les zones (tous blocks) + défaut pour les nouvelles colonnes. */
const setAllBacks = useCallback((hasBack) => {
pushUndoSnapshot();
setConfig((c) => ({
...c,
hasBack,
modules: (c.modules || []).map((m) => ({
...m,
hasBack,
hasBackTop: hasBack,
blockBacks: Array.isArray(m.blockBacks) ? m.blockBacks.map(() => hasBack) : m.blockBacks
}))
}));
}, [pushUndoSnapshot]);
/** Applique un nombre de blocks (répartition égale) à toutes les colonnes + défaut pour les nouvelles. */
const setAllBlockCounts = useCallback((count) => {
pushUndoSnapshot();
setConfig((c) => {
const body = carcassBodyMm(c);
const n = Math.min(Math.max(1, Number(count) || 1), maxBlocksForBody(body));
const heights = n <= 1 ? [body] : Array.from({ length: n }, () => body / n);
return {
...c,
stackEnabled: n >= 2,
modules: (c.modules || []).map((m) => {
const nextModule = { ...m, blockHeightsMm: heights.slice() };
return {
...nextModule,
shelfPositionsMm: resolveShelfPositionsMm({
shelfCount: nextModule.shelves ?? 0,
heightMm: c.heightMm,
plinthHeightMm: c.plinthHeightMm ?? 100,
shelfPositionsMm: undefined,
stack: moduleStackInfo(nextModule, c)
})
};
})
};
});
}, [pushUndoSnapshot]);
const handleColumnLock = useCallback((index) => {
pushUndoSnapshot();
setConfig(c => {
const modules = [...c.modules];
modules[index] = { ...modules[index], locked: !modules[index].locked };
return { ...c, modules };
});
}, []);
}, [pushUndoSnapshot]);
const redistributeWidths = useCallback(() => {
setConfig(c => {
@@ -466,29 +810,46 @@ export default function useWardrobeConfig() {
});
}, []);
// Re-distribute when width changes
// Cotes modifiées: la disposition suivie (auto ou type) est recalculée tant que les colonnes n'ont pas été
// personnalisées, sinon simple redistribution des largeurs. Le suivi est évalué sur l'état du rendu, avant que les
// normalisations (blocks, rayons) mises en file par les effets précédents ne modifient les colonnes.
useEffect(() => {
if (config.modules.length > 0) {
redistributeWidths();
if (config.modules.length === 0) return;
if (isLayoutFollowing(config)) {
setConfig(withFollowedLayout);
return;
}
}, [config.widthMm]);
const totalMm = config.modules.reduce((sum, m) => sum + (m.widthMm || 0), 0);
if (totalMm !== config.widthMm) redistributeWidths();
}, [config.widthMm, config.heightMm, config.depthMm, config.plinthHeightMm]);
useEffect(() => {
setNumColumns(Math.max(1, config.modules.length || 1));
}, [config.modules.length]);
// Initialize columns on first load
useEffect(() => {
if (config.modules.length === 0) {
const modules = generateColumns(numColumns, config.widthMm, []);
setConfig(c => ({ ...c, modules }));
}
if (config.modules.length === 0) setConfig(withAutoLayout);
}, []);
const handleColumnUpdate = useCallback((index, updates) => {
/** Réactive la proposition automatique (colonnes recalculées selon les cotes). */
const enableAutoLayout = useCallback(() => {
pushUndoSnapshot();
setConfig(withAutoLayout);
}, [pushUndoSnapshot]);
const applyColumnUpdate = useCallback((index, updates) => {
setConfig(c => {
const modules = [...c.modules];
const prev = modules[index];
const merged = { ...prev, ...updates };
const minS = minShelvesForStructuralHeight(c.heightMm);
const minS = minShelvesForColumn(c.heightMm, merged, c);
const shelves = Math.max(minS, merged.shelves ?? 0);
const drawers = merged.drawers ?? 0;
const blockGeometryChanged = !sameNumberArray(
moduleBlockHeightsMm(prev, c),
moduleBlockHeightsMm(merged, c)
);
const withShelves = {
...merged,
shelves,
@@ -496,7 +857,8 @@ export default function useWardrobeConfig() {
shelfCount: shelves,
heightMm: c.heightMm,
plinthHeightMm: c.plinthHeightMm ?? 100,
shelfPositionsMm: merged.shelfPositionsMm
shelfPositionsMm: blockGeometryChanged ? undefined : merged.shelfPositionsMm,
stack: moduleStackInfo(merged, c)
})
};
const needsMixte = shelves > 0 && drawers > 0 && withShelves.type !== 'mixte';
@@ -507,26 +869,139 @@ export default function useWardrobeConfig() {
});
}, []);
const handleColumnUpdate = useCallback((index, updates) => {
pushUndoSnapshot();
applyColumnUpdate(index, updates);
}, [applyColumnUpdate, pushUndoSnapshot]);
const reorderModules = useCallback((fromIndex, toIndex) => {
if (fromIndex === toIndex) return;
pushUndoSnapshot();
setConfig((c) => {
const list = [...c.modules];
const n = list.length;
if (fromIndex < 0 || fromIndex >= n || toIndex < 0 || toIndex >= n) return c;
const [item] = list.splice(fromIndex, 1);
list.splice(toIndex, 0, item);
return {
const next = {
...c,
modules: list.map((m, idx) => ({ ...m, position: idx })),
};
return { ...next, horizontalSpans: reconcileHorizontalSpans(c.horizontalSpans, next) };
});
}, []);
}, [pushUndoSnapshot]);
const handleResizeModules = useCallback((separatorIndex, deltaXmm) => {
/**
* Create a canonical horizontal span as one undoable state transition.
* Its boundaries and open door bands must land in the same React update as
* the span itself; otherwise rapid queued updates can snapshot stale state.
*/
const createHorizontalSpan = useCallback((spanInput = {}) => {
const id = spanInput.id ?? createHorizontalSpanId();
pushUndoSnapshot();
setConfig((c) => {
const moduleIds = Array.isArray(spanInput.moduleIds) ? spanInput.moduleIds : [];
const synchronized = synchronizeModuleSpanBoundaries(
c,
moduleIds,
spanInput.bottomMm,
spanInput.topMm
);
const selectedIds = new Set(moduleIds.map(String));
const bottomMm = Number(spanInput.bottomMm);
const topMm = Number(spanInput.topMm);
const modules = synchronized.modules.map((module) => {
if (!selectedIds.has(String(module.id))) return module;
const edges = getModuleVerticalBoundariesMm(module, synchronized);
const doors = moduleBlockDoors(module, synchronized, edges.length - 1);
return {
...module,
blockDoors: doors.map((hasDoor, index) => (
edges[index] >= bottomMm - 0.01 && edges[index + 1] <= topMm + 0.01
? false
: hasDoor
))
};
});
const next = { ...synchronized, modules };
const span = normalizeHorizontalSpan({ ...spanInput, id }, modules);
return { ...next, horizontalSpans: [...(c.horizontalSpans || []), span] };
});
return id;
}, [pushUndoSnapshot]);
/** Patch a span without changing its id. */
const updateHorizontalSpan = useCallback((spanId, updates = {}) => {
if (!(configRef.current.horizontalSpans || []).some((span) => String(span.id) === String(spanId))) return false;
pushUndoSnapshot();
setConfig((c) => ({
...c,
horizontalSpans: (c.horizontalSpans || []).map((span) =>
String(span.id) === String(spanId)
? normalizeHorizontalSpan({ ...span, ...updates, id: span.id }, c.modules)
: span
)
}));
return true;
}, [pushUndoSnapshot]);
const deleteHorizontalSpan = useCallback((spanId) => {
if (!(configRef.current.horizontalSpans || []).some((span) => String(span.id) === String(spanId))) return false;
pushUndoSnapshot();
setConfig((c) => ({
...c,
horizontalSpans: (c.horizontalSpans || []).filter((span) => String(span.id) !== String(spanId))
}));
return true;
}, [pushUndoSnapshot]);
/**
* Add the selected bottom/top limits to every covered column's vertical block model.
* Existing block back/door flags are inherited by each resulting segment.
*/
const synchronizeHorizontalSpanBoundaries = useCallback((moduleIds, bottomMm, topMm) => {
if (!Array.isArray(moduleIds) || moduleIds.length === 0) return false;
pushUndoSnapshot();
setConfig((c) => synchronizeModuleSpanBoundaries(c, moduleIds, bottomMm, topMm));
return true;
}, [pushUndoSnapshot]);
/** Add the shared 300 mm regular block and move the span boundary in one undoable update. */
const addHorizontalSpanUpperBlock = useCallback((spanId) => {
const result = addUpperBlockAboveHorizontalSpan(configRef.current, spanId);
if (!result.ok) return result;
pushUndoSnapshot();
configRef.current = result.config;
setConfig(result.config);
return result;
}, [pushUndoSnapshot]);
/** Resize the shared upper block across every covered column in one undoable update. */
const resizeHorizontalSpanUpperBlockHeight = useCallback((spanId, heightMm) => {
const result = resizeHorizontalSpanUpperBlock(configRef.current, spanId, heightMm);
if (!result.ok || result.config === configRef.current) return result;
pushUndoSnapshot();
configRef.current = result.config;
setConfig(result.config);
return result;
}, [pushUndoSnapshot]);
const getHorizontalSpanUpperBlock = useCallback(
(spanId) => getHorizontalSpanUpperBlockState(configRef.current, spanId),
[]
);
/** `base` (gesture start widths) makes the delta absolute, so repeated moves never accumulate rounding. */
const handleResizeModules = useCallback((separatorIndex, deltaXmm, base) => {
setConfig((c) => {
const modules = [...c.modules];
if (!modules[separatorIndex] || !modules[separatorIndex + 1]) return c;
const left = { ...modules[separatorIndex] };
const right = { ...modules[separatorIndex + 1] };
if (base) {
left.widthMm = base.leftMm;
right.widthMm = base.rightMm;
}
const leftMin = left.minWidth || 300;
const leftMax = left.maxWidth || 1200;
@@ -543,6 +1018,10 @@ export default function useWardrobeConfig() {
left.widthMm = Math.round(newLeftW);
right.widthMm = Math.round(newRightW);
if (
left.widthMm === c.modules[separatorIndex].widthMm
&& right.widthMm === c.modules[separatorIndex + 1].widthMm
) return c;
modules[separatorIndex] = left;
modules[separatorIndex + 1] = right;
return { ...c, modules };
@@ -555,6 +1034,7 @@ export default function useWardrobeConfig() {
}, []);
const handleColorSelect = useCallback((elementType, finish) => {
pushUndoSnapshot();
setConfig((c) => ({
...c,
colors: {
@@ -567,9 +1047,10 @@ export default function useWardrobeConfig() {
}
}
}));
}, []);
}, [pushUndoSnapshot]);
const handleColorApplyToAll = useCallback((finish) => {
pushUndoSnapshot();
const colorEntry = {
finishId: finish.id,
label: finish.label || finish.name,
@@ -586,18 +1067,20 @@ export default function useWardrobeConfig() {
});
return { ...c, colors: newColors };
});
}, []);
}, [pushUndoSnapshot]);
// Hardware
const setHardware = useCallback((key, value) => {
pushUndoSnapshot();
setConfig((c) => ({
...c,
hardware: { ...c.hardware, [key]: value }
}));
}, []);
}, [pushUndoSnapshot]);
// Material management
const handleMaterialConfirm = useCallback((materialId, dimensionId) => {
pushUndoSnapshot();
setConfig((c) => {
const material = finishes.find(f => f.id === materialId);
const updated = { ...c, finishId: materialId, dimensionId };
@@ -619,7 +1102,7 @@ export default function useWardrobeConfig() {
}
return updated;
});
}, [finishes]);
}, [finishes, pushUndoSnapshot]);
// Derived values
const totalModuleWidth = useMemo(() =>
@@ -643,7 +1126,10 @@ export default function useWardrobeConfig() {
);
return {
config, setConfig, set, setDimension,
config, setConfig: setConfigUndoable, restoreConfig, applyLayoutPreset, enableAutoLayout,
layoutFollow: isLayoutFollowing(config) ? (config.layoutFollow || { mode: 'auto' }) : null,
set, setDimension,
setDimensionWithoutUndo: applyDimension,
finishes, serviceOptions,
priceResult,
loading,
@@ -655,9 +1141,19 @@ export default function useWardrobeConfig() {
handleAIOptimize,
handleColumnTypeChange,
handleColumnLock,
setAllBacks,
setAllBlockCounts,
redistributeWidths,
handleColumnUpdate,
handleColumnUpdateWithoutUndo: applyColumnUpdate,
reorderModules,
createHorizontalSpan,
updateHorizontalSpan,
deleteHorizontalSpan,
synchronizeHorizontalSpanBoundaries,
addHorizontalSpanUpperBlock,
resizeHorizontalSpanUpperBlockHeight,
getHorizontalSpanUpperBlock,
handleResizeModules,
handleElementClick,
handleColorSelect,
+1
View File
@@ -15,6 +15,7 @@
--warning: #f59e0b;
--danger: #dc2626;
--font: 'DM Sans', system-ui, sans-serif;
--header-height: 63px;
}
* {
+340 -5
View File
@@ -7,6 +7,10 @@ import {
getFormexSyncStreamUrl,
getHaefeleSyncStreamUrl,
fetchHaefeleMaterials,
getLeubaHiagSyncStreamUrl,
fetchLeubaHiagMaterials,
fetchLeubaHiagTargets,
setFormexMaterialActive,
fetchOrderSupplierVouchers,
fetchOrderPlanExports,
generateOrderPlanExports,
@@ -16,8 +20,16 @@ import {
deleteOrderPlanExport
} from '../api/client';
import Icon from '../components/ui/Icon';
import SectionNav from '../components/SectionNav';
import styles from './Admin.module.css';
const ADMIN_SECTIONS = [
{ id: 'sync-formex', label: 'Formex' },
{ id: 'sync-hafele', label: 'Häfele' },
{ id: 'sync-leuba', label: 'Leuba Hiag' },
{ id: 'commandes', label: 'Commandes' }
];
export default function Admin() {
const { isAdmin, logout } = useAuth();
const navigate = useNavigate();
@@ -61,17 +73,42 @@ export default function Admin() {
const [haefeleError, setHaefeleError] = useState('');
const haefeleLogsEndRef = useRef(null);
// Leuba Hiag sync state
const [leubaTargets, setLeubaTargets] = useState([]);
const [leubaSelected, setLeubaSelected] = useState([]);
const [leubaActivate, setLeubaActivate] = useState(false);
const [leubaFetchPrices, setLeubaFetchPrices] = useState(true);
const [leubaSyncing, setLeubaSyncing] = useState(false);
const [leubaSyncResult, setLeubaSyncResult] = useState(null);
const [leubaLiveMessages, setLeubaLiveMessages] = useState([]);
const [leubaProgress, setLeubaProgress] = useState({ current: 0, total: 0, collection: '' });
const [leubaSearch, setLeubaSearch] = useState('');
const [leubaMaterials, setLeubaMaterials] = useState([]);
const [leubaLoading, setLeubaLoading] = useState(false);
const [leubaError, setLeubaError] = useState('');
const leubaLogsEndRef = useRef(null);
useEffect(() => {
if (!isAdmin) {
navigate('/login?next=/admin', { replace: true });
return;
}
// Fetch orders and sync logs in parallel
Promise.all([fetchOrders(), fetchSyncLogs(10), fetchHaefeleMaterials({ limit: 50 })])
.then(([ordersData, logsData, haefeleData]) => {
Promise.all([
fetchOrders(),
fetchSyncLogs(10),
fetchHaefeleMaterials({ limit: 50 }),
fetchLeubaHiagMaterials({ limit: 100 }),
fetchLeubaHiagTargets()
])
.then(([ordersData, logsData, haefeleData, leubaData, leubaTargetsData]) => {
setOrders(ordersData);
setSyncLogs(logsData);
setHaefeleMaterials(haefeleData?.materials || []);
setLeubaMaterials(leubaData?.materials || []);
const targets = leubaTargetsData || [];
setLeubaTargets(targets);
setLeubaSelected(targets.map((t) => t.key));
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
@@ -90,6 +127,12 @@ export default function Admin() {
}
}, [haefeleLiveMessages]);
useEffect(() => {
if (leubaLogsEndRef.current) {
leubaLogsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [leubaLiveMessages]);
useEffect(() => {
const saved = window.localStorage.getItem('admin.haefele.urls');
if (saved) setHaefeleUrlsInput(saved);
@@ -255,12 +298,118 @@ export default function Admin() {
setHaefeleProgress({ current: 0, total: 0, collection: '' });
};
const refreshLeubaMaterials = async (search = leubaSearch) => {
setLeubaLoading(true);
setLeubaError('');
try {
const data = await fetchLeubaHiagMaterials({ search, limit: 200 });
setLeubaMaterials(data?.materials || []);
} catch (err) {
setLeubaError(err.message || 'Erreur de chargement Leuba Hiag');
} finally {
setLeubaLoading(false);
}
};
const toggleLeubaTarget = (key) => {
setLeubaSelected((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
);
};
const handleToggleLeubaActive = async (item) => {
const nextActive = !item.active;
// Optimistic update so the change is reflected immediately.
setLeubaMaterials((prev) =>
prev.map((m) => (m.id === item.id ? { ...m, active: nextActive ? 1 : 0 } : m))
);
try {
await setFormexMaterialActive(item.id, nextActive);
} catch (err) {
// Revert on failure.
setLeubaMaterials((prev) =>
prev.map((m) => (m.id === item.id ? { ...m, active: item.active } : m))
);
setLeubaError(err.message || 'Échec de la mise à jour du statut');
}
};
const handleLeubaSync = () => {
if (leubaSelected.length === 0) {
setLeubaSyncResult({ success: false, error: 'Sélectionnez au moins une gamme à importer.' });
return;
}
setLeubaSyncing(true);
setLeubaSyncResult(null);
setLeubaLiveMessages([]);
setLeubaProgress({ current: 0, total: leubaSelected.length, collection: 'Leuba Hiag' });
const eventSource = new EventSource(
getLeubaHiagSyncStreamUrl({ targets: leubaSelected, activate: leubaActivate, fetchPrices: leubaFetchPrices })
);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'log') {
const msg = data.message;
const lower = msg.toLowerCase();
const kind = lower.includes('erreur') || lower.includes('error') || lower.includes('✗')
? 'error'
: lower.includes('✓') || lower.includes('importé') || lower.includes('détecté')
? 'ok'
: 'info';
setLeubaLiveMessages((prev) => [...prev, { msg, kind }]);
} else if (data.type === 'progress') {
setLeubaProgress({
current: data.current,
total: data.total,
collection: data.collection || 'Leuba Hiag'
});
} else if (data.type === 'complete') {
setLeubaSyncResult(data.result);
setLeubaSyncing(false);
eventSource.close();
fetchSyncLogs(10).then(setSyncLogs).catch(() => {});
refreshLeubaMaterials().catch(() => {});
} else if (data.type === 'error') {
setLeubaLiveMessages((prev) => [...prev, { msg: `ERREUR: ${data.error}`, kind: 'error' }]);
setLeubaSyncResult({ success: false, error: data.error });
setLeubaSyncing(false);
eventSource.close();
fetchSyncLogs(10).then(setSyncLogs).catch(() => {});
}
} catch (e) {
console.error('Leuba Hiag SSE parse error:', e);
}
};
eventSource.onerror = () => {
setLeubaLiveMessages((prev) => [...prev, { msg: 'Connexion SSE Leuba Hiag perdue', kind: 'error' }]);
setLeubaSyncResult({ success: false, error: 'Connexion perdue' });
setLeubaSyncing(false);
eventSource.close();
fetchSyncLogs(10).then(setSyncLogs).catch(() => {});
refreshLeubaMaterials().catch(() => {});
};
};
const handleClearLeubaLogs = () => {
setLeubaLiveMessages([]);
setLeubaSyncResult(null);
setLeubaProgress({ current: 0, total: 0, collection: '' });
};
const progressPercent = progress.total > 0
? Math.round((progress.current / progress.total) * 100)
: 0;
const haefeleProgressPercent = haefeleProgress.total > 0
? Math.round((haefeleProgress.current / haefeleProgress.total) * 100)
: 0;
const leubaProgressPercent = leubaProgress.total > 0
? Math.round((leubaProgress.current / leubaProgress.total) * 100)
: 0;
const handleToggleSupplierDetails = async (orderId) => {
if (expandedOrderId === orderId) {
@@ -365,10 +514,12 @@ export default function Admin() {
</div>
{loading && <p className={styles.muted}>Chargement…</p>}
{error && <p className={styles.error}>{error}</p>}
{!loading && !error && <SectionNav sections={ADMIN_SECTIONS} />}
{/* Formex Sync Section */}
{!loading && !error && (
<section className={styles.section}>
<section id="sync-formex" className={styles.section}>
<h2>Synchronisation Formex</h2>
<div className={styles.syncActions}>
<button
@@ -468,7 +619,7 @@ export default function Admin() {
)}
{!loading && !error && (
<section className={styles.section}>
<section id="sync-hafele" className={styles.section}>
<h2>Synchronisation Häfele (URLs ciblées)</h2>
<p className={styles.muted}>
Collez une URL par ligne (ou séparées par virgule). Seules les URL Häfele seront traitées.
@@ -607,9 +758,193 @@ export default function Admin() {
</section>
)}
{/* Leuba Hiag Sync Section */}
{!loading && !error && (
<section id="sync-leuba" className={styles.section}>
<h2>Synchronisation Leuba Hiag (gammes ciblées)</h2>
<p className={styles.muted}>
Importe uniquement les gammes convenues depuis le catalogue Leuba Hiag (Algolia).
Les articles sont importés inactifs par défaut&nbsp;: activez ensuite les décors voulus
dans la page Matériaux.
</p>
<div className={styles.leubaTargets}>
{leubaTargets.map((t) => (
<label key={t.key} className={styles.leubaTargetItem}>
<input
type="checkbox"
checked={leubaSelected.includes(t.key)}
onChange={() => toggleLeubaTarget(t.key)}
disabled={leubaSyncing}
/>
<span>
<strong>{t.label}</strong>
<small>{t.tier}</small>
</span>
</label>
))}
</div>
<div className={styles.syncActions}>
<button
type="button"
onClick={handleLeubaSync}
disabled={leubaSyncing}
className={styles.syncBtn}
>
<Icon name="sync" size={15} />
{leubaSyncing ? 'Synchronisation Leuba Hiag en cours…' : 'Importer les gammes sélectionnées'}
</button>
<label className={styles.forceLabel}>
<input
type="checkbox"
checked={leubaFetchPrices}
onChange={(e) => setLeubaFetchPrices(e.target.checked)}
disabled={leubaSyncing}
/>
Récupérer les prix client (connexion)
</label>
<label className={styles.forceLabel}>
<input
type="checkbox"
checked={leubaActivate}
onChange={(e) => setLeubaActivate(e.target.checked)}
disabled={leubaSyncing}
/>
Activer immédiatement (visible dans le configurateur)
</label>
{leubaSyncResult && !leubaSyncing && (
<span className={leubaSyncResult.success ? styles.success : styles.error}>
{leubaSyncResult.success
? `${leubaSyncResult.itemsCount} produits importés${leubaSyncResult.pricedCount ? `, ${leubaSyncResult.pricedCount} prix` : ''} (${leubaSyncResult.duration}s)`
: `Erreur: ${leubaSyncResult.error}`}
</span>
)}
</div>
{(leubaSyncing || leubaLiveMessages.length > 0) && (
<div className={styles.liveSync}>
<div className={styles.progressWrap}>
<div className={styles.progressInfo}>
<span>{leubaProgress.collection || (leubaSyncing ? 'Initialisation…' : 'Terminé')}</span>
<span>
{leubaSyncing
? `${leubaProgressPercent}%`
: (leubaSyncResult?.success ? '✓ Succès' : '✗ Échec')}
</span>
</div>
<div className={styles.progressBar}>
<div
className={`${styles.progressFill} ${!leubaSyncing && leubaSyncResult ? (leubaSyncResult.success ? styles.progressDone : styles.progressError) : ''}`}
style={{ width: `${leubaSyncing ? leubaProgressPercent : 100}%` }}
/>
</div>
<div className={styles.progressStats}>
{leubaProgress.total > 0 && `${leubaProgress.current} / ${leubaProgress.total} gammes`}
</div>
</div>
<div className={styles.liveLogsPanel}>
<div className={styles.liveLogsHeader}>
<span>Journal Leuba Hiag ({leubaLiveMessages.length} lignes)</span>
{!leubaSyncing && (
<button type="button" onClick={handleClearLeubaLogs} className={styles.clearBtn}>
Effacer
</button>
)}
</div>
<div className={styles.liveLogsContent}>
{leubaLiveMessages.map(({ msg, kind }, idx) => (
<div key={idx} className={`${styles.logLine} ${styles[`logLine_${kind}`]}`}>{msg}</div>
))}
<div ref={leubaLogsEndRef} />
</div>
</div>
</div>
)}
<div className={styles.haefeleListHeader}>
<h3>Articles Leuba Hiag importés</h3>
<div className={styles.haefeleListActions}>
<input
type="text"
className={styles.searchInput}
value={leubaSearch}
onChange={(e) => setLeubaSearch(e.target.value)}
placeholder="Rechercher (nom, code, gamme)"
/>
<button
type="button"
className={styles.detailsBtn}
onClick={() => refreshLeubaMaterials()}
disabled={leubaLoading}
>
<Icon name="sync" size={14} />
{leubaLoading ? 'Chargement…' : 'Actualiser'}
</button>
</div>
</div>
{leubaError && <p className={styles.error}>{leubaError}</p>}
<div className={styles.tableWrap}>
<table className={styles.logsTable}>
<thead>
<tr>
<th>Code</th>
<th>Nom</th>
<th>Gamme</th>
<th>Dimensions (mm)</th>
<th>Prix CHF</th>
<th>Actif</th>
<th>URL</th>
</tr>
</thead>
<tbody>
{leubaMaterials.length === 0 ? (
<tr>
<td colSpan={7} className={styles.empty}>
{leubaLoading ? 'Chargement…' : 'Aucun article Leuba Hiag importé.'}
</td>
</tr>
) : (
leubaMaterials.map((item) => (
<tr key={item.id}>
<td>{item.formex_code || '—'}</td>
<td>{item.name || '—'}</td>
<td>{item.collection || '—'}</td>
<td>
{item.thickness_mm && item.height_mm && item.width_mm
? `${item.thickness_mm} × ${item.height_mm} × ${item.width_mm}`
: item.thickness_mm ? `ép. ${item.thickness_mm}` : '—'}
</td>
<td>{item.price_chf != null ? `${Number(item.price_chf).toFixed(2)}` : '—'}</td>
<td>
<button
type="button"
className={`${styles.activeToggle} ${item.active ? styles.activeToggleOn : styles.activeToggleOff}`}
onClick={() => handleToggleLeubaActive(item)}
title={item.active ? 'Cliquer pour masquer du configurateur' : 'Cliquer pour activer dans le configurateur'}
>
{item.active ? 'Oui' : 'Non'}
</button>
</td>
<td>
{item.formex_url ? (
<a href={item.formex_url} target="_blank" rel="noreferrer" className={styles.downloadLink}>
Voir page
</a>
) : '—'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
)}
{/* Orders Section */}
{!loading && !error && (
<section className={styles.section}>
<section id="commandes" className={styles.section}>
<h2>Commandes</h2>
<div className={styles.tableWrap}>
<table className={styles.table}>
+64
View File
@@ -300,6 +300,70 @@
margin-top: 0.75rem;
}
.leubaTargets {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.5rem;
margin: 0.75rem 0;
}
.leubaTargetItem {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.65rem;
border: 1px solid var(--border, #e2e2e2);
border-radius: 10px;
cursor: pointer;
user-select: none;
}
.leubaTargetItem:has(input:checked) {
border-color: var(--accent, #e67e22);
background: rgba(230, 126, 34, 0.06);
}
.leubaTargetItem input[type="checkbox"] {
accent-color: var(--accent, #e67e22);
}
.leubaTargetItem span {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.leubaTargetItem small {
color: var(--text-secondary, #777);
font-size: 0.72rem;
}
.activeToggle {
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.15rem 0.7rem;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
line-height: 1.3;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.activeToggleOn {
background: rgba(22, 163, 74, 0.12);
border-color: rgba(22, 163, 74, 0.5);
color: #16a34a;
}
.activeToggleOff {
background: rgba(120, 120, 120, 0.08);
color: var(--text-secondary, #777);
}
.activeToggle:hover {
border-color: var(--accent, #e67e22);
}
.urlTextarea {
width: 100%;
padding: 0.75rem;
+19
View File
@@ -14,6 +14,7 @@ import RoomUpload from '../components/RoomUpload';
import ColorPickerModal from '../components/ColorPickerModal';
import Plans2DView from '../components/Plans2DView';
import AIRenderModal from '../components/AIRenderModal';
import SavedDesignsModal from '../components/SavedDesignsModal';
import styles from './Configurator.module.css';
export default function Configurator() {
@@ -22,6 +23,7 @@ export default function Configurator() {
// Track which steps have been visited
const [touched, setTouched] = useState(new Set([1]));
const [showSavedDesigns, setShowSavedDesigns] = useState(false);
const handleStepChange = (step) => {
ui.setActiveStep(step);
setTouched(prev => new Set(prev).add(step));
@@ -77,6 +79,11 @@ export default function Configurator() {
<StepperBar
activeStep={ui.activeStep}
onStepChange={handleStepChange}
onOpenSavedDesigns={() => setShowSavedDesigns(true)}
canUndo={wc.canUndo}
canRedo={wc.canRedo}
onUndo={wc.undo}
onRedo={wc.redo}
totalChf={totalChf}
touched={touched}
/>
@@ -100,6 +107,18 @@ export default function Configurator() {
{/* ===== Modals ===== */}
{showSavedDesigns && (
<SavedDesignsModal
config={wc.config}
onRestore={(savedConfig) => {
if (wc.restoreConfig(savedConfig)) {
setTouched(new Set([1, 2, 3, 4, 5, 6]));
}
}}
onClose={() => setShowSavedDesigns(false)}
/>
)}
{ui.showMaterialModal && ui.selectedMaterial && (
<MaterialDetailModal
material={ui.selectedMaterial}
+10 -1
View File
@@ -42,7 +42,7 @@
padding: 0;
min-width: 0; /* allow canvas to shrink without overflow */
position: sticky;
top: 58px;
top: calc(var(--header-height) + 58px);
align-self: flex-start;
z-index: 10;
}
@@ -52,6 +52,15 @@
/* Extra right padding so scrollbars don't cover slider thumbs/cursors */
padding: 0.75rem 1.4rem 0.25rem 0.9rem;
min-width: 0;
/* Keep the parameters panel pinned next to the 3D view: it sticks under the
stepper bar and scrolls internally, so 3D + parameters stay visible together.
No overscroll-behavior: when the panel hits its top/bottom, the scroll chains
to the page so the user can keep scrolling up to the page start. */
position: sticky;
top: calc(var(--header-height) + 58px);
align-self: flex-start;
max-height: calc(100vh - var(--header-height) - 66px);
overflow-y: auto;
}
}
+16 -5
View File
@@ -1,6 +1,15 @@
import { Link } from 'react-router-dom';
import SectionNav from '../components/SectionNav';
import styles from './Home.module.css';
const HOME_SECTIONS = [
{ id: 'pourquoi', label: 'Pourquoi SIGNATURE' },
{ id: 'fonctionnement', label: 'Comment ça marche' },
{ id: 'systeme', label: 'Le système modulaire' },
{ id: 'tarifs', label: 'Tarifs' },
{ id: 'services', label: 'Services optionnels' }
];
export default function Home() {
return (
<div className={styles.home}>
@@ -12,7 +21,9 @@ export default function Home() {
<Link to="/configurateur" className={styles.cta}>Configurer mon armoire</Link>
</section>
<section className={styles.section}>
<SectionNav sections={HOME_SECTIONS} />
<section id="pourquoi" className={styles.section}>
<h2>Pourquoi SIGNATURE</h2>
<ul className={styles.features}>
<li><strong>Blocs modulaires</strong> — Penderie, rayonnage, mixtes ; assemblez selon vos besoins.</li>
@@ -21,7 +32,7 @@ export default function Home() {
</ul>
</section>
<section className={styles.section}>
<section id="fonctionnement" className={styles.section}>
<h2>Comment ça marche</h2>
<ol className={styles.steps}>
<li>Configurez en ligne (dimensions, blocs, portes, finitions).</li>
@@ -31,20 +42,20 @@ export default function Home() {
</ol>
</section>
<section className={styles.section}>
<section id="systeme" className={styles.section}>
<h2>Le système modulaire</h2>
<p className={styles.muted}>
Armoire composée de blocs juxtaposés : penderie, rayonnage, blocs mixtes. Chaque bloc est paramétrable en largeur. Portes battantes ou coulissantes selon la largeur.
</p>
</section>
<section className={styles.section}>
<section id="tarifs" className={styles.section}>
<h2>Tarifs</h2>
<p className={styles.price}>À partir de CHF 3'900.–</p>
<p className={styles.muted}>Prix indicatif pour une configuration type. Le configurateur affiche le prix exact selon vos choix.</p>
</section>
<section className={styles.section}>
<section id="services" className={styles.section}>
<h2>Services optionnels</h2>
<ul className={styles.features}>
<li>Prise de mesure sur site</li>
+195
View File
@@ -0,0 +1,195 @@
import { Link, useLocation } from 'react-router-dom';
import styles from './Legal.module.css';
const COMPANY = {
name: 'SIGNATURE',
legalName: 'SIGNATURE Sàrl',
address: 'Rue de l\u2019Exemple 1, 1000 Lausanne, Suisse',
email: '[email protected]',
phone: '+41 21 000 00 00',
ide: 'CHE-000.000.000',
director: 'Direction SIGNATURE'
};
const SECTIONS = [
{ id: 'mentions', path: '/mentions-legales', label: 'Mentions légales' },
{ id: 'confidentialite', path: '/confidentialite', label: 'Politique de confidentialité' },
{ id: 'cgv', path: '/conditions-generales', label: 'Conditions générales' },
{ id: 'reglements', path: '/reglements', label: 'Règlements' }
];
function sectionFromPath(pathname) {
const match = SECTIONS.find((s) => s.path === pathname);
return match ? match.id : 'mentions';
}
export default function Legal() {
const location = useLocation();
const active = sectionFromPath(location.pathname);
return (
<div className={styles.page}>
<aside className={styles.sidebar}>
<h1 className={styles.pageTitle}>Informations légales</h1>
<nav className={styles.nav}>
{SECTIONS.map((s) => (
<Link
key={s.id}
to={s.path}
className={`${styles.navLink} ${active === s.id ? styles.navLinkActive : ''}`}
>
{s.label}
</Link>
))}
</nav>
</aside>
<article className={styles.content}>
{active === 'mentions' && <Mentions />}
{active === 'confidentialite' && <Confidentialite />}
{active === 'cgv' && <ConditionsGenerales />}
{active === 'reglements' && <Reglements />}
<p className={styles.updated}>Dernière mise à jour : {new Date().toLocaleDateString('fr-CH')}</p>
</article>
</div>
);
}
function Mentions() {
return (
<section className={styles.section}>
<h2>Mentions légales / Impressum</h2>
<p>
Le présent site et la plateforme de configuration sont édités par {COMPANY.legalName}.
</p>
<h3>Éditeur</h3>
<ul className={styles.list}>
<li><strong>Raison sociale :</strong> {COMPANY.legalName}</li>
<li><strong>Adresse :</strong> {COMPANY.address}</li>
<li><strong>Courriel :</strong> {COMPANY.email}</li>
<li><strong>Téléphone :</strong> {COMPANY.phone}</li>
<li><strong>N° IDE :</strong> {COMPANY.ide}</li>
<li><strong>Responsable de la publication :</strong> {COMPANY.director}</li>
</ul>
<h3>Hébergement</h3>
<p>
L\u2019application est hébergée sur une infrastructure sécurisée. Les coordonnées de
l\u2019hébergeur peuvent être obtenues sur demande à l\u2019adresse {COMPANY.email}.
</p>
<h3>Propriété intellectuelle</h3>
<p>
L\u2019ensemble des contenus (textes, visuels, modèles 3D, configurateur, marques) est
protégé. Toute reproduction sans autorisation préalable est interdite.
</p>
</section>
);
}
function Confidentialite() {
return (
<section className={styles.section}>
<h2>Politique de confidentialité</h2>
<p>
{COMPANY.legalName} traite les données personnelles conformément à la Loi fédérale sur la
protection des données (nLPD) et, le cas échéant, au RGPD.
</p>
<h3>Données collectées</h3>
<ul className={styles.list}>
<li>Données d\u2019identification (nom, prénom, courriel, téléphone) lors d\u2019une demande de devis ou commande.</li>
<li>Données de configuration des projets (dimensions, finitions, options).</li>
<li>Données techniques de connexion (journaux, type de navigateur) à des fins de sécurité.</li>
</ul>
<h3>Finalités</h3>
<ul className={styles.list}>
<li>Établissement des devis, traitement et suivi des commandes.</li>
<li>Transmission des bons de commande aux fournisseurs / à l\u2019usine de production.</li>
<li>Amélioration du configurateur et support client.</li>
</ul>
<h3>Conservation</h3>
<p>
Les données sont conservées pour la durée nécessaire au traitement de la relation
commerciale et aux obligations légales (notamment comptables).
</p>
<h3>Vos droits</h3>
<p>
Vous disposez d\u2019un droit d\u2019accès, de rectification, d\u2019effacement et d\u2019opposition.
Pour l\u2019exercer, écrivez à {COMPANY.email}.
</p>
<h3>Cookies</h3>
<p>
La plateforme utilise des cookies strictement nécessaires à son fonctionnement (session,
préférences). Aucun cookie publicitaire tiers n\u2019est déposé sans consentement.
</p>
</section>
);
}
function ConditionsGenerales() {
return (
<section className={styles.section}>
<h2>Conditions générales de vente (CGV)</h2>
<h3>1. Objet</h3>
<p>
Les présentes conditions régissent la vente de mobilier sur mesure configuré via la
plateforme {COMPANY.name}.
</p>
<h3>2. Devis et commande</h3>
<p>
Toute configuration validée donne lieu à un devis. La commande est ferme après
confirmation écrite et, le cas échéant, versement d\u2019un acompte.
</p>
<h3>3. Prix</h3>
<p>
Les prix sont indiqués en francs suisses (CHF), TVA en sus au taux en vigueur. Les
produits étant fabriqués sur mesure, ils ne sont ni repris ni échangés.
</p>
<h3>4. Délais et livraison</h3>
<p>
Les délais sont communiqués à titre indicatif. {COMPANY.legalName} ne saurait être tenue
responsable des retards dus aux fournisseurs ou à des cas de force majeure.
</p>
<h3>5. Garantie</h3>
<p>
Les produits bénéficient de la garantie légale pour les défauts de conformité. Les
dimensions sur mesure relèvent de la responsabilité du client lors de la validation.
</p>
<h3>6. Droit applicable</h3>
<p>
Les présentes CGV sont soumises au droit suisse. Tout litige relève des tribunaux
compétents du siège de {COMPANY.legalName}.
</p>
</section>
);
}
function Reglements() {
return (
<section className={styles.section}>
<h2>Règlements et conformité</h2>
<h3>Matériaux et émissions</h3>
<p>
Les panneaux utilisés répondent aux exigences d\u2019émission de formaldéhyde (classe E1 /
CARB2) conformément à l\u2019ordonnance sur les produits chimiques (OChim).
</p>
<h3>Déclaration du bois (ODoB)</h3>
<p>
Conformément à l\u2019ordonnance sur la déclaration concernant le bois et les produits en
bois, l\u2019essence et la provenance sont indiquées sur la facture lorsque requis.
</p>
<h3>Indication des prix (OIP)</h3>
<p>
Les prix affichés tiennent compte de l\u2019ordonnance sur l\u2019indication des prix, TVA
incluse lorsque applicable.
</p>
<h3>Sécurité des paiements</h3>
<p>
Les paiements en ligne éventuels transitent par une passerelle certifiée PCI-DSS. Aucune
donnée bancaire n\u2019est hébergée par {COMPANY.legalName}.
</p>
<p className={styles.note}>
Ce contenu est fourni à titre informatif et doit être validé par votre conseil juridique
avant publication définitive.
</p>
</section>
);
}
+99
View File
@@ -0,0 +1,99 @@
.page {
display: grid;
grid-template-columns: 260px 1fr;
gap: 2rem;
padding: 2rem 0 3rem;
align-items: start;
}
.pageTitle {
font-size: 1.1rem;
font-weight: 700;
margin: 0 0 1rem;
color: var(--primary, #111827);
}
.sidebar {
position: sticky;
top: calc(var(--header-height) + 1.5rem);
}
.nav {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.navLink {
padding: 0.55rem 0.75rem;
border-radius: 8px;
color: var(--text-muted, #6b7280);
font-weight: 500;
text-decoration: none;
}
.navLink:hover {
background: var(--bg-card, #f3f4f6);
color: var(--primary, #111827);
}
.navLinkActive {
background: var(--bg-card, #eef2ff);
color: var(--primary, #111827);
font-weight: 600;
}
.content {
max-width: 760px;
line-height: 1.6;
color: var(--text, #1f2937);
}
.section h2 {
font-size: 1.5rem;
margin: 0 0 1rem;
}
.section h3 {
font-size: 1.05rem;
margin: 1.5rem 0 0.5rem;
}
.section p {
margin: 0 0 0.75rem;
}
.list {
margin: 0 0 1rem;
padding-left: 1.25rem;
}
.list li {
margin-bottom: 0.35rem;
}
.note {
margin-top: 1.5rem;
padding: 0.75rem 1rem;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 8px;
font-size: 0.85rem;
color: #9a3412;
}
.updated {
margin-top: 2rem;
font-size: 0.8rem;
color: var(--text-muted, #9ca3af);
}
@media (max-width: 720px) {
.page {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
}
}
+177 -59
View File
@@ -6,31 +6,97 @@ import {
fetchSuppliers, createSupplier, updateSupplier, deleteSupplier,
fetchEngineeringRules, createEngineeringRule, updateEngineeringRule, deleteEngineeringRule
} from '../api/client';
import Icon from '../components/ui/Icon';
import SectionNav from '../components/SectionNav';
import styles from './Settings.module.css';
// Human-readable labels for settings keys
const SETTINGS_META = [
{ key: 'kit_preparation_fee_chf', label: 'Forfait préparation kit (CHF)', type: 'number', min: 0, step: 10, unit: 'CHF' },
{ key: 'panel_thickness_mm', label: 'Épaisseur panneaux', type: 'number', min: 1, step: 1, unit: 'mm' },
{ key: 'plinth_height_min_mm', label: 'Hauteur plinthe minimale', type: 'number', min: 0, step: 5, unit: 'mm' },
{ key: 'plinth_height_max_mm', label: 'Hauteur plinthe maximale', type: 'number', min: 0, step: 5, unit: 'mm' },
{ key: 'sliding_doors_min_width_mm', label: 'Largeur min. portes coulissantes', type: 'number', min: 0, step: 50, unit: 'mm' },
{ key: 'allow_sliding_doors', label: 'Autoriser portes coulissantes (1/0)', type: 'number', min: 0, step: 1, unit: '' },
{ key: 'sanitary_gap_mm', label: 'Vide sanitaire niche', type: 'number', min: 0, step: 1, unit: 'mm' },
{ key: 'drawer_depth_min_mm', label: 'Tiroir profondeur min', type: 'number', min: 0, step: 10, unit: 'mm' },
{ key: 'drawer_depth_max_mm', label: 'Tiroir profondeur max', type: 'number', min: 0, step: 10, unit: 'mm' },
{ key: 'drawer_depth_step_mm', label: 'Tiroir pas profondeur', type: 'number', min: 1, step: 1, unit: 'mm' },
{ key: 'drawer_back_gap_mm', label: 'Vide sanitaire derrière tiroir', type: 'number', min: 0, step: 1, unit: 'mm' },
{ key: 'drawer_hidden_handle_reduction_mm', label: 'Réduction profondeur tiroir caché', type: 'number', min: 0, step: 1, unit: 'mm' },
{ key: 'standard_handle_axis_mm', label: 'Axe perçage poignée standard', type: 'number', min: 0, step: 10, unit: 'mm' },
{ key: 'standard_handle_tolerance_mm', label: 'Tolérance axe poignée', type: 'number', min: 0, step: 10, unit: 'mm' },
{ key: 'groove_width_mm', label: 'Rainure largeur', type: 'number', min: 0, step: 0.5, unit: 'mm' },
{ key: 'groove_depth_mm', label: 'Rainure profondeur', type: 'number', min: 0, step: 0.5, unit: 'mm' },
{ key: 'groove_edge_offset_mm', label: 'Rainure distance au bord', type: 'number', min: 0, step: 0.5, unit: 'mm' },
{ key: 'niche_clearance_multiplier', label: 'Multiplicateur jeu niche', type: 'number', min: 1, step: 0.5, unit: '' },
{ key: 'min_clearance_mm', label: 'Jeu minimum', type: 'number', min: 0, step: 1, unit: 'mm' },
const SETTINGS_SECTIONS = [
{ id: 'parametres-generaux', label: 'Paramètres généraux' },
{ id: 'regles', label: 'Règles APEX 4.0' },
{ id: 'email', label: 'Email (SMTP)' },
{ id: 'fournisseurs', label: 'Fournisseurs' }
];
const SETTINGS_GROUPS = [
{
id: 'pricing',
title: 'Tarification',
description: 'Frais fixes ajoutés au devis.',
icon: 'coins',
fields: [
{ key: 'kit_preparation_fee_chf', label: 'Forfait préparation kit', hint: 'Ajouté une fois par commande', min: 0, step: 10, unit: 'CHF' },
],
},
{
id: 'panels',
title: 'Panneaux & plinthe',
description: 'Matière de base et socle du meuble.',
icon: 'layers',
fields: [
{ key: 'panel_thickness_mm', label: 'Épaisseur panneaux', hint: 'Épaisseur standard des caissons', min: 1, step: 1, unit: 'mm' },
{ key: 'plinth_height_min_mm', label: 'Plinthe — hauteur min', min: 0, step: 5, unit: 'mm' },
{ key: 'plinth_height_max_mm', label: 'Plinthe — hauteur max', min: 0, step: 5, unit: 'mm' },
],
},
{
id: 'sliding',
title: 'Portes coulissantes',
description: 'Disponibilité et contraintes des portes coulissantes.',
icon: 'sliding',
fields: [
{ key: 'allow_sliding_doors', label: 'Autoriser les portes coulissantes', hint: 'Affiche l’option dans le configurateur', kind: 'toggle' },
{ key: 'sliding_doors_min_width_mm', label: 'Largeur minimale', hint: 'En dessous, l’option est masquée', min: 0, step: 50, unit: 'mm' },
],
},
{
id: 'drawers',
title: 'Tiroirs',
description: 'Plage de profondeurs et réductions appliquées.',
icon: 'drawer',
fields: [
{ key: 'drawer_depth_min_mm', label: 'Profondeur min', min: 0, step: 10, unit: 'mm' },
{ key: 'drawer_depth_max_mm', label: 'Profondeur max', min: 0, step: 10, unit: 'mm' },
{ key: 'drawer_depth_step_mm', label: 'Pas de profondeur', hint: 'Incrément entre longueurs de coulisses', min: 1, step: 1, unit: 'mm' },
{ key: 'drawer_back_gap_mm', label: 'Vide arrière', hint: 'Espace laissé derrière le tiroir', min: 0, step: 1, unit: 'mm' },
{ key: 'drawer_hidden_handle_reduction_mm', label: 'Réduction tiroir caché', hint: 'Soustraite pour un tiroir intérieur', min: 0, step: 1, unit: 'mm' },
],
},
{
id: 'handles',
title: 'Poignées',
description: 'Position standard du perçage.',
icon: 'handle',
fields: [
{ key: 'standard_handle_axis_mm', label: 'Axe de perçage standard', hint: 'Hauteur depuis le sol', min: 0, step: 10, unit: 'mm' },
{ key: 'standard_handle_tolerance_mm', label: 'Tolérance axe', hint: 'Écart accepté autour de l’axe', min: 0, step: 10, unit: 'mm' },
],
},
{
id: 'grooves',
title: 'Rainures (fond)',
description: 'Usinage de la rainure de panneau arrière.',
icon: 'groove',
fields: [
{ key: 'groove_width_mm', label: 'Largeur', min: 0, step: 0.5, unit: 'mm' },
{ key: 'groove_depth_mm', label: 'Profondeur', min: 0, step: 0.5, unit: 'mm' },
{ key: 'groove_edge_offset_mm', label: 'Distance au bord', min: 0, step: 0.5, unit: 'mm' },
],
},
{
id: 'clearance',
title: 'Niche & jeux',
description: 'Jeux de pose entre le meuble et la niche.',
icon: 'clearance',
fields: [
{ key: 'sanitary_gap_mm', label: 'Vide sanitaire niche', min: 0, step: 1, unit: 'mm' },
{ key: 'min_clearance_mm', label: 'Jeu minimum', min: 0, step: 1, unit: 'mm' },
{ key: 'niche_clearance_multiplier', label: 'Multiplicateur jeu niche', hint: 'Appliqué au jeu minimum', min: 1, step: 0.5, unit: '×' },
],
},
];
const ALL_SETTING_KEYS = SETTINGS_GROUPS.flatMap(g => g.fields.map(f => f.key));
const SUPPLIER_TYPES = [
{ value: 'panels', label: 'Panneaux / Matériaux' },
{ value: 'hardware', label: 'Quincaillerie' },
@@ -90,6 +156,9 @@ export default function Settings() {
// ─── App Settings ──────────────────────────────────────────────────────────
const isSettingDirty = key => String(settingsDraft[key] ?? '') !== String(settings[key] ?? '');
const dirtyCount = ALL_SETTING_KEYS.filter(isSettingDirty).length;
function handleSettingChange(key, value) {
setSettingsDraft(prev => ({ ...prev, [key]: value }));
}
@@ -252,57 +321,106 @@ export default function Settings() {
return (
<div className={styles.page}>
<div className={styles.header}>
<h1>Paramètres</h1>
</div>
{loading && <p className={styles.muted}>Chargement…</p>}
{error && <p className={styles.error}>{error}</p>}
{!loading && !error && (
<>
<SectionNav sections={SETTINGS_SECTIONS} />
{/* ── App Settings ─────────────────────────────────────── */}
<section className={styles.section}>
<section id="parametres-generaux" className={styles.section}>
<h2>Paramètres généraux</h2>
<form onSubmit={handleSettingsSave} className={styles.settingsForm}>
<div className={styles.settingsGrid}>
{SETTINGS_META.map(({ key, label, type, min, step, unit }) => (
<div className={styles.field} key={key}>
<label className={styles.label} htmlFor={`setting-${key}`}>
{label}
{unit && <span className={styles.unit}>{unit}</span>}
</label>
<input
id={`setting-${key}`}
className={styles.input}
type={type}
min={min}
step={step}
value={settingsDraft[key] ?? ''}
onChange={e => handleSettingChange(key, e.target.value)}
/>
</div>
<form onSubmit={handleSettingsSave}>
<div className={styles.groupsGrid}>
{SETTINGS_GROUPS.map(group => (
<fieldset key={group.id} className={styles.groupCard}>
<legend className={styles.srOnly}>{group.title}</legend>
<div className={styles.groupHeader} title={group.description}>
<span className={styles.groupIcon}>
<Icon name={group.icon} size={18} />
</span>
<h3 className={styles.groupTitle}>{group.title}</h3>
</div>
<div className={styles.groupFields}>
{group.fields.map(({ key, label, hint, kind, min, step, unit }) => {
const id = `setting-${key}`;
const dirty = isSettingDirty(key);
if (kind === 'toggle') {
const checked = Number(settingsDraft[key]) === 1;
return (
<label key={key} htmlFor={id} title={hint} className={`${styles.row} ${styles.toggleRow} ${dirty ? styles.fieldDirty : ''}`}>
<span className={styles.label}>{label}</span>
<input
id={id}
type="checkbox"
className={styles.toggleInput}
checked={checked}
onChange={e => handleSettingChange(key, e.target.checked ? '1' : '0')}
/>
<span className={styles.toggle} aria-hidden="true" />
</label>
);
}
return (
<div key={key} title={hint} className={`${styles.row} ${dirty ? styles.fieldDirty : ''}`}>
<label className={styles.label} htmlFor={id}>{label}</label>
<div className={styles.inputWrap}>
<input
id={id}
className={styles.input}
type="number"
min={min}
step={step}
value={settingsDraft[key] ?? ''}
onChange={e => handleSettingChange(key, e.target.value)}
/>
{unit && <span className={styles.inputUnit}>{unit}</span>}
</div>
</div>
);
})}
</div>
</fieldset>
))}
</div>
<div className={styles.formActions}>
<button type="submit" className={styles.btnPrimary} disabled={settingsSaving}>
{settingsSaving ? 'Sauvegarde…' : 'Sauvegarder'}
</button>
<button type="button" className={styles.btnGhost} onClick={handleSettingsReset}>
Annuler
</button>
{settingsMsg && (
<span className={settingsMsg.ok ? styles.success : styles.error}>
{settingsMsg.text}
</span>
)}
<div className={`${styles.saveBar} ${dirtyCount > 0 ? styles.saveBarActive : ''}`}>
<span className={styles.saveBarStatus}>
{settingsMsg ? (
<span className={settingsMsg.ok ? styles.success : styles.error}>{settingsMsg.text}</span>
) : dirtyCount > 0 ? (
`${dirtyCount} modification${dirtyCount > 1 ? 's' : ''} non sauvegardée${dirtyCount > 1 ? 's' : ''}`
) : (
'Aucune modification'
)}
</span>
<div className={styles.formActions}>
<button
type="button"
className={styles.btnGhost}
onClick={handleSettingsReset}
disabled={dirtyCount === 0 || settingsSaving}
>
<Icon name="undo" size={14} /> Annuler
</button>
<button
type="submit"
className={styles.btnPrimary}
disabled={settingsSaving || dirtyCount === 0}
>
<Icon name="save" size={14} /> {settingsSaving ? 'Sauvegarde…' : 'Sauvegarder'}
</button>
</div>
</div>
</form>
</section>
{/* ── Engineering Rules ───────────────────────────────────── */}
<section className={styles.section}>
<section id="regles" className={styles.section}>
<div className={styles.sectionHeader}>
<h2>Règles APEX 4.0 (ingénierie, ergonomie, légalité)</h2>
<button type="button" className={styles.btnPrimary} onClick={openNewRule}>
@@ -442,7 +560,7 @@ export default function Settings() {
</section>
{/* ── SMTP / Email info ─────────────────────────────────── */}
<section className={styles.section}>
<section id="email" className={styles.section}>
<h2>Configuration email (SMTP)</h2>
<p className={styles.muted}>
Ces valeurs sont définies dans le fichier <code>.env</code> du serveur. Redémarrez le serveur après modification.
@@ -465,7 +583,7 @@ export default function Settings() {
</section>
{/* ── Suppliers ─────────────────────────────────────────── */}
<section className={styles.section}>
<section id="fournisseurs" className={styles.section}>
<div className={styles.sectionHeader}>
<h2>Fournisseurs</h2>
<button type="button" className={styles.btnPrimary} onClick={openNewSupplier}>
+237 -1
View File
@@ -1,5 +1,5 @@
.page {
padding: 1.5rem 0 4rem;
padding: 0 0 4rem;
}
.header {
@@ -121,6 +121,242 @@
border-color: var(--accent);
}
/* ── Settings Groups ──────────────────────────── */
.groupsGrid {
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.groupCard {
margin: 0;
min-width: 0;
border: none;
border-bottom: 1px solid var(--border);
padding: 0.875rem 1.25rem;
display: flex;
align-items: center;
gap: 1.5rem;
transition: background 0.15s;
}
.groupCard:last-child {
border-bottom: none;
}
.groupCard:focus-within {
background: var(--bg);
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.groupHeader {
flex: 0 0 220px;
display: flex;
align-items: center;
gap: 0.75rem;
}
.groupIcon {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--bg);
color: var(--accent);
}
.section .groupTitle {
font-size: 0.9375rem;
font-weight: 600;
margin: 0;
white-space: nowrap;
}
.groupDesc {
margin: 0;
font-size: 0.8125rem;
color: var(--text-muted);
line-height: 1.35;
}
.groupFields {
flex: 1;
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.625rem 1.75rem;
}
.row {
display: flex;
align-items: center;
gap: 0.625rem;
}
.row .label {
white-space: nowrap;
font-weight: 400;
align-items: center;
}
.inputWrap {
position: relative;
flex: 0 0 120px;
}
@media (max-width: 760px) {
.groupCard {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.groupHeader {
flex-basis: auto;
}
}
.inputWrap .input {
padding-top: 0.375rem;
padding-bottom: 0.375rem;
text-align: right;
}
.inputWrap .input {
padding-right: 2.5rem;
}
.inputUnit {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
font-size: 0.75rem;
color: var(--text-muted);
pointer-events: none;
}
.fieldDirty .input {
border-color: var(--accent);
background: #fffbeb;
}
.fieldDirty .label::after {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
align-self: center;
}
/* Toggle switch */
.toggleRow {
cursor: pointer;
position: relative;
}
.toggleInput {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.toggle {
flex-shrink: 0;
position: relative;
width: 40px;
height: 22px;
border-radius: 999px;
background: #cbd5e1;
transition: background 0.15s;
}
.toggle::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
transition: transform 0.15s;
}
.toggleInput:checked + .toggle {
background: var(--accent);
}
.toggleInput:checked + .toggle::after {
transform: translateX(18px);
}
.toggleInput:focus-visible + .toggle {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Sticky save bar */
.saveBar {
position: sticky;
bottom: 1rem;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-top: 1rem;
padding: 0.75rem 1rem;
background: #fff;
border: 1px solid var(--border);
border-radius: 10px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.saveBarActive {
border-color: var(--accent);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.1);
}
.saveBarStatus {
font-size: 0.875rem;
color: var(--text-muted);
}
.saveBar .btnPrimary,
.saveBar .btnGhost {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.btnGhost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── SMTP Info Grid ───────────────────────────── */
.smtpGrid {