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
+2 -1
View File
@@ -1,5 +1,5 @@
node_modules/ node_modules/
server/data/ server/data/*
.env .env
.claude/ .claude/
*.docx *.docx
@@ -7,6 +7,7 @@ server/data/
~WRL*.tmp ~WRL*.tmp
*.db *.db
*.sqlite *.sqlite
!server/data/configurateur.db
.DS_Store .DS_Store
# Build output # Build output
+5
View File
@@ -8,6 +8,7 @@ import Login from './pages/Login';
import Admin from './pages/Admin'; import Admin from './pages/Admin';
import Materials from './pages/Materials'; import Materials from './pages/Materials';
import Settings from './pages/Settings'; import Settings from './pages/Settings';
import Legal from './pages/Legal';
export default function App() { export default function App() {
return ( return (
@@ -16,6 +17,10 @@ export default function App() {
<Layout> <Layout>
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <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="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
<Route path="/configurateur" element={<ProtectedRoute><Configurator /></ProtectedRoute>} /> <Route path="/configurateur" element={<ProtectedRoute><Configurator /></ProtectedRoute>} />
<Route path="/materiaux" element={<ProtectedRoute><Materials /></ProtectedRoute>} /> <Route path="/materiaux" element={<ProtectedRoute><Materials /></ProtectedRoute>} />
+102
View File
@@ -117,6 +117,58 @@ export async function calculatePrice(config) {
return res.json(); 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() { export async function fetchOrders() {
const res = await fetch(`${API_BASE}/orders`); const res = await fetch(`${API_BASE}/orders`);
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
@@ -245,6 +297,46 @@ export async function fetchHaefeleMaterials({ search = '', active = null, page =
return res.json(); 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 // Formex Materials CRUD
export async function fetchFormexMaterials({ search = '', collection = '', category = '', supplier = '', active = null, page = 1, limit = 25 } = {}) { 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(); 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) { export async function deleteFormexMaterial(id) {
const res = await fetch(`${API_BASE}/formex/materials/${id}`, { const res = await fetch(`${API_BASE}/formex/materials/${id}`, {
method: 'DELETE' method: 'DELETE'
+190 -46
View File
@@ -13,7 +13,7 @@ import {
Shape, Shape,
Vector3 Vector3
} from 'three'; } from 'three';
import { OrbitControls } from '@react-three/drei'; import { Html, OrbitControls } from '@react-three/drei';
import { import {
buildTopBottomPlanHoleSpecsMm, buildTopBottomPlanHoleSpecsMm,
buildVerticalPanelIxconnectFaceHoleSpecsMm, buildVerticalPanelIxconnectFaceHoleSpecsMm,
@@ -21,9 +21,13 @@ import {
buildFinishHabillageHoleSpecsMm, buildFinishHabillageHoleSpecsMm,
buildDoorHingeCupHoleSpecsMm, buildDoorHingeCupHoleSpecsMm,
buildDrawerReinforcementHoleSpecsMm, buildDrawerReinforcementHoleSpecsMm,
buildDrawerRenfortPanelIxconnectSpecsMm buildDrawerRenfortPanelIxconnectSpecsMm,
buildLegraboxRailHoleSpecsMm,
buildLegraboxFacadeInsertaSpecsMm,
buildAxiloFootHoleSpecsMm,
buildSystem32HoleSpecsMm
} from '@shared/cncPanelHoles.js'; } 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'; import styles from './Cnc3DViewer.module.css';
/** Matches Canvas camera defaults so switching items without a saved view resets correctly. */ /** Matches Canvas camera defaults so switching items without a saved view resets correctly. */
@@ -118,7 +122,7 @@ function PersistedOrbitControls({ storageKey }) {
makeDefault makeDefault
enablePan enablePan
enableZoom enableZoom
zoomSpeed={-0.6} zoomSpeed={0.9}
enableRotate enableRotate
rotateSpeed={0.6} rotateSpeed={0.6}
screenSpacePanning screenSpacePanning
@@ -254,9 +258,12 @@ function AxisAlignAnimator({ alignId, alignAxis, fallbackDistance }) {
function parseRawSize(gcode) { function parseRawSize(gcode) {
const lines = (gcode || '').split(/\r?\n/); const lines = (gcode || '').split(/\r?\n/);
let fromRaw = null;
for (const raw of lines) { for (const raw of lines) {
const line = String(raw || ''); 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 mx = line.match(/X=([0-9]+(\.[0-9]+)?)/i);
const my = line.match(/Y=([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); 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 heightM = (my ? Number(my[1]) : 0) / 1000;
const thickM = (mt ? Number(mt[1]) : 16) / 1000; const thickM = (mt ? Number(mt[1]) : 16) / 1000;
if (widthM > 0 && heightM > 0) { 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) { function parseHoles(gcode) {
@@ -301,9 +331,10 @@ function planHoleSpecsToSceneHoles(piece, rawSize, partIndex = 0) {
if (!piece?.machining || !rawSize) return []; if (!piece?.machining || !rawSize) return [];
const wMm = rawSize.widthM * 1000; const wMm = rawSize.widthM * 1000;
const hMm = rawSize.heightM * 1000; const hMm = rawSize.heightM * 1000;
const mirrorDepthAxis = ['dessus', 'fond'].includes(piece?.elementType || '');
const mapSpec = (spec) => ({ const mapSpec = (spec) => ({
xM: spec.xMm / 1000, xM: spec.xMm / 1000,
yM: spec.yMm / 1000, yM: (mirrorDepthAxis ? hMm - spec.yMm : spec.yMm) / 1000,
dM: spec.diameterMm / 1000, dM: spec.diameterMm / 1000,
zM: spec.depthMm / 1000, zM: spec.depthMm / 1000,
type: spec.type, type: spec.type,
@@ -312,12 +343,16 @@ function planHoleSpecsToSceneHoles(piece, rawSize, partIndex = 0) {
}); });
return [ return [
...buildTopBottomPlanHoleSpecsMm(piece, wMm, hMm).map(mapSpec), ...buildTopBottomPlanHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
...buildAxiloFootHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
...buildSystem32HoleSpecsMm(piece, wMm, hMm).map(mapSpec),
...buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, wMm, hMm).map(mapSpec), ...buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
...buildShelfMobileHoleSpecsMm(piece, wMm, hMm).map(mapSpec), ...buildShelfMobileHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
...buildFinishHabillageHoleSpecsMm(piece, wMm, hMm).map(mapSpec), ...buildFinishHabillageHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
...buildDoorHingeCupHoleSpecsMm(piece, wMm, hMm).map(mapSpec), ...buildDoorHingeCupHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
...buildDrawerReinforcementHoleSpecsMm(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 ( return (
h.type === 'ixconnect_inbus' && h.type === 'ixconnect_inbus' &&
(etPiece === 'dessus' || etPiece === 'fond') && (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. */ /** Couleur des faces usinées (rainure) — pas le bois apparent extérieur. */
const GROOVE_CUT_FACE_COLOR = '#0d9488'; const GROOVE_CUT_FACE_COLOR = '#0d9488';
@@ -443,7 +506,6 @@ function getBackGrooveSceneParams(rawSize, piece) {
const gw = (m.width ?? GROOVE.back.width) / 1000; const gw = (m.width ?? GROOVE.back.width) / 1000;
const gd = (m.depth ?? GROOVE.back.depth) / 1000; const gd = (m.depth ?? GROOVE.back.depth) / 1000;
const axis = (m.axisFromPanelEdge ?? m.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge) / 1000; const axis = (m.axisFromPanelEdge ?? m.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge) / 1000;
const endInsetM = getBackGrooveInnerEndInsetFromBackEdgeMm() / 1000;
const eps = 0.00025; const eps = 0.00025;
/* /*
@@ -454,9 +516,8 @@ function getBackGrooveSceneParams(rawSize, piece) {
*/ */
if (orient === 'vertical') { if (orient === 'vertical') {
const xC = w - axis; 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. // Side-panel back grooves run through the complete panel length.
const extraEndInsetM = piece?.elementType === 'cotes' ? (Number(piece?.thicknessMm) || 19) / 1000 : 0; const zLen = h;
const zLen = Math.max(0.05, h - 2 * (endInsetM + extraEndInsetM));
const yTop = t; const yTop = t;
const yBot = t - gd; const yBot = t - gd;
const min = new Vector3(xC - gw / 2, yBot - eps, h / 2 - zLen / 2); 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 }; 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 et = piece.elementType || '';
const opensFromBottom = et === 'dessus' || et === 'bandeauHaut'; const opensFromBottom = et === 'dessus' || et === 'bandeauHaut';
const y0 = 0.0002; const y0 = 0.0002;
@@ -663,9 +726,12 @@ function buildPanelHoleDiscardUniforms(holes, rawSize, piece) {
const rawDepth = Math.max(0, Number(h.zM) || 0); const rawDepth = Math.max(0, Number(h.zM) || 0);
let depth = Math.min(t * 0.999, Math.max(0.0008, rawDepth)); let depth = Math.min(t * 0.999, Math.max(0.0008, rawDepth));
if (h.type === 'tourillon') depth = Math.min(depth, 0.013); if (h.type === 'tourillon') depth = Math.min(depth, 0.013);
const sepFromBottom = ixconnectInbusFaceFromBottomThick(etPiece, h) const dessusDir = etPiece === 'dessus' ? dessusFaceHoleFromBottom(h) : null;
const sepFromBottom = dessusDir != null
? dessusDir
: ixconnectInbusFaceFromBottomThick(etPiece, h)
? horizontalSeparatorFaceHoleFromBottomThick(etPiece) ? horizontalSeparatorFaceHoleFromBottomThick(etPiece)
: (etPiece === 'dessus' || etPiece === 'fond' || etPiece === 'bandeauHaut') && : etPiece === 'bandeauHaut' &&
h.face === 'face' && h.face === 'face' &&
!isHorizontalPanelExteriorCoteJunction(h.xM, w) && !isHorizontalPanelExteriorCoteJunction(h.xM, w) &&
horizontalSeparatorFaceHoleFromBottomThick(etPiece); horizontalSeparatorFaceHoleFromBottomThick(etPiece);
@@ -675,9 +741,11 @@ function buildPanelHoleDiscardUniforms(holes, rawSize, piece) {
h.face === 'face' && h.face === 'face' &&
h.type === 'shelf_mobile_face'; h.type === 'shelf_mobile_face';
let fromBottom = sepFromBottom || shelfFromBottom; 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. // FACE=back désigne toujours la grande face opposée : sous-face pour les panneaux horizontaux,
// Séparateurs : renfort tiroir vers module supérieur (colonne droite) = FACE=back — grande face opposée à celle du module inférieur. // face extérieure/opposée pour les montants et séparateurs.
if ((etPiece === 'cotes' || etPiece === 'panneauxInterieurs') && h.face === 'back') fromBottom = true; 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; const y = fromBottom ? depth / 2 + 0.0002 : t - depth / 2 + 0.0002;
cx = h.xM; cx = h.xM;
cy = fromBottom ? depth / 2 + 0.0002 : t - depth / 2 + 0.0002; 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 grooveActive = grooveParams ? 1 : 0;
const hc = holeDiscard.count; const hc = holeDiscard.count;
const mc = holeDiscard.miterCount || 0; 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) => { material.onBeforeCompile = (shader) => {
shader.uniforms.uGrooveActive = { value: grooveActive }; shader.uniforms.uGrooveActive = { value: grooveActive };
shader.uniforms.uGrooveMin = { value: uMin }; shader.uniforms.uGrooveMin = { value: uMin };
@@ -899,8 +967,10 @@ function GroovedPanelMesh({ rawSize, piece, materialColor, holes }) {
roughness: 0.72, roughness: 0.72,
metalness: 0.05, metalness: 0.05,
transparent: true, transparent: true,
opacity: 0.72, opacity: 0.82,
depthWrite: false, // 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 side: DoubleSide
}); });
if (!grooveParams && holeDiscard.count === 0) return m; 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 }) { 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 * 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 && ( {rawSize && (
<GroovedPanelMesh rawSize={rawSize} piece={piece} materialColor={materialColor} holes={holes} /> <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 radius = Math.max(0.0001, Number(h.dM) / 2);
const etPiece = piece?.elementType || ''; const etPiece = piece?.elementType || '';
const wPanel = rawSize.widthM; const wPanel = rawSize.widthM;
const isInbus = h.type === 'ixconnect_inbus';
const color = const color =
h.type === 'ixconnect' h.type === 'ixconnect' || h.type === 'ixconnect_finish'
? '#7c3aed' ? '#7c3aed'
: String(h.type || '').startsWith('ixconnect_drawer') : String(h.type || '').startsWith('ixconnect_drawer')
? '#ea580c' ? '#ea580c'
: h.type === 'ixconnect_inbus' : isInbus
? '#a855f7' ? '#a855f7'
: h.type === 'ferrure' : h.type === 'ferrure'
? '#0f766e' ? '#0f766e'
@@ -1106,16 +1219,20 @@ function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, align
: h.type === 'tourillon' : h.type === 'tourillon'
? '#64748b' ? '#64748b'
: '#ea580c'; : '#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; let holeColor = color;
if ((etPiece === 'dessus' || etPiece === 'fond') && h.face === 'face') { if ((etPiece === 'dessus' || etPiece === 'fond') && h.type === 'ixconnect' && h.face !== 'end') {
if (!isHorizontalPanelExteriorCoteJunction(h.xM, wPanel) && h.type === 'ixconnect') { const isFinishSc =
holeColor = '#64748b'; 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 lip = Math.min(0.00012, Math.max(0.00003, radius * 0.06));
const innerR = Math.max(0.00005, radius - lip); const innerR = Math.max(0.00005, radius - lip);
const outerR = radius + lip; const outerR = radius + lip;
const faceHoleRenderOrder = isInbus ? 16 : 12;
const ringRenderOrder = isInbus ? 18 : 11;
if (h.face === 'end') { if (h.face === 'end') {
const w = rawSize.widthM; const w = rawSize.widthM;
@@ -1289,9 +1406,12 @@ function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, align
if (h.type === 'tourillon') depth = Math.min(depth, 0.013); if (h.type === 'tourillon') depth = Math.min(depth, 0.013);
const forceThroughSystem32 = h.type === 'system32' && etPiece !== 'cotes'; const forceThroughSystem32 = h.type === 'system32' && etPiece !== 'cotes';
const throughFace = rawDepth >= rawSize.thickM - 1e-5 || forceThroughSystem32; const throughFace = rawDepth >= rawSize.thickM - 1e-5 || forceThroughSystem32;
const sepFromBottom = ixconnectInbusFaceFromBottomThick(etPiece, h) const dessusDir = etPiece === 'dessus' ? dessusFaceHoleFromBottom(h) : null;
const sepFromBottom = dessusDir != null
? dessusDir
: ixconnectInbusFaceFromBottomThick(etPiece, h)
? horizontalSeparatorFaceHoleFromBottomThick(etPiece) ? horizontalSeparatorFaceHoleFromBottomThick(etPiece)
: (etPiece === 'dessus' || etPiece === 'fond' || etPiece === 'bandeauHaut') && : etPiece === 'bandeauHaut' &&
h.face === 'face' && h.face === 'face' &&
!isHorizontalPanelExteriorCoteJunction(h.xM, wPanel) && !isHorizontalPanelExteriorCoteJunction(h.xM, wPanel) &&
horizontalSeparatorFaceHoleFromBottomThick(etPiece); horizontalSeparatorFaceHoleFromBottomThick(etPiece);
@@ -1300,54 +1420,68 @@ function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, align
h.face === 'face' && h.face === 'face' &&
h.type === 'shelf_mobile_face'; h.type === 'shelf_mobile_face';
let fromBottom = sepFromBottom || shelfFromBottom; 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 const y = throughFace
? rawSize.thickM / 2 ? rawSize.thickM / 2
: fromBottom : fromBottom
? depth / 2 + 0.0002 ? depth / 2 + 0.0002
: rawSize.thickM - 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 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]; const ringOppRot = fromBottom ? [-Math.PI / 2, 0, 0] : [Math.PI / 2, 0, 0];
return ( return (
<group key={`hole-${i}`}> <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]} /> <cylinderGeometry args={[radius, radius, depth, 32, 1, true]} />
<meshStandardMaterial <meshStandardMaterial
color={holeColor} color={holeColor}
roughness={0.45} roughness={0.45}
metalness={0.05} metalness={isInbus ? 0.15 : 0.05}
emissive={isInbus ? holeColor : '#000000'}
emissiveIntensity={isInbus ? 0.25 : 0}
side={DoubleSide} side={DoubleSide}
depthTest depthTest
depthWrite depthWrite
/> />
</mesh> </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]} /> <ringGeometry args={[innerR, outerR, 48]} />
<meshStandardMaterial <meshStandardMaterial
color={holeColor} color={holeColor}
roughness={0.35} roughness={0.35}
metalness={0.1} metalness={0.1}
emissive={holeColor} emissive={holeColor}
emissiveIntensity={0.2} emissiveIntensity={isInbus ? 0.45 : 0.2}
depthTest={false} depthTest
depthWrite={false} depthWrite={false}
side={DoubleSide} side={DoubleSide}
polygonOffset
polygonOffsetFactor={-2}
polygonOffsetUnits={-2}
/> />
</mesh> </mesh>
{throughFace && ( {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]} /> <ringGeometry args={[innerR, outerR, 48]} />
<meshStandardMaterial <meshStandardMaterial
color={holeColor} color={holeColor}
roughness={0.35} roughness={0.35}
metalness={0.1} metalness={0.1}
emissive={holeColor} emissive={holeColor}
emissiveIntensity={0.2} emissiveIntensity={isInbus ? 0.45 : 0.2}
depthTest={false} depthTest
depthWrite={false} depthWrite={false}
side={DoubleSide} side={DoubleSide}
polygonOffset
polygonOffsetFactor={-2}
polygonOffsetUnits={-2}
/> />
</mesh> </mesh>
)} )}
@@ -1366,10 +1500,20 @@ export default function Cnc3DViewer({
materialColor = '#c4a574', materialColor = '#c4a574',
orbitStorageKey = 'default' orbitStorageKey = 'default'
}) { }) {
const rawSize = useMemo(() => parseRawSize(gcode), [gcode]); const rawSize = useMemo(() => panelSceneSize(piece, gcode), [piece, gcode]);
const holes = useMemo(() => { 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 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); return mergeHoleListsByPosition(fromG, fromPlan);
}, [gcode, piece, rawSize]); }, [gcode, piece, rawSize]);
@@ -8,6 +8,24 @@
touch-action: none; 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 { .axisBar {
position: absolute; position: absolute;
left: 8px; 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. // 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. // 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']); const depthIsHeight = new Set(['dessus', 'fond', 'habillageFinitionDessus']);
if (depthIsWidth.has(t)) return width ?? null; if (depthIsWidth.has(t)) return width ?? null;
@@ -186,10 +186,14 @@ export default function CuttingListTable({ cuttingList, onExportMprPiece, cncPro
function buildManufacturingRows(piece) { function buildManufacturingRows(piece) {
const m = piece?.machining || {}; 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 = [ const rows = [
['Dimensions finies', `${piece.widthMm} x ${piece.heightMm} x ${piece.thicknessMm} mm`], ['Dimensions finies', `${piece.widthMm} x ${piece.heightMm} x ${piece.thicknessMm} mm`],
['Dimensions brutes CNC', `${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} 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`] ['Chants', `${piece.edgeBanding?.sides || 0} côté(s) · épaisseur ${piece.edgeBanding?.thickness || 0} mm${bandingDir}`]
]; ];
if (m.system32) { 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.surfaceCell}>{piece.surfaceM2.toFixed(3)}</td>
<td className={styles.edgeCell}> <td className={styles.edgeCell}>
{piece.edgeBanding.type !== 'none' && ( {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.sides} côtés
{piece.edgeBanding.direction?.label ? ` · ${piece.edgeBanding.direction.label}` : ''}
</span> </span>
)} )}
</td> </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 { useAuth } from '../context/AuthContext';
import styles from './Layout.module.css'; import styles from './Layout.module.css';
const navClass = ({ isActive }) => (isActive ? styles.navActive : undefined);
export default function Layout({ children }) { export default function Layout({ children }) {
const { isAdmin, logout } = useAuth(); const { isAdmin, logout } = useAuth();
const navigate = useNavigate(); 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 = () => { const handleLogout = () => {
logout(); logout();
@@ -13,16 +28,16 @@ export default function Layout({ children }) {
return ( return (
<div className={styles.app}> <div className={styles.app}>
<header className={styles.header}> <header ref={headerRef} className={styles.header}>
<Link to="/" className={styles.logo}>SIGNATURE</Link> <Link to="/" className={styles.logo}>SIGNATURE</Link>
<nav> <nav>
{isAdmin ? ( {isAdmin ? (
<> <>
<Link to="/">Accueil</Link> <NavLink to="/" end className={navClass}>Accueil</NavLink>
<Link to="/configurateur">Configurateur</Link> <NavLink to="/configurateur" className={navClass}>Configurateur</NavLink>
<Link to="/materiaux">Matériaux</Link> <NavLink to="/materiaux" className={navClass}>Matériaux</NavLink>
<Link to="/admin">Admin</Link> <NavLink to="/admin" className={navClass}>Admin</NavLink>
<Link to="/parametres">Paramètres</Link> <NavLink to="/parametres" className={navClass}>Paramètres</NavLink>
<button type="button" className={styles.logoutBtn} onClick={handleLogout}>Déconnexion</button> <button type="button" className={styles.logoutBtn} onClick={handleLogout}>Déconnexion</button>
</> </>
) : ( ) : (
@@ -33,6 +48,15 @@ export default function Layout({ children }) {
<main className={styles.main}> <main className={styles.main}>
{children} {children}
</main> </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> </div>
); );
} }
+58 -1
View File
@@ -9,8 +9,15 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 1rem 1.5rem; 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); 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 { .logo {
@@ -40,6 +47,23 @@
color: var(--primary); 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 { .header nav .logoutBtn {
background: none; background: none;
border: none; border: none;
@@ -61,3 +85,36 @@
margin: 0 auto; margin: 0 auto;
padding: 0 1.5rem; 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 API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const PLANS_TAB_KEY = 'apex.plans.tab'; 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() { function readSavedPlanTab() {
try { try {
@@ -34,6 +34,8 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId
const [activeTab, setActiveTab] = useState(readSavedPlanTab); const [activeTab, setActiveTab] = useState(readSavedPlanTab);
const [plans, setPlans] = useState(null); const [plans, setPlans] = useState(null);
const [cuttingList, setCuttingList] = useState(null); const [cuttingList, setCuttingList] = useState(null);
const [cuttingPlan, setCuttingPlan] = useState(null);
const [cuttingPlanError, setCuttingPlanError] = useState(null);
const [hardwareList, setHardwareList] = useState(null); const [hardwareList, setHardwareList] = useState(null);
const [hardwareError, setHardwareError] = useState(null); const [hardwareError, setHardwareError] = useState(null);
const [loading, setLoading] = useState(true); 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'); 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`, { const cncRes = await fetch(`${API_BASE}/api/plans/export-cnc`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -246,6 +268,21 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
setExportStatus('Archive Homag MPR (.zip) téléchargée.'); 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) { } catch (err) {
console.error('Export error:', 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: 'side', label: 'Vue de côté' },
{ id: 'top', label: 'Vue de dessus' }, { id: 'top', label: 'Vue de dessus' },
{ id: 'cutting', label: 'Liste de débitage' }, { id: 'cutting', label: 'Liste de débitage' },
{ id: 'calepinage', label: 'Calepinage' },
{ id: 'hardware', label: 'Quincaillerie' } { 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' && ( {activeTab === 'hardware' && (
<HardwareListTable <HardwareListTable
hardwareList={hardwareList} hardwareList={hardwareList}
@@ -417,6 +498,15 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId
Liste texte Liste texte
</button> </button>
)} )}
{(activeTab === 'cutting' || activeTab === 'calepinage' || activeTab === 'hardware') && (
<button
className={styles.exportBtn}
onClick={() => handleExport('excel')}
>
<Icon name="download" size={15} />
Export Excel (.xlsx)
</button>
)}
<button <button
className={styles.exportBtn} className={styles.exportBtn}
onClick={() => handleExport('pdf')} onClick={() => handleExport('pdf')}
@@ -144,6 +144,72 @@
margin: 0 auto; 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 { .planContainer {
display: flex; display: flex;
align-items: center; 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 // Default colors for different element types
const DEFAULT_COLORS = { const DEFAULT_COLORS = {
plintheBas: '#8b7355', plintheBas: '#8b7355',
plintheGauche: '#8b7355',
plintheDroite: '#8b7355',
fileur: '#8b7355', fileur: '#8b7355',
bandeauHaut: '#8b7355', bandeauHaut: '#8b7355',
champs: '#6b5344', champs: '#6b5344',
@@ -32,6 +34,8 @@ const DEFAULT_COLORS = {
// Element labels for display // Element labels for display
const ELEMENT_LABELS = { const ELEMENT_LABELS = {
plintheBas: 'Plinthe bas', plintheBas: 'Plinthe bas',
plintheGauche: 'Plinthe retour gauche',
plintheDroite: 'Plinthe retour droit',
fileur: 'Fileur', fileur: 'Fileur',
bandeauHaut: 'Bandeau haut', bandeauHaut: 'Bandeau haut',
champs: 'Champs', champs: 'Champs',
@@ -63,12 +67,27 @@ function getElementColor(elementType, colors) {
return DEFAULT_COLORS[elementType] || '#cccccc'; 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) * Get image URL for element texture (if available)
*/ */
function getElementImageUrl(elementType, colors) { function getElementImageUrl(elementType, colors) {
if (colors && colors[elementType] && colors[elementType].imageUrl) { if (colors && colors[elementType] && colors[elementType].imageUrl) {
return colors[elementType].imageUrl; return toCanvasSafeUrl(colors[elementType].imageUrl);
} }
return null; return null;
} }
@@ -197,8 +216,10 @@ function loadCroppedTexture(url) {
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh); ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
const tex = new THREE.CanvasTexture(canvas); const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping; // Clamp (don't repeat): a single swatch is stretched to fill each panel so
tex.wrapT = THREE.RepeatWrapping; // 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.colorSpace = THREE.SRGBColorSpace;
tex.minFilter = THREE.LinearMipmapLinearFilter; tex.minFilter = THREE.LinearMipmapLinearFilter;
tex.magFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter;
@@ -281,37 +302,20 @@ function useCroppedTexture(imageUrl, geometryArgs, geometryType) {
} }
/** /**
* Configure texture repeat based on geometry dimensions. * Stretch a single swatch across the whole face instead of tiling it.
* The cropped swatch represents approximately 0.3m x 0.3m of real material. *
* 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) { function configureTextureRepeat(tex /* , geometryArgs, geometryType */) {
if (!tex || !geometryArgs) return; if (!tex) return;
tex.wrapS = THREE.ClampToEdgeWrapping;
const TEXTURE_PATCH_SIZE = 0.3; // meters tex.wrapT = THREE.ClampToEdgeWrapping;
let widthM, heightM; tex.repeat.set(1, 1);
tex.offset.set(0, 0);
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)
);
tex.needsUpdate = true; 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 { useCallback, useEffect } from 'react';
import WardrobePreview from '../WardrobePreview'; import WardrobePreview from '../WardrobePreview';
import DesignTip from '../DesignTip'; 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'; import styles from './PreviewArea.module.css';
export default function PreviewArea({ wc, ui }) { export default function PreviewArea({ wc, ui }) {
const colorPickEnabled = ui.activeStep === 4 || ui.activeStep === 6; const colorPickEnabled = ui.activeStep === 4 || ui.activeStep === 6;
const infoByStep = { const infoByStep = {
1: 'Survolez les zones colorees du cadre puis glissez pour regler L/H/P.', 1: 'Glissez les poignees L/H/P : elles suivent le curseur. Echap annule, F recadre la vue.',
2: 'Tirez les separateurs pour redimensionner et glissez les rayonnages par la tranche avant.', 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.', 3: 'Activez ou masquez les portes pour verifier l ouverture.',
4: 'Choisissez un element puis appliquez sa couleur.', 4: 'Choisissez un element puis appliquez sa couleur.',
5: 'Verifiez les volumes interieurs pour valider la quincaillerie.', 5: 'Verifiez les volumes interieurs pour valider la quincaillerie.',
@@ -24,6 +25,9 @@ export default function PreviewArea({ wc, ui }) {
const out = []; const out = [];
for (let i = 0; i < mods.length; i++) { for (let i = 0; i < mods.length; i++) {
const mod = mods[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 shelfCount = Math.max(0, Number(mod?.shelves) || 0);
const positions = resolveShelfPositionsMm({ const positions = resolveShelfPositionsMm({
shelfCount, shelfCount,
@@ -31,14 +35,19 @@ export default function PreviewArea({ wc, ui }) {
plinthHeightMm: plinthMm, plinthHeightMm: plinthMm,
shelfPositionsMm: mod?.shelfPositionsMm 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).`); if (!hasStructural) out.push(`Colonne ${i + 1}: placer un rayon dans la zone structurelle (±20 cm).`);
} }
return out; 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) => { const handleSetDimension = useCallback((key, valueMm) => {
wc.setDimension(key, valueMm); wc.setDimensionWithoutUndo(key, valueMm);
}, [wc.setDimension]); }, [wc.setDimensionWithoutUndo]);
useEffect(() => { useEffect(() => {
const onKeyDown = (e) => { const onKeyDown = (e) => {
@@ -115,10 +124,13 @@ export default function PreviewArea({ wc, ui }) {
colors={wc.config.colors} colors={wc.config.colors}
modules={wc.config.modules} modules={wc.config.modules}
onElementClick={colorPickEnabled ? (elementType, label) => ui.openColorPicker(elementType, label) : undefined} onElementClick={colorPickEnabled ? (elementType, label) => ui.openColorPicker(elementType, label) : undefined}
onModuleClick={ui.handleModuleClick} onModuleClick={ui.activeStep === 2 ? ui.handleModuleClick : undefined}
onUpdateModule={wc.handleColumnUpdate} onUpdateModule={wc.handleColumnUpdateWithoutUndo}
onResizeModules={wc.handleResizeModules} 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} onSetDimension={handleSetDimension}
onBeginUndoableGesture={wc.pushUndoSnapshot} onBeginUndoableGesture={wc.pushUndoSnapshot}
canUndo3d={wc.canUndo} canUndo3d={wc.canUndo}
@@ -130,10 +142,11 @@ export default function PreviewArea({ wc, ui }) {
</div> </div>
<div className={styles.infoZone}> <div className={styles.infoZone}>
<div className={styles.infoLine}>{infoByStep[ui.activeStep] || 'Manipulez la vue pour controler la configuration.'}</div> <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 key={msg} className={styles.warnLine}>{msg}</div>
))} ))}
</div> </div>
{ui.activeStep === 1 && <LayoutPresets wc={wc} />}
<DesignTip config={wc.config} /> <DesignTip config={wc.config} />
</div> </div>
); );
@@ -1,15 +1,47 @@
import { useState, useRef, useCallback } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react';
import { import {
COLUMN_TYPES, COLUMN_TYPES,
maxShelfGapMm, maxShelfGapMm,
SHELF_GAP_MM, SHELF_GAP_MM,
minShelvesForStructuralHeight, minShelvesForStructuralHeight,
minShelvesForColumn,
moduleStackInfo,
moduleBlockHeightsMm,
moduleBlockBacks,
moduleBlockDoors,
moduleBottomBlockMinMm,
minBlocksForBody,
maxBlocksForBody,
resolveShelfPositionsMm, resolveShelfPositionsMm,
system32ShelfPositionsMm, system32ShelfPositionsMm,
SYSTEM32_PITCH_MM SYSTEM32_PITCH_MM,
STRUCTURAL_WINDOW_HALF_MM,
STACK_BLOCK_MM
} from '../../constants/wardrobe'; } from '../../constants/wardrobe';
import {
getModuleVerticalBoundariesMm,
HORIZONTAL_SPAN_DOOR_MODES,
resolveHorizontalSpan,
synchronizeModuleSpanBoundaries,
validateHorizontalSpans
} from '@shared/manufacturing.js';
import s from './steps.module.css'; 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. */ /** After moving array item from -> to, map a selected index to the new position. */
function mapExpandedIndexAfterReorder(expanded, from, to) { function mapExpandedIndexAfterReorder(expanded, from, to) {
if (expanded === null || expanded === undefined) return expanded; if (expanded === null || expanded === undefined) return expanded;
@@ -26,11 +58,19 @@ export default function StepColumns({ wc, ui }) {
const { const {
config, numColumns, config, numColumns,
handleNumColumnsChange, handleAIOptimize, handleColumnTypeChange, handleNumColumnsChange, handleAIOptimize, handleColumnTypeChange,
handleColumnLock, handleColumnUpdate, handleColumnLock, handleColumnUpdate, setAllBacks, setAllBlockCounts,
aiOptimizing, aiOptimizeMsg, setAiOptimizeMsg, totalModuleWidth, aiOptimizing, aiOptimizeMsg, setAiOptimizeMsg, totalModuleWidth,
reorderModules, pushUndoSnapshot, reorderModules, pushUndoSnapshot,
createHorizontalSpan, updateHorizontalSpan, deleteHorizontalSpan,
addHorizontalSpanUpperBlock, resizeHorizontalSpanUpperBlockHeight,
getHorizontalSpanUpperBlock,
} = wc; } = wc;
const { expandedColumn, setExpandedColumn } = ui; const {
expandedColumn, setExpandedColumn, expandedBlock, setExpandedBlock,
selectedHorizontalSpanId, setSelectedHorizontalSpanId,
horizontalSpanDraft, setHorizontalSpanDraft,
horizontalSpanMessage, setHorizontalSpanMessage,
} = ui;
const [draggingIndex, setDraggingIndex] = useState(null); const [draggingIndex, setDraggingIndex] = useState(null);
const [dragOverIndex, setDragOverIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null);
@@ -81,60 +121,210 @@ export default function StepColumns({ wc, ui }) {
suppressCardClickRef.current = false; suppressCardClickRef.current = false;
return; return;
} }
setSelectedHorizontalSpanId(null);
setHorizontalSpanDraft(null);
setHorizontalSpanMessage('');
setExpandedBlock(0);
setExpandedColumn(isActive ? null : i); 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 minShelvesStructural = minShelvesForStructuralHeight(config.heightMm);
const hasTallCabinet = minShelvesStructural > 0; const hasTallCabinet = minShelvesStructural > 0;
const hw = config.hardware || {}; 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 ( return (
<div className={s.stepSection}> <div className={s.stepSection}>
<h2 className={s.stepTitle}>Configuration int&eacute;rieure</h2> <h2 className={s.stepTitle}>Configuration int&eacute;rieure</h2>
<p className={s.stepDesc}> <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 && ( {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> </p>
<div className={s.hardwareGroup}> {/* Column count — non limité, borné par la largeur (min 300 mm par colonne) */}
<label className={s.hardwareLabel}>Choix colonne: séparations ou block</label>
<div className={s.colCountBtns}>
{[
{ key: 'separations', label: 'Séparations' },
{ key: 'block', label: 'Block (x caisses)' }
].map((m) => (
<button
key={m.key}
type="button"
className={`${s.colCountBtn} ${config.columnMode === m.key ? s.colCountActive : ''}`}
onClick={() => wc.set('columnMode', m.key)}
>
{m.label}
</button>
))}
</div>
{config.columnMode === 'block' && (
<p className={s.hint}>Assemblage block: vis de jonction + perçages 3 milieux et +100/-100 mm (30 mm face/dos).</p>
)}
</div>
{/* Column count */}
<div className={s.colCountRow}> <div className={s.colCountRow}>
<span className={s.colCountLabel}>Nombre de colonnes</span> <span className={s.colCountLabel}>Nombre de colonnes</span>
<div className={s.colCountBtns}> <div className={s.counter}>
{[1, 2, 3, 4, 5, 6].map(n => (
<button <button
key={n}
type="button" type="button"
className={`${s.colCountBtn} ${numColumns === n ? s.colCountActive : ''}`} className={s.counterBtn}
onClick={() => handleNumColumnsChange(n)} onClick={() => handleNumColumnsChange(Math.max(1, numColumns - 1))}
> disabled={numColumns <= 1}
{n} >&minus;</button>
</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> </div>
<button <button
type="button" type="button"
@@ -145,6 +335,9 @@ export default function StepColumns({ wc, ui }) {
{aiOptimizing ? '...' : '\u2728 Optimiser'} {aiOptimizing ? '...' : '\u2728 Optimiser'}
</button> </button>
</div> </div>
{numColumns >= maxColumns && (
<p className={s.hint}>Maximum {maxColumns} colonnes pour {config.widthMm} mm de largeur (min. 300 mm par colonne).</p>
)}
{aiOptimizeMsg && ( {aiOptimizeMsg && (
<div className={s.aiMsg}> <div className={s.aiMsg}>
@@ -187,27 +380,288 @@ export default function StepColumns({ wc, ui }) {
<span className={s.colCardIcon}>{typeDef.icon}</span> <span className={s.colCardIcon}>{typeDef.icon}</span>
<span className={s.colCardType}>{typeDef.label}</span> <span className={s.colCardType}>{typeDef.label}</span>
<span className={s.colCardWidth}>{mod.widthMm} mm</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> </button>
); );
})} })}
</div> </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 */} {/* Detail tray for expanded column */}
{expandedColumn !== null && config.modules[expandedColumn] && (() => { {expandedColumn !== null && config.modules[expandedColumn] && (() => {
const i = expandedColumn; const i = expandedColumn;
const mod = config.modules[i]; const mod = config.modules[i];
const typeDef = COLUMN_TYPES.find(t => t.key === mod.type) || COLUMN_TYPES[0]; 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 shelfN = mod.shelves || 0;
const shelfPositions = resolveShelfPositionsMm({ const shelfPositions = resolveShelfPositionsMm({
shelfCount: shelfN, shelfCount: shelfN,
heightMm: config.heightMm, heightMm: config.heightMm,
plinthHeightMm: config.plinthHeightMm ?? 100, plinthHeightMm: config.plinthHeightMm ?? 100,
shelfPositionsMm: mod.shelfPositionsMm shelfPositionsMm: mod.shelfPositionsMm,
stack: modStackInfo
}); });
const minShelves = minShelvesStructural; // Colonne empilée: la jonction est structurelle → pas de rayon minimum imposé.
const bodyHmm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 0); const minShelves = minShelvesForColumn(config.heightMm, mod, config);
const bodyMidMm = bodyHmm / 2; const bodyMidMm = bodyHmm / 2;
const shelfSlots = system32ShelfPositionsMm(config.heightMm, config.plinthHeightMm ?? 100); const shelfSlots = system32ShelfPositionsMm(config.heightMm, config.plinthHeightMm ?? 100, modStackInfo);
const structuralIdx = const structuralIdx =
minShelves > 0 && shelfPositions.length > 0 minShelves > 0 && shelfPositions.length > 0
? shelfPositions.reduce((bestIdx, posMm, idx, arr) => { ? shelfPositions.reduce((bestIdx, posMm, idx, arr) => {
@@ -218,7 +672,7 @@ export default function StepColumns({ wc, ui }) {
: null; : null;
const hasShelfInStructuralWindow = const hasShelfInStructuralWindow =
minShelves <= 0 || 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( const gapMax = Math.min(
SHELF_GAP_MM.max, SHELF_GAP_MM.max,
maxShelfGapMm(config.heightMm, config.plinthHeightMm ?? 100, shelfN) maxShelfGapMm(config.heightMm, config.plinthHeightMm ?? 100, shelfN)
@@ -232,9 +686,13 @@ export default function StepColumns({ wc, ui }) {
handleColumnUpdate(i, { shelfPositionsMm: next }); handleColumnUpdate(i, { shelfPositionsMm: next });
}; };
return ( return (
<div className={s.colDetail}> <div className={s.colDetail} ref={detailRef}>
<div className={s.colDetailHeader}> <div className={s.colDetailHeader}>
<span className={s.colDetailTitle}>Colonne {i + 1} &mdash; {typeDef.icon} {typeDef.label}</span> <span className={s.colDetailTitle}>
Colonne {i + 1} &mdash; {typeDef.icon} {typeDef.label}
{modStackedHere && ` · ${blockLabel(activeBlockIndex)}`}
</span>
<div className={s.colDetailHeaderBtns}>
<button <button
type="button" type="button"
className={`${s.lockBtn} ${mod.locked ? s.isLocked : ''}`} className={`${s.lockBtn} ${mod.locked ? s.isLocked : ''}`}
@@ -242,6 +700,15 @@ export default function StepColumns({ wc, ui }) {
> >
{mod.locked ? '\u{1F512} Verrouill\u00e9e' : '\u{1F513} Verrouiller'} {mod.locked ? '\u{1F512} Verrouill\u00e9e' : '\u{1F513} Verrouiller'}
</button> </button>
<button
type="button"
className={s.lockBtn}
onClick={() => setExpandedColumn(null)}
title="Fermer les options de ce block"
>
&times;
</button>
</div>
</div> </div>
{/* Type tiles */} {/* Type tiles */}
@@ -277,6 +744,11 @@ export default function StepColumns({ wc, ui }) {
>+</button> >+</button>
</div> </div>
</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 && ( {minShelves >= 1 && (mod.shelves || 0) <= minShelves && (
<p className={s.hint}> <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. 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" type="button"
className={s.counterBtn} className={s.counterBtn}
onClick={() => handleColumnUpdate(i, { drawers: Math.min(6, (mod.drawers || 0) + 1) })} 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> >+</button>
</div> </div>
</div> </div>
@@ -396,8 +869,8 @@ export default function StepColumns({ wc, ui }) {
<button <button
key={item.key} key={item.key}
type="button" type="button"
className={`${s.colCountBtn} ${hw.drawerType === item.key ? s.colCountActive : ''}`} className={`${s.colCountBtn} ${drawerType === item.key ? s.colCountActive : ''}`}
onClick={() => wc.setHardware('drawerType', item.key)} onClick={() => handleColumnUpdate(i, { drawerType: item.key })}
> >
{item.label} {item.label}
</button> </button>
@@ -405,7 +878,7 @@ export default function StepColumns({ wc, ui }) {
</div> </div>
</div> </div>
<p className={s.hint} style={{ marginTop: 0 }}> <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> </p>
</> </>
)} )}
@@ -421,14 +894,287 @@ export default function StepColumns({ wc, ui }) {
{mod.hasRod ? 'Oui' : 'Non'} {mod.hasRod ? 'Oui' : 'Non'}
</button> </button>
</div> </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> </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}> <p className={s.summaryLine}>
{config.modules.length} colonne{config.modules.length > 1 ? 's' : ''} &mdash; Largeur totale : {totalModuleWidth} mm {config.modules.length} colonne{config.modules.length > 1 ? 's' : ''} &mdash; Largeur totale : {totalModuleWidth} mm
</p> </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) && ( {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> <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 s from './steps.module.css';
import {
AXILO_TARGET_HEIGHTS_MM,
DIMENSION_RANGES,
outerColumnStacked,
moduleBlockBacks
} from '../../constants/wardrobe.js';
const DIMS = [ const DIMS = [
{ key: 'widthMm', label: 'Largeur totale', min: 400, max: 5000, step: 50, unit: 'mm' }, { key: 'widthMm', label: 'Largeur totale', ...DIMENSION_RANGES.widthMm, unit: 'mm' },
{ key: 'heightMm', label: 'Hauteur', min: 1800, max: 2800, step: 50, unit: 'mm' }, { key: 'heightMm', label: 'Hauteur', ...DIMENSION_RANGES.heightMm, unit: 'mm' },
{ key: 'depthMm', label: 'Profondeur', min: 400, max: 800, step: 10, unit: 'mm' }, { key: 'depthMm', label: 'Profondeur', ...DIMENSION_RANGES.depthMm, unit: 'mm' },
{ key: 'plinthHeightMm', label: 'Plinthe', min: 60, max: 160, step: 10, unit: 'mm' }, { key: 'plinthHeightMm', label: 'Plinthe', ...DIMENSION_RANGES.plinthHeightMm, unit: 'mm' },
]; ];
export default function StepDimensions({ wc }) { export default function StepDimensions({ wc }) {
@@ -15,13 +21,26 @@ export default function StepDimensions({ wc }) {
const sideRightEnabled = config.sideFinishRightEnabled ?? sideDefaultsEnabled; const sideRightEnabled = config.sideFinishRightEnabled ?? sideDefaultsEnabled;
const topFinishEnabled = config.topFinishEnabled !== false; 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 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 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 minNicheHeight = Math.min(niche.heightLeftMm || config.heightMm, niche.heightRightMm || config.heightMm);
const gap = Number(config.sanitaryGapMm || 30); const gap = Number(config.sanitaryGapMm || 30);
const usableWidth = Math.max(400, minNicheWidth - gap * 2); const usableWidth = Math.max(DIMENSION_RANGES.widthMm.min, minNicheWidth - gap * 2);
const usableDepth = Math.max(400, minNicheDepth - gap * 2); const usableDepth = Math.max(DIMENSION_RANGES.depthMm.min, minNicheDepth - gap * 2);
const usableHeight = Math.max(1800, minNicheHeight - gap * 2); const usableHeight = Math.max(DIMENSION_RANGES.heightMm.min, minNicheHeight - gap);
return ( return (
<div className={s.stepSection}> <div className={s.stepSection}>
@@ -84,7 +103,23 @@ export default function StepDimensions({ wc }) {
{/* Dimension sliders */} {/* Dimension sliders */}
<div className={s.dimGrid}> <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}> <div key={key} className={s.dimField}>
<span className={s.dimLabel}>{label}</span> <span className={s.dimLabel}>{label}</span>
<div className={s.dimSliderRow}> <div className={s.dimSliderRow}>
@@ -118,29 +153,47 @@ export default function StepDimensions({ wc }) {
<div className={s.finishSelector}> <div className={s.finishSelector}>
<button <button
type="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)} onClick={() => set('sideFinishLeftEnabled', !sideLeftEnabled)}
> >
<span className={s.finishOptionLabel}>C&ocirc;t&eacute; gauche</span> <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>
<button <button
type="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)} onClick={() => set('sideFinishRightEnabled', !sideRightEnabled)}
> >
<span className={s.finishOptionLabel}>C&ocirc;t&eacute; droit</span> <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>
<button <button
type="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)} onClick={() => set('topFinishEnabled', !topFinishEnabled)}
> >
<span className={s.finishOptionLabel}>Dessus</span> <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> </button>
</div> </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> </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 { submitOrder } from '../../api/client';
import Icon from '../ui/Icon'; import Icon from '../ui/Icon';
import s from './steps.module.css'; 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 }) { export default function StepFinalize({ wc, ui }) {
const { config, priceResult, serviceOptions, selectedFinish, totalModuleWidth } = wc; const { config, priceResult, serviceOptions, selectedFinish, totalModuleWidth } = wc;
const { orderForm, setOrderForm, orderSent, setOrderSent, setActiveStep } = ui; const { orderForm, setOrderForm, orderSent, setOrderSent, setActiveStep } = ui;
@@ -22,7 +33,32 @@ export default function StepFinalize({ wc, ui }) {
// Build summary lines // Build summary lines
const installLabel = config.installationType === 'free' ? 'Libre' : 'Niche'; 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 = { const roomLabel = {
other: 'Autre', other: 'Autre',
bathroom: 'Salle de bain', bathroom: 'Salle de bain',
@@ -34,6 +70,34 @@ export default function StepFinalize({ wc, ui }) {
return `${t.icon} ${t.label} (${m.widthMm}mm)`; return `${t.icon} ${t.label} (${m.widthMm}mm)`;
}).join(', '); }).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 ( return (
<div className={s.stepSection}> <div className={s.stepSection}>
<h2 className={s.stepTitle}>R&eacute;capitulatif &amp; Commande</h2> <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> <button type="button" className={s.summaryEdit} onClick={() => setActiveStep(2)}>modifier</button>
</span> </span>
</div> </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}> <div className={s.summaryRow}>
<span className={s.summaryLabel}>Portes</span> <span className={s.summaryLabel}>Portes</span>
<span className={s.summaryValue}> <span className={s.summaryValue}>
@@ -113,17 +186,27 @@ export default function StepFinalize({ wc, ui }) {
<div className={s.panelInfo}> <div className={s.panelInfo}>
<div className={s.panelInfoTitle}>Fabrication APEX 4.0</div> <div className={s.panelInfoTitle}>Fabrication APEX 4.0</div>
<div className={s.panelRow}> <div className={s.panelRow}>
<span>Structure / Renforts / Dos :</span> <span>Structure / Renforts{backCount > 0 ? ' / Dos' : ''} :</span>
<strong>19 mm / 16 mm / 8 mm</strong> <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>
<div className={s.panelRow}> <div className={s.panelRow}>
<span>System 32 :</span> <span>System 32 :</span>
<strong>&Oslash;5 mm, prof. 12 mm, pas 32 mm</strong> <strong>&Oslash;5 mm, prof. 12 mm, pas 32 mm</strong>
</div> </div>
{backCount > 0 && (
<div className={s.panelRow}> <div className={s.panelRow}>
<span>Rainure dos :</span> <span>Rainure dos :</span>
<strong>9&times;10.5 mm, &agrave; 16 mm du bord</strong> <strong>9&times;10.5 mm, &agrave; 16 mm du bord{backCount < zoneCount ? ' (zones avec dos uniquement)' : ''}</strong>
</div> </div>
)}
<div className={s.panelRow}> <div className={s.panelRow}>
<span>Connecteurs structure :</span> <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> <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>
<div className={s.panelRow}> <div className={s.panelRow}>
<span>Plinthe :</span> <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> </div>
{priceResult?.details?.reinforcementCount > 0 && ( {priceResult?.details?.reinforcementCount > 0 && (
<div className={s.panelRow}> <div className={s.panelRow}>
@@ -3,9 +3,14 @@ import s from './steps.module.css';
export default function StepHardware({ wc }) { export default function StepHardware({ wc }) {
const { config, handleVariants, hingeVariants, blumHardware, setHardware } = wc; const { config, handleVariants, hingeVariants, blumHardware, setHardware } = wc;
const hasDrawers = config.modules.some(m => m.drawers > 0); const drawerModules = config.modules.filter(m => m.drawers > 0);
const drawerType = config.hardware?.drawerType || 'visible'; const hasDrawers = drawerModules.length > 0;
const standardHeights = [60, 90, 128, 177, 240]; 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]; const currentHeights = config.hardware?.drawerHeightsMm || [90, 128, 177, 240];
return ( return (
@@ -24,7 +29,7 @@ export default function StepHardware({ wc }) {
className={`${s.hardwareBtn} ${config.hardware.handleVariantId === h.id ? s.hardwareBtnSelected : ''}`} className={`${s.hardwareBtn} ${config.hardware.handleVariantId === h.id ? s.hardwareBtnSelected : ''}`}
onClick={() => setHardware('handleVariantId', h.id)} onClick={() => setHardware('handleVariantId', h.id)}
title={`${h.name} - ${h.price_chf} CHF`} title={`${h.name} - ${h.price_chf} CHF`}
disabled={drawerType === 'visible' && hasDrawers} disabled={allDrawersVisible}
> >
{h.image_url ? ( {h.image_url ? (
<img src={h.image_url} alt={h.name} /> <img src={h.image_url} alt={h.name} />
@@ -36,7 +41,7 @@ export default function StepHardware({ wc }) {
</button> </button>
))} ))}
</div> </div>
{drawerType === 'visible' && hasDrawers && ( {allDrawersVisible && (
<p className={s.hint}>Tiroir visible avec coulisse Blum Motion: poignée non requise.</p> <p className={s.hint}>Tiroir visible avec coulisse Blum Motion: poignée non requise.</p>
)} )}
</div> </div>
@@ -96,12 +101,12 @@ export default function StepHardware({ wc }) {
setHardware('drawerHeightsMm', next.slice(0, 4)); setHardware('drawerHeightsMm', next.slice(0, 4));
}} }}
> >
{mm} mm {legraboxSystemByHeight[mm]} — {mm} mm
</button> </button>
); );
})} })}
</div> </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>
</div> </div>
)} )}
@@ -1,7 +1,18 @@
import { STEPS } from '../../constants/wardrobe'; import { STEPS } from '../../constants/wardrobe';
import Icon from '../ui/Icon';
import styles from './StepperBar.module.css'; 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 ( return (
<div className={styles.stepperBar}> <div className={styles.stepperBar}>
{STEPS.map(({ id, label }) => ( {STEPS.map(({ id, label }) => (
@@ -20,6 +31,33 @@ export default function StepperBar({ activeStep, onStepChange, totalChf, touched
</button> </button>
))} ))}
<span className={styles.spacer} /> <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}> <div className={styles.priceBadge}>
<span className={styles.priceLabel}>CHF</span> <span className={styles.priceLabel}>CHF</span>
{(totalChf || 0).toLocaleString('fr-CH', { minimumFractionDigits: 0 })}.&ndash; {(totalChf || 0).toLocaleString('fr-CH', { minimumFractionDigits: 0 })}.&ndash;
@@ -3,11 +3,14 @@
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
padding: 0.5rem 1rem; 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); border-bottom: 1px solid var(--border);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
position: -webkit-sticky; position: -webkit-sticky;
position: sticky; position: sticky;
top: 0; top: var(--header-height);
z-index: 60; z-index: 60;
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
@@ -67,6 +70,52 @@
.spacer { flex: 1; min-width: 0.5rem; } .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 { .priceBadge {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -92,5 +141,8 @@
.stepperBar { padding: 0.4rem 0.5rem; gap: 0.15rem; } .stepperBar { padding: 0.4rem 0.5rem; gap: 0.15rem; }
.step { padding: 0.4rem 0.6rem; font-size: 0.75rem; } .step { padding: 0.4rem 0.6rem; font-size: 0.75rem; }
.stepLabel { display: none; } .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; } .priceBadge { font-size: 0.8125rem; padding: 0.35rem 0.65rem; }
} }
@@ -97,6 +97,9 @@
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
} }
.dimFieldFull {
grid-column: 1 / -1;
}
.dimLabel { .dimLabel {
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 500; font-weight: 500;
@@ -298,6 +301,22 @@
.colCardIcon { font-size: 1.2rem; display: block; } .colCardIcon { font-size: 1.2rem; display: block; }
.colCardType { font-size: 0.6875rem; font-weight: 600; color: var(--text); margin-top: 0.15rem; } .colCardType { font-size: 0.6875rem; font-weight: 600; color: var(--text); margin-top: 0.15rem; }
.colCardWidth { font-size: 0.625rem; color: var(--text-muted); } .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 { .colLockIcon {
position: absolute; position: absolute;
top: 0.2rem; top: 0.2rem;
@@ -320,6 +339,61 @@
justify-content: space-between; justify-content: space-between;
margin-bottom: 0.75rem; 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 { .colDetailTitle {
font-size: 0.9375rem; font-size: 0.9375rem;
font-weight: 600; font-weight: 600;
@@ -337,6 +411,180 @@
.lockBtn:hover { border-color: var(--text); } .lockBtn:hover { border-color: var(--text); }
.isLocked { background: var(--bg); } .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 */ /* Type selector tiles */
.typeTiles { .typeTiles {
display: flex; 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; 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); border-color: var(--action);
transform: translateY(-1px); transform: translateY(-1px);
} }
.finishOption:disabled {
cursor: not-allowed;
opacity: 1;
transform: none;
}
.finishOptionActive { .finishOptionActive {
border-color: var(--action); border-color: var(--action);
background: color-mix(in srgb, var(--action) 10%, var(--bg-card)); background: color-mix(in srgb, var(--action) 10%, var(--bg-card));
box-shadow: 0 8px 18px color-mix(in srgb, var(--action) 20%, transparent); 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 { .finishOptionLabel {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
@@ -865,6 +1126,26 @@
color: var(--text-muted); 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 { .ruleGrid3 {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -916,6 +1197,7 @@
.cardToggle { flex-direction: column; } .cardToggle { flex-direction: column; }
.columnStrip { gap: 0.25rem; } .columnStrip { gap: 0.25rem; }
.ruleGrid3 { grid-template-columns: 1fr; } .ruleGrid3 { grid-template-columns: 1fr; }
.spanDoorChoices { grid-template-columns: 1fr; }
} }
/* Compact mode for desktop split-panel (keeps everything visible) */ /* 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" /> <path d="M3 21v-5h5" />
</svg> </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': case 'eye':
return ( return (
<svg {...props}> <svg {...props}>
@@ -37,6 +47,23 @@ export default function Icon({ name, size = 16, className = '' }) {
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
</svg> </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': case 'download':
return ( return (
<svg {...props}> <svg {...props}>
@@ -45,6 +72,34 @@ export default function Icon({ name, size = 16, className = '' }) {
<path d="M12 15V3" /> <path d="M12 15V3" />
</svg> </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': case 'trash':
return ( return (
<svg {...props}> <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" /> <path d="M5 3v3M3.5 4.5h3M19 16v5M16.5 18.5h5" />
</svg> </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': case 'x':
return ( return (
<svg {...props}> <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('|');
}
+414 -11
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. */ /** Inner carcass height (mm): floor to ceiling inside side panels. */
export function innerCarcassHeightMm(heightMm, plinthHeightMm) { export function innerCarcassHeightMm(heightMm, plinthHeightMm) {
@@ -15,18 +21,178 @@ export function maxShelfGapMm(heightMm, plinthHeightMm, shelfCount) {
return Math.floor((H - n * t) / (n - 1)); 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 }; 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_PITCH_MM = 32;
export const SYSTEM32_FIRST_HOLE_MM = 51; export const SYSTEM32_FIRST_HOLE_MM = 51;
/** Above this carcass height (mm), at least one shelf per column is required (rigidité / RAZENTZAB). */ /** Above this carcass height (mm), at least one shelf per column is required (rigidité / RAZENTZAB). */
export const STRUCTURAL_MIN_SHELF_HEIGHT_MM = 2000; 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). */ /** Minimum shelf count for a column at this height (0 or 1). */
export function minShelvesForStructuralHeight(heightMm) { export function minShelvesForStructuralHeight(heightMm) {
return (Number(heightMm) || 0) > STRUCTURAL_MIN_SHELF_HEIGHT_MM ? 1 : 0; 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. * Shelf center Y (meters), wardrobe group space — matches WardrobePreview formulas.
* @param {number|null|undefined} shelfGapMm — clear opening between shelf faces; null = equal division (default). * @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); const bodyH = (Number(heightMm) || 0) - (Number(plinthHeightMm) || 0);
if (!(bodyH > 0)) return []; if (!(bodyH > 0)) return [];
const minPos = SYSTEM32_FIRST_HOLE_MM; const rangeSlots = (fromMm, toMm) => {
const maxPos = bodyH - SYSTEM32_FIRST_HOLE_MM; const minPos = fromMm + SYSTEM32_FIRST_HOLE_MM;
const maxPos = toMm - SYSTEM32_FIRST_HOLE_MM;
if (maxPos < minPos) return []; if (maxPos < minPos) return [];
const count = Math.floor((maxPos - minPos) / SYSTEM32_PITCH_MM) + 1; const count = Math.floor((maxPos - minPos) / SYSTEM32_PITCH_MM) + 1;
return Array.from({ length: Math.max(0, count) }, (_, i) => minPos + i * SYSTEM32_PITCH_MM); 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({ export function resolveShelfPositionsMm({
shelfCount, shelfCount,
heightMm, heightMm,
plinthHeightMm, plinthHeightMm,
shelfPositionsMm shelfPositionsMm,
stack = null
}) { }) {
const n = Math.max(0, Number(shelfCount) || 0); const n = Math.max(0, Number(shelfCount) || 0);
if (n <= 0) return []; if (n <= 0) return [];
const slots = system32ShelfPositionsMm(heightMm, plinthHeightMm); const slots = system32ShelfPositionsMm(heightMm, plinthHeightMm, stack);
if (slots.length <= 0) return []; if (slots.length <= 0) return [];
if (n >= slots.length) return slots.slice(0, n); if (n >= slots.length) return slots.slice(0, n);
@@ -113,10 +444,7 @@ export function resolveShelfPositionsMm({
if (Array.isArray(shelfPositionsMm) && shelfPositionsMm.length === n) { if (Array.isArray(shelfPositionsMm) && shelfPositionsMm.length === n) {
indexes = shelfPositionsMm.map(toNearestIndex); indexes = shelfPositionsMm.map(toNearestIndex);
} else { } else {
indexes = Array.from({ length: n }, (_, i) => { indexes = distributedShelfIndexes(n, slots, heightMm, plinthHeightMm, stack);
const t = ((i + 1) * (slots.length + 1)) / (n + 1);
return Math.max(0, Math.min(slots.length - 1, Math.round(t) - 1));
});
} }
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
@@ -128,9 +456,59 @@ export function resolveShelfPositionsMm({
indexes[i] = Math.min(indexes[i], indexes[i + 1] - 1); 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]); 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 // Column element types -- single source of truth
export const COLUMN_TYPES = [ export const COLUMN_TYPES = [
{ key: 'penderie', label: 'Penderie', icon: '\u{1F454}', shelves: 1, drawers: 0, hasRod: true, defaultWidth: 600, minWidth: 400, maxWidth: 1200 }, { 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, sanitaryGapMm: 30,
modules: [], 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 }, colors: { ...DEFAULT_COLORS },
hardware: { hardware: {
handleVariantId: null, handleVariantId: null,
@@ -218,6 +608,19 @@ export const DEFAULT_CONFIG = {
serviceOptionIds: [] 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 = [ export const STEPS = [
{ id: 1, key: 'espace', label: 'Espace' }, { id: 1, key: 'espace', label: 'Espace' },
{ id: 2, key: 'colonnes', label: 'Colonnes' }, { 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 SESSION_STEP_KEY = 'apex_wardrobe_step';
const PLANS_OPEN_KEY = 'apex.plans.open'; 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; } try { return Number(sessionStorage.getItem(SESSION_STEP_KEY)) || 1; } catch { return 1; }
}); });
const [expandedColumn, setExpandedColumn] = useState(null); 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(() => { useEffect(() => {
try { sessionStorage.setItem(SESSION_STEP_KEY, String(activeStep)); } catch {} try { sessionStorage.setItem(SESSION_STEP_KEY, String(activeStep)); } catch {}
@@ -67,10 +74,30 @@ export default function useConfiguratorUI() {
const [orderSent, setOrderSent] = useState(false); const [orderSent, setOrderSent] = useState(false);
const [lastOrderId, setLastOrderId] = useState(null); 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 // Module click from 3D view
const handleModuleClick = useCallback((moduleIndex) => { const handleModuleClick = useCallback((moduleIndex, blockIndex = 0) => {
setExpandedColumn(prev => prev === moduleIndex ? null : moduleIndex); 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 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 // Element click from 3D view
@@ -101,6 +128,10 @@ export default function useConfiguratorUI() {
return { return {
activeStep, setActiveStep, activeStep, setActiveStep,
expandedColumn, setExpandedColumn, expandedColumn, setExpandedColumn,
expandedBlock, setExpandedBlock,
selectedHorizontalSpanId, setSelectedHorizontalSpanId,
horizontalSpanDraft, setHorizontalSpanDraft,
horizontalSpanMessage, setHorizontalSpanMessage,
showMaterialPicker, setShowMaterialPicker, showMaterialPicker, setShowMaterialPicker,
showMaterialModal, setShowMaterialModal, showMaterialModal, setShowMaterialModal,
showColorPicker, setShowColorPicker, showColorPicker, setShowColorPicker,
@@ -118,6 +149,7 @@ export default function useConfiguratorUI() {
orderSent, setOrderSent, orderSent, setOrderSent,
lastOrderId, setLastOrderId, lastOrderId, setLastOrderId,
handleModuleClick, handleModuleClick,
handleHorizontalSpanSelect,
openColorPicker, openColorPicker,
closeColorPicker, closeColorPicker,
openMaterialDetail, openMaterialDetail,
+549 -53
View File
@@ -2,14 +2,107 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { fetchFinishes, fetchServiceOptions, calculatePrice } from '../api/client'; import { fetchFinishes, fetchServiceOptions, calculatePrice } from '../api/client';
import { import {
COLUMN_TYPES, DEFAULT_CYCLE, DEFAULT_COLORS, DEFAULT_CONFIG, maxShelfGapMm, SHELF_GAP_MM, 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'; } 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 API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const SESSION_KEY = 'apex_wardrobe_config'; const SESSION_KEY = 'apex_wardrobe_config';
const SESSION_COLS_KEY = 'apex_wardrobe_numcols'; const SESSION_COLS_KEY = 'apex_wardrobe_numcols';
const MAX_CONFIG_UNDO = 50; 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() { function loadSavedConfig() {
try { try {
const raw = sessionStorage.getItem(SESSION_KEY); const raw = sessionStorage.getItem(SESSION_KEY);
@@ -33,6 +126,8 @@ export default function useWardrobeConfig() {
const pushUndoSnapshot = useCallback(() => { const pushUndoSnapshot = useCallback(() => {
const snap = JSON.parse(JSON.stringify(configRef.current)); 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); undoPastRef.current.push(snap);
if (undoPastRef.current.length > MAX_CONFIG_UNDO) undoPastRef.current.shift(); if (undoPastRef.current.length > MAX_CONFIG_UNDO) undoPastRef.current.shift();
undoFutureRef.current = []; undoFutureRef.current = [];
@@ -43,6 +138,7 @@ export default function useWardrobeConfig() {
if (undoPastRef.current.length === 0) return; if (undoPastRef.current.length === 0) return;
const prev = undoPastRef.current.pop(); const prev = undoPastRef.current.pop();
undoFutureRef.current.push(JSON.parse(JSON.stringify(configRef.current))); undoFutureRef.current.push(JSON.parse(JSON.stringify(configRef.current)));
configRef.current = prev;
setConfig(prev); setConfig(prev);
setUndoRevision((n) => n + 1); setUndoRevision((n) => n + 1);
}, []); }, []);
@@ -51,9 +147,79 @@ export default function useWardrobeConfig() {
if (undoFutureRef.current.length === 0) return; if (undoFutureRef.current.length === 0) return;
const next = undoFutureRef.current.pop(); const next = undoFutureRef.current.pop();
undoPastRef.current.push(JSON.parse(JSON.stringify(configRef.current))); undoPastRef.current.push(JSON.parse(JSON.stringify(configRef.current)));
configRef.current = next;
setConfig(next); setConfig(next);
setUndoRevision((n) => n + 1); 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 [finishes, setFinishes] = useState([]);
const [serviceOptions, setServiceOptions] = useState([]); const [serviceOptions, setServiceOptions] = useState([]);
const [priceResult, setPriceResult] = useState(null); const [priceResult, setPriceResult] = useState(null);
@@ -78,9 +244,23 @@ export default function useWardrobeConfig() {
// Keep extended configuration schema compatible with older saved sessions. // Keep extended configuration schema compatible with older saved sessions.
useEffect(() => { useEffect(() => {
setConfig((c) => ({ 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, ...DEFAULT_CONFIG,
...c, ...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: { nicheMeasurements: {
...DEFAULT_CONFIG.nicheMeasurements, ...DEFAULT_CONFIG.nicheMeasurements,
...(c.nicheMeasurements || {}) ...(c.nicheMeasurements || {})
@@ -89,7 +269,10 @@ export default function useWardrobeConfig() {
...DEFAULT_CONFIG.hardware, ...DEFAULT_CONFIG.hardware,
...(c.hardware || {}) ...(c.hardware || {})
} }
})); };
next.horizontalSpans = reconcileHorizontalSpans(next.horizontalSpans, next);
return next;
});
// Run once at startup for legacy session payloads. // Run once at startup for legacy session payloads.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -104,10 +287,11 @@ export default function useWardrobeConfig() {
setConfig((c) => { setConfig((c) => {
const h = c.heightMm; const h = c.heightMm;
const pl = c.plinthHeightMm ?? 100; const pl = c.plinthHeightMm ?? 100;
const minS = minShelvesForStructuralHeight(h);
const list = c.modules || []; const list = c.modules || [];
let touched = false; let touched = false;
const modules = list.map((mod) => { 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 nRaw = mod.shelves || 0;
const n = Math.max(minS, nRaw); const n = Math.max(minS, nRaw);
let nextMod = mod; let nextMod = mod;
@@ -119,7 +303,8 @@ export default function useWardrobeConfig() {
shelfCount: n, shelfCount: n,
heightMm: h, heightMm: h,
plinthHeightMm: pl, plinthHeightMm: pl,
shelfPositionsMm: nextMod.shelfPositionsMm shelfPositionsMm: nextMod.shelfPositionsMm,
stack: moduleStackInfo(mod, c)
}); });
const nextShelfPositions = n > 0 ? resolvedShelfPositions : []; const nextShelfPositions = n > 0 ? resolvedShelfPositions : [];
const prevShelfPositions = Array.isArray(nextMod.shelfPositionsMm) ? nextMod.shelfPositionsMm : []; const prevShelfPositions = Array.isArray(nextMod.shelfPositionsMm) ? nextMod.shelfPositionsMm : [];
@@ -174,6 +359,94 @@ export default function useWardrobeConfig() {
next = { ...next, doorType: 'hinged', slidingPanelsCount: 2 }; 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 modules = Array.isArray(next.modules) ? [...next.modules] : [];
const rawDrawerType = next.hardware?.drawerType || 'visible'; const rawDrawerType = next.hardware?.drawerType || 'visible';
const drawerType = 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. // Drawers constraints: from bottom, depth 400..650 by 50, and drawer columns max 1000 mm.
const constrainedModules = modules.map((mod) => { const constrainedModules = modules.map((mod) => {
const hasDrawers = (mod.drawers || 0) > 0 || mod.type === 'tiroirs'; const hasDrawers = (mod.drawers || 0) > 0 || mod.type === 'tiroirs';
if (!hasDrawers) return mod; const rawModuleDrawerType = mod.drawerType ?? rawDrawerType;
const nextMaxWidth = Math.min(1000, mod.maxWidth || 1200); 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. // 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 { return {
...mod, ...mod,
maxWidth: nextMaxWidth maxWidth: nextMaxWidth,
drawerType: moduleDrawerType
}; };
}); });
const modulesChanged = constrainedModules.some((m, i) => m !== modules[i]); const modulesChanged = constrainedModules.some((m, i) => m !== modules[i]);
const sanitizedHandleBore = sanitizeStep(next.hardware?.handleBoreHeightMm, 600, 1600, 10); const sanitizedHandleBore = sanitizeStep(next.hardware?.handleBoreHeightMm, 600, 1600, 10);
const sanitizedDrawerHeights = (next.hardware?.drawerHeightsMm || [90, 128, 177, 240]).slice(0, 4); 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) => { 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); 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 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 minNicheHeight = Math.min(niche.heightLeftMm || next.heightMm, niche.heightRightMm || next.heightMm);
const plinthHeightMm = normalizeAxiloTargetHeightMm(next.plinthHeightMm);
let widthMm = next.widthMm; let widthMm = next.widthMm;
let depthMm = next.depthMm; let depthMm = next.depthMm;
let heightMm = next.heightMm; let heightMm = next.heightMm;
if (next.installationType === 'niche') { if (next.installationType === 'niche') {
const gap = Number(next.sanitaryGapMm || 30); const gap = Number(next.sanitaryGapMm || 30);
widthMm = Math.max(400, minNicheWidth - gap * 2); widthMm = Math.max(DIMENSION_RANGES.widthMm.min, minNicheWidth - gap * 2);
depthMm = Math.max(400, minNicheDepth - gap * 2); depthMm = Math.max(DIMENSION_RANGES.depthMm.min, minNicheDepth - gap * 2);
heightMm = Math.max(1800, minNicheHeight - 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 = { const hardware = {
@@ -230,10 +513,12 @@ export default function useWardrobeConfig() {
}; };
const changed = const changed =
backModeChanged ||
modulesChanged || modulesChanged ||
widthMm !== next.widthMm || widthMm !== next.widthMm ||
depthMm !== next.depthMm || depthMm !== next.depthMm ||
heightMm !== next.heightMm || heightMm !== next.heightMm ||
plinthHeightMm !== next.plinthHeightMm ||
sanitizedHandleBore !== (next.hardware?.handleBoreHeightMm || 1000) || sanitizedHandleBore !== (next.hardware?.handleBoreHeightMm || 1000) ||
effectiveDrawerDepthMm !== next.effectiveDrawerDepthMm || effectiveDrawerDepthMm !== next.effectiveDrawerDepthMm ||
handleType !== (next.hardware?.handleType || 'standard') || handleType !== (next.hardware?.handleType || 'standard') ||
@@ -248,11 +533,12 @@ export default function useWardrobeConfig() {
widthMm, widthMm,
depthMm, depthMm,
heightMm, heightMm,
plinthHeightMm,
hardware, hardware,
effectiveDrawerDepthMm 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 // Fetch initial data
useEffect(() => { useEffect(() => {
@@ -296,21 +582,18 @@ export default function useWardrobeConfig() {
}, [config, loading, updatePrice]); }, [config, loading, updatePrice]);
// Simple setter helper // 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 applyDimension = useCallback((key, valueMm) => {
const ranges = { const rules = DIMENSION_RANGES[key];
widthMm: { min: 400, max: 5000, step: 50 },
heightMm: { min: 1800, max: 2800, step: 50 },
depthMm: { min: 400, max: 800, step: 10 },
};
const rules = ranges[key];
if (!rules) return; if (!rules) return;
const nextRaw = Number(valueMm); const nextRaw = Number(valueMm);
if (!Number.isFinite(nextRaw)) return; if (!Number.isFinite(nextRaw)) return;
const nextStepped = Math.round(nextRaw / rules.step) * rules.step; 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) => { setConfig((c) => {
const patch = { [key]: next }; const patch = { [key]: next };
@@ -329,8 +612,9 @@ export default function useWardrobeConfig() {
niche.depthMidMm = nicheOuter; niche.depthMidMm = nicheOuter;
niche.depthBottomMm = nicheOuter; niche.depthBottomMm = nicheOuter;
} else if (key === 'heightMm') { } else if (key === 'heightMm') {
niche.heightLeftMm = nicheOuter; const nicheHeight = next + gap;
niche.heightRightMm = nicheOuter; niche.heightLeftMm = nicheHeight;
niche.heightRightMm = nicheHeight;
} }
patch.nicheMeasurements = niche; patch.nicheMeasurements = niche;
} }
@@ -340,6 +624,11 @@ export default function useWardrobeConfig() {
}); });
}, []); }, []);
const setDimension = useCallback((key, valueMm) => {
pushUndoSnapshot();
applyDimension(key, valueMm);
}, [applyDimension, pushUndoSnapshot]);
// Column generation // Column generation
const generateColumns = useCallback((nCols, totalW, existingModules = []) => { const generateColumns = useCallback((nCols, totalW, existingModules = []) => {
const lockedWidth = existingModules const lockedWidth = existingModules
@@ -369,16 +658,25 @@ export default function useWardrobeConfig() {
locked: false, locked: false,
shelves: typeDef.shelves, shelves: typeDef.shelves,
drawers: typeDef.drawers, 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) => { const handleNumColumnsChange = useCallback((newNum) => {
pushUndoSnapshot();
setNumColumns(newNum); setNumColumns(newNum);
const modules = generateColumns(newNum, config.widthMm, config.modules); const modules = generateColumns(newNum, config.widthMm, config.modules);
setConfig(c => ({ ...c, modules })); setConfig((c) => {
}, [config.widthMm, config.modules, generateColumns]); const next = { ...c, modules };
return { ...next, horizontalSpans: reconcileHorizontalSpans(c.horizontalSpans, next) };
});
}, [config.widthMm, config.modules, generateColumns, pushUndoSnapshot]);
// AI layout optimization // AI layout optimization
const handleAIOptimize = useCallback(async () => { const handleAIOptimize = useCallback(async () => {
@@ -406,13 +704,15 @@ export default function useWardrobeConfig() {
if (!res.ok) throw new Error('API error'); if (!res.ok) throw new Error('API error');
const data = await res.json(); const data = await res.json();
if (data.modules && Array.isArray(data.modules)) { if (data.modules && Array.isArray(data.modules)) {
pushUndoSnapshot();
setConfig(c => { setConfig(c => {
const newModules = data.modules.map((m, i) => ({ const newModules = data.modules.map((m, i) => ({
...c.modules[i], ...c.modules[i],
...m, ...m,
id: c.modules[i]?.id || `col-${i}` 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); setNumColumns(data.modules.length);
} }
@@ -424,13 +724,14 @@ export default function useWardrobeConfig() {
} finally { } finally {
setAiOptimizing(false); setAiOptimizing(false);
} }
}, [config]); }, [config, pushUndoSnapshot]);
const handleColumnTypeChange = useCallback((index, typeKey) => { const handleColumnTypeChange = useCallback((index, typeKey) => {
pushUndoSnapshot();
const typeDef = COLUMN_TYPES.find(t => t.key === typeKey) || COLUMN_TYPES[0]; const typeDef = COLUMN_TYPES.find(t => t.key === typeKey) || COLUMN_TYPES[0];
setConfig(c => { setConfig(c => {
const modules = [...c.modules]; const modules = [...c.modules];
const minS = minShelvesForStructuralHeight(c.heightMm); const minS = minShelvesForColumn(c.heightMm, modules[index], c);
const shelves = Math.max(minS, typeDef.shelves); const shelves = Math.max(minS, typeDef.shelves);
modules[index] = { modules[index] = {
...modules[index], ...modules[index],
@@ -442,15 +743,58 @@ export default function useWardrobeConfig() {
}; };
return { ...c, modules }; 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) => { const handleColumnLock = useCallback((index) => {
pushUndoSnapshot();
setConfig(c => { setConfig(c => {
const modules = [...c.modules]; const modules = [...c.modules];
modules[index] = { ...modules[index], locked: !modules[index].locked }; modules[index] = { ...modules[index], locked: !modules[index].locked };
return { ...c, modules }; return { ...c, modules };
}); });
}, []); }, [pushUndoSnapshot]);
const redistributeWidths = useCallback(() => { const redistributeWidths = useCallback(() => {
setConfig(c => { 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(() => { useEffect(() => {
if (config.modules.length > 0) { if (config.modules.length === 0) return;
redistributeWidths(); 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 // Initialize columns on first load
useEffect(() => { useEffect(() => {
if (config.modules.length === 0) { if (config.modules.length === 0) setConfig(withAutoLayout);
const modules = generateColumns(numColumns, config.widthMm, []);
setConfig(c => ({ ...c, modules }));
}
}, []); }, []);
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 => { setConfig(c => {
const modules = [...c.modules]; const modules = [...c.modules];
const prev = modules[index]; const prev = modules[index];
const merged = { ...prev, ...updates }; 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 shelves = Math.max(minS, merged.shelves ?? 0);
const drawers = merged.drawers ?? 0; const drawers = merged.drawers ?? 0;
const blockGeometryChanged = !sameNumberArray(
moduleBlockHeightsMm(prev, c),
moduleBlockHeightsMm(merged, c)
);
const withShelves = { const withShelves = {
...merged, ...merged,
shelves, shelves,
@@ -496,7 +857,8 @@ export default function useWardrobeConfig() {
shelfCount: shelves, shelfCount: shelves,
heightMm: c.heightMm, heightMm: c.heightMm,
plinthHeightMm: c.plinthHeightMm ?? 100, 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'; 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) => { const reorderModules = useCallback((fromIndex, toIndex) => {
if (fromIndex === toIndex) return; if (fromIndex === toIndex) return;
pushUndoSnapshot();
setConfig((c) => { setConfig((c) => {
const list = [...c.modules]; const list = [...c.modules];
const n = list.length; const n = list.length;
if (fromIndex < 0 || fromIndex >= n || toIndex < 0 || toIndex >= n) return c; if (fromIndex < 0 || fromIndex >= n || toIndex < 0 || toIndex >= n) return c;
const [item] = list.splice(fromIndex, 1); const [item] = list.splice(fromIndex, 1);
list.splice(toIndex, 0, item); list.splice(toIndex, 0, item);
return { const next = {
...c, ...c,
modules: list.map((m, idx) => ({ ...m, position: idx })), 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) => { setConfig((c) => {
const modules = [...c.modules]; const modules = [...c.modules];
if (!modules[separatorIndex] || !modules[separatorIndex + 1]) return c;
const left = { ...modules[separatorIndex] }; const left = { ...modules[separatorIndex] };
const right = { ...modules[separatorIndex + 1] }; const right = { ...modules[separatorIndex + 1] };
if (base) {
left.widthMm = base.leftMm;
right.widthMm = base.rightMm;
}
const leftMin = left.minWidth || 300; const leftMin = left.minWidth || 300;
const leftMax = left.maxWidth || 1200; const leftMax = left.maxWidth || 1200;
@@ -543,6 +1018,10 @@ export default function useWardrobeConfig() {
left.widthMm = Math.round(newLeftW); left.widthMm = Math.round(newLeftW);
right.widthMm = Math.round(newRightW); 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] = left;
modules[separatorIndex + 1] = right; modules[separatorIndex + 1] = right;
return { ...c, modules }; return { ...c, modules };
@@ -555,6 +1034,7 @@ export default function useWardrobeConfig() {
}, []); }, []);
const handleColorSelect = useCallback((elementType, finish) => { const handleColorSelect = useCallback((elementType, finish) => {
pushUndoSnapshot();
setConfig((c) => ({ setConfig((c) => ({
...c, ...c,
colors: { colors: {
@@ -567,9 +1047,10 @@ export default function useWardrobeConfig() {
} }
} }
})); }));
}, []); }, [pushUndoSnapshot]);
const handleColorApplyToAll = useCallback((finish) => { const handleColorApplyToAll = useCallback((finish) => {
pushUndoSnapshot();
const colorEntry = { const colorEntry = {
finishId: finish.id, finishId: finish.id,
label: finish.label || finish.name, label: finish.label || finish.name,
@@ -586,18 +1067,20 @@ export default function useWardrobeConfig() {
}); });
return { ...c, colors: newColors }; return { ...c, colors: newColors };
}); });
}, []); }, [pushUndoSnapshot]);
// Hardware // Hardware
const setHardware = useCallback((key, value) => { const setHardware = useCallback((key, value) => {
pushUndoSnapshot();
setConfig((c) => ({ setConfig((c) => ({
...c, ...c,
hardware: { ...c.hardware, [key]: value } hardware: { ...c.hardware, [key]: value }
})); }));
}, []); }, [pushUndoSnapshot]);
// Material management // Material management
const handleMaterialConfirm = useCallback((materialId, dimensionId) => { const handleMaterialConfirm = useCallback((materialId, dimensionId) => {
pushUndoSnapshot();
setConfig((c) => { setConfig((c) => {
const material = finishes.find(f => f.id === materialId); const material = finishes.find(f => f.id === materialId);
const updated = { ...c, finishId: materialId, dimensionId }; const updated = { ...c, finishId: materialId, dimensionId };
@@ -619,7 +1102,7 @@ export default function useWardrobeConfig() {
} }
return updated; return updated;
}); });
}, [finishes]); }, [finishes, pushUndoSnapshot]);
// Derived values // Derived values
const totalModuleWidth = useMemo(() => const totalModuleWidth = useMemo(() =>
@@ -643,7 +1126,10 @@ export default function useWardrobeConfig() {
); );
return { 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, finishes, serviceOptions,
priceResult, priceResult,
loading, loading,
@@ -655,9 +1141,19 @@ export default function useWardrobeConfig() {
handleAIOptimize, handleAIOptimize,
handleColumnTypeChange, handleColumnTypeChange,
handleColumnLock, handleColumnLock,
setAllBacks,
setAllBlockCounts,
redistributeWidths, redistributeWidths,
handleColumnUpdate, handleColumnUpdate,
handleColumnUpdateWithoutUndo: applyColumnUpdate,
reorderModules, reorderModules,
createHorizontalSpan,
updateHorizontalSpan,
deleteHorizontalSpan,
synchronizeHorizontalSpanBoundaries,
addHorizontalSpanUpperBlock,
resizeHorizontalSpanUpperBlockHeight,
getHorizontalSpanUpperBlock,
handleResizeModules, handleResizeModules,
handleElementClick, handleElementClick,
handleColorSelect, handleColorSelect,
+1
View File
@@ -15,6 +15,7 @@
--warning: #f59e0b; --warning: #f59e0b;
--danger: #dc2626; --danger: #dc2626;
--font: 'DM Sans', system-ui, sans-serif; --font: 'DM Sans', system-ui, sans-serif;
--header-height: 63px;
} }
* { * {
+340 -5
View File
@@ -7,6 +7,10 @@ import {
getFormexSyncStreamUrl, getFormexSyncStreamUrl,
getHaefeleSyncStreamUrl, getHaefeleSyncStreamUrl,
fetchHaefeleMaterials, fetchHaefeleMaterials,
getLeubaHiagSyncStreamUrl,
fetchLeubaHiagMaterials,
fetchLeubaHiagTargets,
setFormexMaterialActive,
fetchOrderSupplierVouchers, fetchOrderSupplierVouchers,
fetchOrderPlanExports, fetchOrderPlanExports,
generateOrderPlanExports, generateOrderPlanExports,
@@ -16,8 +20,16 @@ import {
deleteOrderPlanExport deleteOrderPlanExport
} from '../api/client'; } from '../api/client';
import Icon from '../components/ui/Icon'; import Icon from '../components/ui/Icon';
import SectionNav from '../components/SectionNav';
import styles from './Admin.module.css'; 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() { export default function Admin() {
const { isAdmin, logout } = useAuth(); const { isAdmin, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -61,17 +73,42 @@ export default function Admin() {
const [haefeleError, setHaefeleError] = useState(''); const [haefeleError, setHaefeleError] = useState('');
const haefeleLogsEndRef = useRef(null); 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(() => { useEffect(() => {
if (!isAdmin) { if (!isAdmin) {
navigate('/login?next=/admin', { replace: true }); navigate('/login?next=/admin', { replace: true });
return; return;
} }
// Fetch orders and sync logs in parallel // Fetch orders and sync logs in parallel
Promise.all([fetchOrders(), fetchSyncLogs(10), fetchHaefeleMaterials({ limit: 50 })]) Promise.all([
.then(([ordersData, logsData, haefeleData]) => { fetchOrders(),
fetchSyncLogs(10),
fetchHaefeleMaterials({ limit: 50 }),
fetchLeubaHiagMaterials({ limit: 100 }),
fetchLeubaHiagTargets()
])
.then(([ordersData, logsData, haefeleData, leubaData, leubaTargetsData]) => {
setOrders(ordersData); setOrders(ordersData);
setSyncLogs(logsData); setSyncLogs(logsData);
setHaefeleMaterials(haefeleData?.materials || []); setHaefeleMaterials(haefeleData?.materials || []);
setLeubaMaterials(leubaData?.materials || []);
const targets = leubaTargetsData || [];
setLeubaTargets(targets);
setLeubaSelected(targets.map((t) => t.key));
}) })
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -90,6 +127,12 @@ export default function Admin() {
} }
}, [haefeleLiveMessages]); }, [haefeleLiveMessages]);
useEffect(() => {
if (leubaLogsEndRef.current) {
leubaLogsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [leubaLiveMessages]);
useEffect(() => { useEffect(() => {
const saved = window.localStorage.getItem('admin.haefele.urls'); const saved = window.localStorage.getItem('admin.haefele.urls');
if (saved) setHaefeleUrlsInput(saved); if (saved) setHaefeleUrlsInput(saved);
@@ -255,12 +298,118 @@ export default function Admin() {
setHaefeleProgress({ current: 0, total: 0, collection: '' }); 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 const progressPercent = progress.total > 0
? Math.round((progress.current / progress.total) * 100) ? Math.round((progress.current / progress.total) * 100)
: 0; : 0;
const haefeleProgressPercent = haefeleProgress.total > 0 const haefeleProgressPercent = haefeleProgress.total > 0
? Math.round((haefeleProgress.current / haefeleProgress.total) * 100) ? Math.round((haefeleProgress.current / haefeleProgress.total) * 100)
: 0; : 0;
const leubaProgressPercent = leubaProgress.total > 0
? Math.round((leubaProgress.current / leubaProgress.total) * 100)
: 0;
const handleToggleSupplierDetails = async (orderId) => { const handleToggleSupplierDetails = async (orderId) => {
if (expandedOrderId === orderId) { if (expandedOrderId === orderId) {
@@ -366,9 +515,11 @@ export default function Admin() {
{loading && <p className={styles.muted}>Chargement…</p>} {loading && <p className={styles.muted}>Chargement…</p>}
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
{!loading && !error && <SectionNav sections={ADMIN_SECTIONS} />}
{/* Formex Sync Section */} {/* Formex Sync Section */}
{!loading && !error && ( {!loading && !error && (
<section className={styles.section}> <section id="sync-formex" className={styles.section}>
<h2>Synchronisation Formex</h2> <h2>Synchronisation Formex</h2>
<div className={styles.syncActions}> <div className={styles.syncActions}>
<button <button
@@ -468,7 +619,7 @@ export default function Admin() {
)} )}
{!loading && !error && ( {!loading && !error && (
<section className={styles.section}> <section id="sync-hafele" className={styles.section}>
<h2>Synchronisation Häfele (URLs ciblées)</h2> <h2>Synchronisation Häfele (URLs ciblées)</h2>
<p className={styles.muted}> <p className={styles.muted}>
Collez une URL par ligne (ou séparées par virgule). Seules les URL Häfele seront traitées. 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> </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 */} {/* Orders Section */}
{!loading && !error && ( {!loading && !error && (
<section className={styles.section}> <section id="commandes" className={styles.section}>
<h2>Commandes</h2> <h2>Commandes</h2>
<div className={styles.tableWrap}> <div className={styles.tableWrap}>
<table className={styles.table}> <table className={styles.table}>
+64
View File
@@ -300,6 +300,70 @@
margin-top: 0.75rem; 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 { .urlTextarea {
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
+19
View File
@@ -14,6 +14,7 @@ import RoomUpload from '../components/RoomUpload';
import ColorPickerModal from '../components/ColorPickerModal'; import ColorPickerModal from '../components/ColorPickerModal';
import Plans2DView from '../components/Plans2DView'; import Plans2DView from '../components/Plans2DView';
import AIRenderModal from '../components/AIRenderModal'; import AIRenderModal from '../components/AIRenderModal';
import SavedDesignsModal from '../components/SavedDesignsModal';
import styles from './Configurator.module.css'; import styles from './Configurator.module.css';
export default function Configurator() { export default function Configurator() {
@@ -22,6 +23,7 @@ export default function Configurator() {
// Track which steps have been visited // Track which steps have been visited
const [touched, setTouched] = useState(new Set([1])); const [touched, setTouched] = useState(new Set([1]));
const [showSavedDesigns, setShowSavedDesigns] = useState(false);
const handleStepChange = (step) => { const handleStepChange = (step) => {
ui.setActiveStep(step); ui.setActiveStep(step);
setTouched(prev => new Set(prev).add(step)); setTouched(prev => new Set(prev).add(step));
@@ -77,6 +79,11 @@ export default function Configurator() {
<StepperBar <StepperBar
activeStep={ui.activeStep} activeStep={ui.activeStep}
onStepChange={handleStepChange} onStepChange={handleStepChange}
onOpenSavedDesigns={() => setShowSavedDesigns(true)}
canUndo={wc.canUndo}
canRedo={wc.canRedo}
onUndo={wc.undo}
onRedo={wc.redo}
totalChf={totalChf} totalChf={totalChf}
touched={touched} touched={touched}
/> />
@@ -100,6 +107,18 @@ export default function Configurator() {
{/* ===== Modals ===== */} {/* ===== 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 && ( {ui.showMaterialModal && ui.selectedMaterial && (
<MaterialDetailModal <MaterialDetailModal
material={ui.selectedMaterial} material={ui.selectedMaterial}
+10 -1
View File
@@ -42,7 +42,7 @@
padding: 0; padding: 0;
min-width: 0; /* allow canvas to shrink without overflow */ min-width: 0; /* allow canvas to shrink without overflow */
position: sticky; position: sticky;
top: 58px; top: calc(var(--header-height) + 58px);
align-self: flex-start; align-self: flex-start;
z-index: 10; z-index: 10;
} }
@@ -52,6 +52,15 @@
/* Extra right padding so scrollbars don't cover slider thumbs/cursors */ /* Extra right padding so scrollbars don't cover slider thumbs/cursors */
padding: 0.75rem 1.4rem 0.25rem 0.9rem; padding: 0.75rem 1.4rem 0.25rem 0.9rem;
min-width: 0; 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 { Link } from 'react-router-dom';
import SectionNav from '../components/SectionNav';
import styles from './Home.module.css'; 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() { export default function Home() {
return ( return (
<div className={styles.home}> <div className={styles.home}>
@@ -12,7 +21,9 @@ export default function Home() {
<Link to="/configurateur" className={styles.cta}>Configurer mon armoire</Link> <Link to="/configurateur" className={styles.cta}>Configurer mon armoire</Link>
</section> </section>
<section className={styles.section}> <SectionNav sections={HOME_SECTIONS} />
<section id="pourquoi" className={styles.section}>
<h2>Pourquoi SIGNATURE</h2> <h2>Pourquoi SIGNATURE</h2>
<ul className={styles.features}> <ul className={styles.features}>
<li><strong>Blocs modulaires</strong> — Penderie, rayonnage, mixtes ; assemblez selon vos besoins.</li> <li><strong>Blocs modulaires</strong> — Penderie, rayonnage, mixtes ; assemblez selon vos besoins.</li>
@@ -21,7 +32,7 @@ export default function Home() {
</ul> </ul>
</section> </section>
<section className={styles.section}> <section id="fonctionnement" className={styles.section}>
<h2>Comment ça marche</h2> <h2>Comment ça marche</h2>
<ol className={styles.steps}> <ol className={styles.steps}>
<li>Configurez en ligne (dimensions, blocs, portes, finitions).</li> <li>Configurez en ligne (dimensions, blocs, portes, finitions).</li>
@@ -31,20 +42,20 @@ export default function Home() {
</ol> </ol>
</section> </section>
<section className={styles.section}> <section id="systeme" className={styles.section}>
<h2>Le système modulaire</h2> <h2>Le système modulaire</h2>
<p className={styles.muted}> <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. 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> </p>
</section> </section>
<section className={styles.section}> <section id="tarifs" className={styles.section}>
<h2>Tarifs</h2> <h2>Tarifs</h2>
<p className={styles.price}>À partir de CHF 3'900.–</p> <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> <p className={styles.muted}>Prix indicatif pour une configuration type. Le configurateur affiche le prix exact selon vos choix.</p>
</section> </section>
<section className={styles.section}> <section id="services" className={styles.section}>
<h2>Services optionnels</h2> <h2>Services optionnels</h2>
<ul className={styles.features}> <ul className={styles.features}>
<li>Prise de mesure sur site</li> <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;
}
}
+168 -50
View File
@@ -6,31 +6,97 @@ import {
fetchSuppliers, createSupplier, updateSupplier, deleteSupplier, fetchSuppliers, createSupplier, updateSupplier, deleteSupplier,
fetchEngineeringRules, createEngineeringRule, updateEngineeringRule, deleteEngineeringRule fetchEngineeringRules, createEngineeringRule, updateEngineeringRule, deleteEngineeringRule
} from '../api/client'; } from '../api/client';
import Icon from '../components/ui/Icon';
import SectionNav from '../components/SectionNav';
import styles from './Settings.module.css'; import styles from './Settings.module.css';
// Human-readable labels for settings keys const SETTINGS_SECTIONS = [
const SETTINGS_META = [ { id: 'parametres-generaux', label: 'Paramètres généraux' },
{ key: 'kit_preparation_fee_chf', label: 'Forfait préparation kit (CHF)', type: 'number', min: 0, step: 10, unit: 'CHF' }, { id: 'regles', label: 'Règles APEX 4.0' },
{ key: 'panel_thickness_mm', label: 'Épaisseur panneaux', type: 'number', min: 1, step: 1, unit: 'mm' }, { id: 'email', label: 'Email (SMTP)' },
{ key: 'plinth_height_min_mm', label: 'Hauteur plinthe minimale', type: 'number', min: 0, step: 5, unit: 'mm' }, { id: 'fournisseurs', label: 'Fournisseurs' }
{ 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_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 = [ const SUPPLIER_TYPES = [
{ value: 'panels', label: 'Panneaux / Matériaux' }, { value: 'panels', label: 'Panneaux / Matériaux' },
{ value: 'hardware', label: 'Quincaillerie' }, { value: 'hardware', label: 'Quincaillerie' },
@@ -90,6 +156,9 @@ export default function Settings() {
// ─── App Settings ────────────────────────────────────────────────────────── // ─── App Settings ──────────────────────────────────────────────────────────
const isSettingDirty = key => String(settingsDraft[key] ?? '') !== String(settings[key] ?? '');
const dirtyCount = ALL_SETTING_KEYS.filter(isSettingDirty).length;
function handleSettingChange(key, value) { function handleSettingChange(key, value) {
setSettingsDraft(prev => ({ ...prev, [key]: value })); setSettingsDraft(prev => ({ ...prev, [key]: value }));
} }
@@ -252,57 +321,106 @@ export default function Settings() {
return ( return (
<div className={styles.page}> <div className={styles.page}>
<div className={styles.header}>
<h1>Paramètres</h1>
</div>
{loading && <p className={styles.muted}>Chargement…</p>} {loading && <p className={styles.muted}>Chargement…</p>}
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
{!loading && !error && ( {!loading && !error && (
<> <>
<SectionNav sections={SETTINGS_SECTIONS} />
{/* ── App Settings ─────────────────────────────────────── */} {/* ── App Settings ─────────────────────────────────────── */}
<section className={styles.section}> <section id="parametres-generaux" className={styles.section}>
<h2>Paramètres généraux</h2> <h2>Paramètres généraux</h2>
<form onSubmit={handleSettingsSave} className={styles.settingsForm}> <form onSubmit={handleSettingsSave}>
<div className={styles.settingsGrid}> <div className={styles.groupsGrid}>
{SETTINGS_META.map(({ key, label, type, min, step, unit }) => ( {SETTINGS_GROUPS.map(group => (
<div className={styles.field} key={key}> <fieldset key={group.id} className={styles.groupCard}>
<label className={styles.label} htmlFor={`setting-${key}`}> <legend className={styles.srOnly}>{group.title}</legend>
{label} <div className={styles.groupHeader} title={group.description}>
{unit && <span className={styles.unit}>{unit}</span>} <span className={styles.groupIcon}>
</label> <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 <input
id={`setting-${key}`} 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} className={styles.input}
type={type} type="number"
min={min} min={min}
step={step} step={step}
value={settingsDraft[key] ?? ''} value={settingsDraft[key] ?? ''}
onChange={e => handleSettingChange(key, e.target.value)} onChange={e => handleSettingChange(key, e.target.value)}
/> />
{unit && <span className={styles.inputUnit}>{unit}</span>}
</div> </div>
</div>
);
})}
</div>
</fieldset>
))} ))}
</div> </div>
<div className={styles.formActions}> <div className={`${styles.saveBar} ${dirtyCount > 0 ? styles.saveBarActive : ''}`}>
<button type="submit" className={styles.btnPrimary} disabled={settingsSaving}> <span className={styles.saveBarStatus}>
{settingsSaving ? 'Sauvegarde…' : 'Sauvegarder'} {settingsMsg ? (
</button> <span className={settingsMsg.ok ? styles.success : styles.error}>{settingsMsg.text}</span>
<button type="button" className={styles.btnGhost} onClick={handleSettingsReset}> ) : dirtyCount > 0 ? (
Annuler `${dirtyCount} modification${dirtyCount > 1 ? 's' : ''} non sauvegardée${dirtyCount > 1 ? 's' : ''}`
</button> ) : (
{settingsMsg && ( 'Aucune modification'
<span className={settingsMsg.ok ? styles.success : styles.error}>
{settingsMsg.text}
</span>
)} )}
</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> </div>
</form> </form>
</section> </section>
{/* ── Engineering Rules ───────────────────────────────────── */} {/* ── Engineering Rules ───────────────────────────────────── */}
<section className={styles.section}> <section id="regles" className={styles.section}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<h2>Règles APEX 4.0 (ingénierie, ergonomie, légalité)</h2> <h2>Règles APEX 4.0 (ingénierie, ergonomie, légalité)</h2>
<button type="button" className={styles.btnPrimary} onClick={openNewRule}> <button type="button" className={styles.btnPrimary} onClick={openNewRule}>
@@ -442,7 +560,7 @@ export default function Settings() {
</section> </section>
{/* ── SMTP / Email info ─────────────────────────────────── */} {/* ── SMTP / Email info ─────────────────────────────────── */}
<section className={styles.section}> <section id="email" className={styles.section}>
<h2>Configuration email (SMTP)</h2> <h2>Configuration email (SMTP)</h2>
<p className={styles.muted}> <p className={styles.muted}>
Ces valeurs sont définies dans le fichier <code>.env</code> du serveur. Redémarrez le serveur après modification. 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> </section>
{/* ── Suppliers ─────────────────────────────────────────── */} {/* ── Suppliers ─────────────────────────────────────────── */}
<section className={styles.section}> <section id="fournisseurs" className={styles.section}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<h2>Fournisseurs</h2> <h2>Fournisseurs</h2>
<button type="button" className={styles.btnPrimary} onClick={openNewSupplier}> <button type="button" className={styles.btnPrimary} onClick={openNewSupplier}>
+237 -1
View File
@@ -1,5 +1,5 @@
.page { .page {
padding: 1.5rem 0 4rem; padding: 0 0 4rem;
} }
.header { .header {
@@ -121,6 +121,242 @@
border-color: var(--accent); 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 ───────────────────────────── */ /* ── SMTP Info Grid ───────────────────────────── */
.smtpGrid { .smtpGrid {
+11
View File
@@ -24,5 +24,16 @@ [email protected]
# Browser timeout in ms # Browser timeout in ms
#HAFELE_BROWSER_TIMEOUT_MS=90000 #HAFELE_BROWSER_TIMEOUT_MS=90000
# Leuba Hiag (catalog import via Algolia)
# The Algolia app id / index / guest key are read live from the catalog page, so no key
# is needed here. Credentials are used to fetch customer-specific prices (via login).
#[email protected]
#LEUBAHIAG_PASSWORD=your-password
# Delay between Algolia pages in ms
#LEUBAHIAG_CRAWL_DELAY_MS=300
# Optional fallbacks if the page structure changes
#LEUBAHIAG_ALGOLIA_APP_ID=QY41R2P2B0
#LEUBAHIAG_ALGOLIA_INDEX=prd_whitelabel_kurlh_products_fr
# OpenAI API Key for room photo analysis (GPT-4 Vision) # OpenAI API Key for room photo analysis (GPT-4 Vision)
OPENAI_API_KEY=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 OPENAI_API_KEY=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
Binary file not shown.
+110
View File
@@ -0,0 +1,110 @@
/**
* Dispositions types intégrées, insérées dans `layout_presets` (une fois par seed_key).
* Le client normalise ensuite blocks / rayons (positions System 32, rayon structurel, etc.).
*/
const TYPE_DEFAULTS = {
penderie: { shelves: 1, drawers: 0, hasRod: true },
tiroirs: { shelves: 1, drawers: 3, hasRod: false },
rayonnage: { shelves: 4, drawers: 0, hasRod: false },
bloc_carre: { shelves: 0, drawers: 0, hasRod: false },
mixte: { shelves: 2, drawers: 2, hasRod: true }
};
function column(key, index, type, widthMm, overrides = {}) {
const hasBack = overrides.hasBack ?? false;
return {
id: `preset-${key}-c${index + 1}`,
type,
moduleCode: type,
widthMm,
minWidth: 300,
maxWidth: type === 'tiroirs' || type === 'mixte' ? 1000 : 1200,
position: index,
locked: false,
...TYPE_DEFAULTS[type],
drawerType: 'visible',
hasBack,
hasBackTop: hasBack,
...overrides
};
}
function layout(key, { name, description, widthMm, heightMm = 2400, depthMm = 600, plinthHeightMm = 100, hasBack = false, columns }) {
return {
seedKey: key,
name,
description,
config: {
widthMm,
heightMm,
depthMm,
plinthHeightMm,
hasBack,
stackEnabled: columns.some((c) => Array.isArray(c[2]?.blockHeightsMm) && c[2].blockHeightsMm.length >= 2),
horizontalSpans: [],
modules: columns.map(([type, w, overrides], i) => column(key, i, type, w, { hasBack, ...overrides }))
}
};
}
/*
* Penderie au-delà de 2000 mm: une colonne en un seul block exige un rayon structurel à mi-hauteur,
* ce qui coupe la penderie (< 1100 mm sous tringle). Les penderies sont donc empilées: block bas de
* 1800 mm (≈ 1710 mm sous tringle, manteaux longs ≥ 1600 mm) + surmeuble; la jonction sert de chapelier.
*/
const HANGING = { blockHeightsMm: [1800, 500], shelves: 0 };
const SURMEUBLE = { blockHeightsMm: [2000, 500] };
const OPEN = { blockDoors: [false] };
export const DEFAULT_LAYOUT_PRESETS = [
layout('penderie-double', {
name: 'Penderie double',
description: 'Deux penderies manteaux longs avec surmeubles',
widthMm: 1200,
columns: [['penderie', 600, HANGING], ['penderie', 600, HANGING]]
}),
layout('classique-3', {
name: 'Classique 3 colonnes',
description: 'Penderie, rayonnage et tiroirs',
widthMm: 1800,
columns: [['penderie', 600, HANGING], ['rayonnage', 600], ['tiroirs', 600]]
}),
layout('dressing-4', {
name: 'Dressing 4 colonnes',
description: 'Deux penderies encadrant tiroirs et rayonnage',
widthMm: 2400,
columns: [['penderie', 600, HANGING], ['tiroirs', 600], ['rayonnage', 600], ['penderie', 600, HANGING]]
}),
layout('grande-penderie', {
name: 'Grande penderie',
description: 'Penderie large, rayonnage et penderie avec tiroirs',
widthMm: 2000,
columns: [['penderie', 1000, HANGING], ['rayonnage', 500], ['mixte', 500, HANGING]]
}),
layout('surmeubles', {
name: 'Avec surmeubles',
description: 'Dressing 4 colonnes surmonté de rangements hauts',
widthMm: 2400,
heightMm: 2600,
columns: [
['penderie', 600, { ...SURMEUBLE, shelves: 0 }],
['rayonnage', 600, SURMEUBLE],
['tiroirs', 600, SURMEUBLE],
['penderie', 600, { ...SURMEUBLE, shelves: 0 }]
]
}),
layout('bibliotheque', {
name: 'Bibliothèque ouverte',
description: 'Trois colonnes de rayonnage sans portes, avec dos',
widthMm: 1500,
heightMm: 2200,
depthMm: 350,
hasBack: true,
columns: [
['rayonnage', 500, { ...OPEN, shelves: 5 }],
['rayonnage', 500, { ...OPEN, shelves: 5 }],
['rayonnage', 500, { ...OPEN, shelves: 5 }]
]
})
];
+138 -8
View File
@@ -10,6 +10,7 @@ const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db
const schemaPath = path.join(__dirname, 'schema.sql'); const schemaPath = path.join(__dirname, 'schema.sql');
let db = null; let db = null;
let sqlJs = null;
/** Création idempotente — bases anciennes sans ces tables (avant migration 014). */ /** Création idempotente — bases anciennes sans ces tables (avant migration 014). */
const HARDWARE_CATALOG_DDL = ` const HARDWARE_CATALOG_DDL = `
@@ -52,23 +53,144 @@ function toSqlitePlaceholders(sql) {
return sql.replace(/\$(\d+)/g, '?'); return sql.replace(/\$(\d+)/g, '?');
} }
function save() { const BACKUP_DIR = path.join(dataDir, 'backups');
if (db && dbPath) { const MAX_BACKUP_DAYS = 14;
const data = db.export();
fs.writeFileSync(dbPath, Buffer.from(data)); /**
* Count formex_materials in an open sql.js Database instance.
*/
function countMaterialsIn(database) {
if (!database) return 0;
try {
const result = database.exec('SELECT COUNT(*) AS c FROM formex_materials');
return Number(result[0]?.values?.[0]?.[0] ?? 0);
} catch {
return 0;
} }
} }
/**
* Peek material count on a DB file without touching the live in-memory DB.
*/
function countMaterialsOnDisk(filePath = dbPath) {
if (!filePath || !fs.existsSync(filePath) || !sqlJs) return 0;
let probe = null;
try {
const buffer = fs.readFileSync(filePath);
if (!buffer.length) return 0;
probe = new sqlJs.Database(buffer);
return countMaterialsIn(probe);
} catch {
return 0;
} finally {
try { probe?.close(); } catch { /* ignore */ }
}
}
/**
* Snapshot the on-disk DB before this process touches it, so scraped data
* (Häfele, Leuba Hiag, Formex) survives accidental wipes. One backup per
* day (first start of the day), kept for MAX_BACKUP_DAYS days.
* Never replaces a richer day's backup with an emptier DB.
*/
function backupExistingDb() {
try {
if (!fs.existsSync(dbPath)) return;
if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
const diskCount = countMaterialsOnDisk(dbPath);
const today = new Date().toISOString().slice(0, 10);
const backupPath = path.join(BACKUP_DIR, `configurateur-${today}.db`);
if (!fs.existsSync(backupPath)) {
if (diskCount === 0) {
console.warn('[db] skip daily backup — on-disk DB has 0 materials (protects older non-empty backups)');
} else {
fs.copyFileSync(dbPath, backupPath);
console.log(`[db] daily backup created: ${backupPath} (${diskCount} materials)`);
}
} else {
const backupCount = countMaterialsOnDisk(backupPath);
if (diskCount > backupCount) {
fs.copyFileSync(dbPath, backupPath);
console.log(`[db] daily backup upgraded: ${backupPath} (${backupCount} → ${diskCount} materials)`);
}
}
const backups = fs.readdirSync(BACKUP_DIR)
.filter((f) => /^configurateur-\d{4}-\d{2}-\d{2}\.db$/.test(f))
.sort();
while (backups.length > MAX_BACKUP_DAYS) {
// Prefer deleting empty backups so a sole non-empty snapshot is kept.
let removeIdx = backups.findIndex((f) => countMaterialsOnDisk(path.join(BACKUP_DIR, f)) === 0);
if (removeIdx < 0) removeIdx = 0;
const removed = backups.splice(removeIdx, 1)[0];
fs.unlinkSync(path.join(BACKUP_DIR, removed));
console.warn(`[db] rotated backup removed: ${removed}`);
}
} catch (e) {
console.error('[db] backup failed:', e.message);
}
}
/**
* Atomically persist the in-memory DB.
*
* Blocks only *catastrophic* overwrites — the failure mode that kept wiping
* scrapes: a stale/empty in-memory process saving over a full on-disk catalog.
* Normal scrapes (count grows) and single-row admin deletes still save.
*/
function save({ allowShrink = false } = {}) {
if (!db || !dbPath) return false;
const memCount = countMaterialsIn(db);
if (!allowShrink && fs.existsSync(dbPath)) {
const diskCount = countMaterialsOnDisk(dbPath);
const emptyClobber = memCount === 0 && diskCount > 0;
const massShrink = diskCount >= 20 && memCount < Math.floor(diskCount * 0.1);
if (emptyClobber || massShrink) {
console.error(
`[db] REFUSING save: in-memory has ${memCount} materials but disk has ${diskCount}. ` +
'Scraped data protected. Restart the server to reload from disk.'
);
try {
if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
fs.writeFileSync(
path.join(BACKUP_DIR, `configurateur-refused-${stamp}.db`),
Buffer.from(db.export())
);
} catch { /* best-effort forensic copy */ }
return false;
}
}
const tmpPath = `${dbPath}.tmp`;
const data = Buffer.from(db.export());
fs.writeFileSync(tmpPath, data);
try {
fs.renameSync(tmpPath, dbPath);
} catch {
// Windows may not allow rename over an existing file while another handle is open.
fs.copyFileSync(tmpPath, dbPath);
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
}
return true;
}
async function getDb() { async function getDb() {
if (db) return db; if (db) return db;
const SQL = await initSqlJs({ sqlJs = await initSqlJs({
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file) locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
}); });
let buffer; let buffer;
if (fs.existsSync(dbPath)) { if (fs.existsSync(dbPath)) {
// sqlJs must be ready before peek-based backup logic.
backupExistingDb();
buffer = fs.readFileSync(dbPath); buffer = fs.readFileSync(dbPath);
} }
db = new SQL.Database(buffer || null); db = new sqlJs.Database(buffer || null);
try { try {
// Ensure full schema exists for legacy/partial DB files. // Ensure full schema exists for legacy/partial DB files.
if (fs.existsSync(schemaPath)) { if (fs.existsSync(schemaPath)) {
@@ -76,11 +198,13 @@ async function getDb() {
db.exec(schemaSql); db.exec(schemaSql);
} }
db.exec(HARDWARE_CATALOG_DDL); db.exec(HARDWARE_CATALOG_DDL);
const data = db.export(); // Persist schema ensures through the guarded saver (never clobber richer disk).
fs.writeFileSync(dbPath, Buffer.from(data)); save();
} catch (e) { } catch (e) {
console.error('[db] ensureHardwareCatalogTables:', e.message); console.error('[db] ensureHardwareCatalogTables:', e.message);
} }
const loaded = countMaterialsIn(db);
console.log(`[db] loaded ${loaded} materials from ${dbPath}`);
return db; return db;
} }
@@ -105,6 +229,12 @@ export const run = async (sql, params = []) => {
return {}; return {};
}; };
/** Force-persist even if material count shrinks (admin bulk delete, etc.). */
export const saveAllowShrink = async () => {
await getDb();
return save({ allowShrink: true });
};
const pool = { const pool = {
query: (sql, params) => query(sql, params) query: (sql, params) => query(sql, params)
}; };
+72 -3
View File
@@ -8,12 +8,81 @@ const dataDir = path.join(__dirname, '..', 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db'); const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db');
const force = process.argv.includes('--force');
const SQL = await initSqlJs({ const SQL = await initSqlJs({
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file) locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
}); });
const db = new SQL.Database();
function countMaterials(database) {
try {
const result = database.exec('SELECT COUNT(*) AS c FROM formex_materials');
return Number(result[0]?.values?.[0]?.[0] ?? 0);
} catch {
return 0;
}
}
function atomicWrite(filePath, database) {
const tmpPath = `${filePath}.tmp`;
fs.writeFileSync(tmpPath, Buffer.from(database.export()));
try {
fs.renameSync(tmpPath, filePath);
} catch {
fs.copyFileSync(tmpPath, filePath);
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
}
}
const dbExists = fs.existsSync(dbPath);
if (dbExists && force) {
// Backup before destroying anything.
const backupDir = path.join(dataDir, 'backups');
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const backupPath = path.join(backupDir, `configurateur-pre-init-${stamp}.db`);
fs.copyFileSync(dbPath, backupPath);
console.log(`Existing database backed up to ${backupPath}`);
}
let db;
if (dbExists && !force) {
// Non-destructive: apply schema (CREATE TABLE IF NOT EXISTS) to the existing DB,
// preserving all data (scraped materials, orders, etc.).
db = new SQL.Database(fs.readFileSync(dbPath));
const before = countMaterials(db);
console.log(
`Existing database found (${before} materials) — applying schema without deleting data (use --force to recreate from scratch).`
);
} else {
db = new SQL.Database();
}
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8'); const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schema); db.exec(schema);
fs.writeFileSync(dbPath, Buffer.from(db.export()));
if (dbExists && !force) {
const after = countMaterials(db);
const diskBefore = (() => {
try {
const probe = new SQL.Database(fs.readFileSync(dbPath));
const n = countMaterials(probe);
probe.close();
return n;
} catch {
return 0;
}
})();
if (diskBefore > 0 && after < diskBefore) {
console.error(
`REFUSING to write: schema apply would shrink materials ${diskBefore} → ${after}. Aborting.`
);
db.close();
process.exit(1);
}
}
atomicWrite(dbPath, db);
console.log(`Schema applied. Materials on disk: ${countMaterials(db)}`);
db.close(); db.close();
console.log('Schema applied.');
+22
View File
@@ -66,6 +66,28 @@ CREATE TABLE IF NOT EXISTS orders (
created_at TEXT DEFAULT (datetime('now')) created_at TEXT DEFAULT (datetime('now'))
); );
-- Configurations d'armoires sauvegardées pour reprise ultérieure
CREATE TABLE IF NOT EXISTS saved_designs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
config TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
-- Dispositions types proposées à l'étape Espace (exemples intégrés + dispositions enregistrées)
CREATE TABLE IF NOT EXISTS layout_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
seed_key TEXT UNIQUE,
name TEXT NOT NULL,
description TEXT,
config TEXT NOT NULL,
sort_order INTEGER DEFAULT 100,
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
-- Matériaux Formex (données scrapées) -- Matériaux Formex (données scrapées)
CREATE TABLE IF NOT EXISTS formex_materials ( CREATE TABLE IF NOT EXISTS formex_materials (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
+35 -2
View File
@@ -14,10 +14,43 @@ if (!fs.existsSync(dbPath)) {
const SQL = await initSqlJs({ const SQL = await initSqlJs({
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file) locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
}); });
function countMaterials(database) {
try {
const result = database.exec('SELECT COUNT(*) AS c FROM formex_materials');
return Number(result[0]?.values?.[0]?.[0] ?? 0);
} catch {
return 0;
}
}
function atomicWrite(filePath, database) {
const tmpPath = `${filePath}.tmp`;
fs.writeFileSync(tmpPath, Buffer.from(database.export()));
try {
fs.renameSync(tmpPath, filePath);
} catch {
fs.copyFileSync(tmpPath, filePath);
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
}
}
const buffer = fs.readFileSync(dbPath); const buffer = fs.readFileSync(dbPath);
const db = new SQL.Database(buffer); const db = new SQL.Database(buffer);
const before = countMaterials(db);
const seedSql = fs.readFileSync(path.join(__dirname, 'seeds.sql'), 'utf8'); const seedSql = fs.readFileSync(path.join(__dirname, 'seeds.sql'), 'utf8');
db.exec(seedSql); db.exec(seedSql);
fs.writeFileSync(dbPath, Buffer.from(db.export()));
const after = countMaterials(db);
if (before > 0 && after < before) {
console.error(
`REFUSING to write seeds: materials would shrink ${before} → ${after}. Aborting.`
);
db.close(); db.close();
console.log('Seeds applied.'); process.exit(1);
}
atomicWrite(dbPath, db);
db.close();
console.log(`Seeds applied. Materials preserved: ${after} (was ${before}).`);
+1 -1
View File
@@ -4,7 +4,7 @@ INSERT OR REPLACE INTO settings (key, value) VALUES
('kit_preparation_fee_chf', '350'), ('kit_preparation_fee_chf', '350'),
('panel_thickness_mm', '19'), ('panel_thickness_mm', '19'),
('plinth_height_min_mm', '60'), ('plinth_height_min_mm', '60'),
('plinth_height_max_mm', '160'), ('plinth_height_max_mm', '200'),
('niche_clearance_multiplier', '2'), ('niche_clearance_multiplier', '2'),
('min_clearance_mm', '20'), ('min_clearance_mm', '20'),
('sliding_doors_min_width_mm', '1200'); ('sliding_doors_min_width_mm', '1200');
+52 -2
View File
@@ -11,6 +11,7 @@ dotenv.config({ path: path.join(__dirname, '.env') });
import { getProducts } from './routes/products.js'; import { getProducts } from './routes/products.js';
import { getBlockTypes } from './routes/blockTypes.js'; import { getBlockTypes } from './routes/blockTypes.js';
import { getFinishes } from './routes/finishes.js'; import { getFinishes } from './routes/finishes.js';
import { getMaterialImage } from './routes/materialImage.js';
import { getServiceOptions } from './routes/serviceOptions.js'; import { getServiceOptions } from './routes/serviceOptions.js';
import { getSettings, updateSettings } from './routes/settings.js'; import { getSettings, updateSettings } from './routes/settings.js';
import { import {
@@ -38,12 +39,17 @@ import {
streamFormexSync, streamFormexSync,
triggerHaefeleSync, triggerHaefeleSync,
streamHaefeleSync, streamHaefeleSync,
triggerLeubaHiagSync,
streamLeubaHiagSync,
getLeubaHiagMaterials,
getLeubaHiagTargets,
getLogs, getLogs,
getStatus, getStatus,
getFormexMaterials, getFormexMaterials,
getFormexMaterial, getFormexMaterial,
createFormexMaterial, createFormexMaterial,
updateFormexMaterial, updateFormexMaterial,
setFormexMaterialActive,
deleteFormexMaterial, deleteFormexMaterial,
getFormexCollections, getFormexCollections,
getFormexCategories, getFormexCategories,
@@ -79,13 +85,26 @@ import {
exportCncRoute, exportCncRoute,
exportMprRoute, exportMprRoute,
exportMprPieceRoute, exportMprPieceRoute,
generateHardwareListRoute generateHardwareListRoute,
generateCuttingPlanRoute,
exportExcelRoute
} from './routes/plans.js'; } from './routes/plans.js';
import { import {
designTipsRoute, designTipsRoute,
recommendMaterialsRoute, recommendMaterialsRoute,
renderRoute renderRoute
} from './routes/ai.js'; } from './routes/ai.js';
import {
getSavedDesigns,
createSavedDesign,
updateSavedDesign,
deleteSavedDesign
} from './routes/savedDesigns.js';
import {
getLayoutPresets,
createLayoutPreset,
deleteLayoutPreset
} from './routes/layoutPresets.js';
import { initScheduler } from './services/scheduler.js'; import { initScheduler } from './services/scheduler.js';
const clientDist = path.join(__dirname, '..', 'client', 'dist'); const clientDist = path.join(__dirname, '..', 'client', 'dist');
@@ -100,6 +119,7 @@ app.use(express.json());
app.get('/api/products', getProducts); app.get('/api/products', getProducts);
app.get('/api/block-types', getBlockTypes); app.get('/api/block-types', getBlockTypes);
app.get('/api/finishes', getFinishes); app.get('/api/finishes', getFinishes);
app.get('/api/material-image', getMaterialImage);
app.get('/api/service-options', getServiceOptions); app.get('/api/service-options', getServiceOptions);
app.get('/api/settings', getSettings); app.get('/api/settings', getSettings);
app.put('/api/settings', updateSettings); app.put('/api/settings', updateSettings);
@@ -108,6 +128,13 @@ app.post('/api/engineering-rules', createEngineeringRule);
app.put('/api/engineering-rules/:id', updateEngineeringRule); app.put('/api/engineering-rules/:id', updateEngineeringRule);
app.delete('/api/engineering-rules/:id', deleteEngineeringRule); app.delete('/api/engineering-rules/:id', deleteEngineeringRule);
app.post('/api/price', postPrice); app.post('/api/price', postPrice);
app.get('/api/saved-designs', getSavedDesigns);
app.post('/api/saved-designs', createSavedDesign);
app.put('/api/saved-designs/:id', updateSavedDesign);
app.delete('/api/saved-designs/:id', deleteSavedDesign);
app.get('/api/layout-presets', getLayoutPresets);
app.post('/api/layout-presets', createLayoutPreset);
app.delete('/api/layout-presets/:id', deleteLayoutPreset);
app.get('/api/orders', getOrders); app.get('/api/orders', getOrders);
app.post('/api/orders', postOrder); app.post('/api/orders', postOrder);
app.get('/api/orders/:id/supplier-vouchers', getOrderSupplierVouchers); app.get('/api/orders/:id/supplier-vouchers', getOrderSupplierVouchers);
@@ -129,6 +156,9 @@ app.post('/api/sync/formex', triggerFormexSync);
app.get('/api/sync/formex/stream', streamFormexSync); app.get('/api/sync/formex/stream', streamFormexSync);
app.post('/api/sync/haefele', triggerHaefeleSync); app.post('/api/sync/haefele', triggerHaefeleSync);
app.get('/api/sync/haefele/stream', streamHaefeleSync); app.get('/api/sync/haefele/stream', streamHaefeleSync);
app.get('/api/sync/leubahiag/targets', getLeubaHiagTargets);
app.post('/api/sync/leubahiag', triggerLeubaHiagSync);
app.get('/api/sync/leubahiag/stream', streamLeubaHiagSync);
app.get('/api/sync/logs', getLogs); app.get('/api/sync/logs', getLogs);
app.get('/api/sync/status', getStatus); app.get('/api/sync/status', getStatus);
@@ -137,11 +167,13 @@ app.get('/api/formex/materials', getFormexMaterials);
app.get('/api/formex/materials/:id', getFormexMaterial); app.get('/api/formex/materials/:id', getFormexMaterial);
app.post('/api/formex/materials', createFormexMaterial); app.post('/api/formex/materials', createFormexMaterial);
app.put('/api/formex/materials/:id', updateFormexMaterial); app.put('/api/formex/materials/:id', updateFormexMaterial);
app.patch('/api/formex/materials/:id/active', setFormexMaterialActive);
app.delete('/api/formex/materials/:id', deleteFormexMaterial); app.delete('/api/formex/materials/:id', deleteFormexMaterial);
app.get('/api/formex/collections', getFormexCollections); app.get('/api/formex/collections', getFormexCollections);
app.get('/api/formex/categories', getFormexCategories); app.get('/api/formex/categories', getFormexCategories);
app.get('/api/formex/suppliers', getFormexSuppliers); app.get('/api/formex/suppliers', getFormexSuppliers);
app.get('/api/haefele/materials', getHaefeleMaterials); app.get('/api/haefele/materials', getHaefeleMaterials);
app.get('/api/leubahiag/materials', getLeubaHiagMaterials);
// Room analysis routes (AI-powered) // Room analysis routes (AI-powered)
app.post('/api/room/analyze', uploadMiddleware, analyzeRoom); app.post('/api/room/analyze', uploadMiddleware, analyzeRoom);
@@ -173,9 +205,11 @@ app.post('/api/ai/render', renderRoute);
// Plans and cutting list routes // Plans and cutting list routes
app.post('/api/plans/generate', generatePlansRoute); app.post('/api/plans/generate', generatePlansRoute);
app.post('/api/plans/cutting-list', generateCuttingListRoute); app.post('/api/plans/cutting-list', generateCuttingListRoute);
app.post('/api/plans/cutting-plan', generateCuttingPlanRoute);
app.post('/api/plans/hardware-list', generateHardwareListRoute); app.post('/api/plans/hardware-list', generateHardwareListRoute);
app.post('/api/plans/export-text', exportCuttingListText); app.post('/api/plans/export-text', exportCuttingListText);
app.post('/api/plans/export-pdf', exportPDFHtml); app.post('/api/plans/export-pdf', exportPDFHtml);
app.post('/api/plans/export-excel', exportExcelRoute);
app.post('/api/plans/export-cnc', exportCncRoute); app.post('/api/plans/export-cnc', exportCncRoute);
app.post('/api/plans/export-mpr', exportMprRoute); app.post('/api/plans/export-mpr', exportMprRoute);
app.post('/api/plans/export-mpr-piece', exportMprPieceRoute); app.post('/api/plans/export-mpr-piece', exportMprPieceRoute);
@@ -242,7 +276,8 @@ async function applyMigration007() {
await run(`INSERT OR IGNORE INTO suppliers (code, name, email, type) VALUES await run(`INSERT OR IGNORE INTO suppliers (code, name, email, type) VALUES
('formex', 'Formex', '[email protected]', 'panels'), ('formex', 'Formex', '[email protected]', 'panels'),
('blum', 'Blum Switzerland', '[email protected]', 'hardware'), ('blum', 'Blum Switzerland', '[email protected]', 'hardware'),
('haefele', 'Hafele', '[email protected]', 'materials')`); ('haefele', 'Hafele', '[email protected]', 'materials'),
('leubahiag', 'Leuba Hiag', '[email protected]', 'materials')`);
// Assign existing rows to their suppliers // Assign existing rows to their suppliers
await run(`UPDATE formex_materials SET supplier_id = (SELECT id FROM suppliers WHERE code = 'formex') WHERE supplier_id IS NULL`); await run(`UPDATE formex_materials SET supplier_id = (SELECT id FROM suppliers WHERE code = 'formex') WHERE supplier_id IS NULL`);
@@ -378,6 +413,21 @@ async function applyMigration013() {
} }
await applyMigration013(); await applyMigration013();
// Apply migration 014: widen plinth max to 200 mm (remarque client) without clobbering custom values.
async function applyMigration014() {
try {
await run('INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2)', ['plinth_height_min_mm', '60']);
await run('INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2)', ['plinth_height_max_mm', '200']);
// Bump legacy default 160 -> 200; leave any other (customized) value untouched.
await run("UPDATE settings SET value = '200' WHERE key = 'plinth_height_max_mm' AND value = '160'");
console.log('[migration-014] Plinth max widened to 200 mm.');
} catch (err) {
console.error('[migration-014] Error:', err.message);
}
}
await applyMigration014();
await ensureEngineeringRulesSeed(); await ensureEngineeringRulesSeed();
// Initialize the scheduler for automated sync // Initialize the scheduler for automated sync
+2 -1
View File
@@ -9,7 +9,8 @@
"dev": "node --watch index.js", "dev": "node --watch index.js",
"db:init": "node db/init.js", "db:init": "node db/init.js",
"db:seed": "node db/seed.js", "db:seed": "node db/seed.js",
"db:migrate": "node db/migrate.js" "db:migrate": "node db/migrate.js",
"db:restore-materials": "node scripts/restore-materials-backup.mjs"
}, },
"dependencies": { "dependencies": {
"cheerio": "^1.0.0", "cheerio": "^1.0.0",
+17 -3
View File
@@ -41,7 +41,9 @@ const DEFAULT_ENGINEERING_RULES = [
details: details:
"Recul face plinthe >= 50 mm par rapport à la façade.\n" + "Recul face plinthe >= 50 mm par rapport à la façade.\n" +
'Calcul de référence: porte 19 + jeu 3 + retrait base 28 = 50 mm.\n' + 'Calcul de référence: porte 19 + jeu 3 + retrait base 28 = 50 mm.\n' +
"Avec plinthe 19 mm: centre pied Axilo à 69 mm des bords avant/arrière." 'AXILO 78 Press-fit réf. 637.76.710: centre avant à 59 mm du chant avant (plinthe finie 19 + 40), ' +
'centre arrière à 50 mm du chant arrière; 4 perçages borgnes Ø10 × 12 mm par platine, entraxe 64 mm.\n' +
'Répartition: 4 pieds aux extrémités de l\'armoire + 2 pieds (avant/arrière) tous les 600 mm.'
}, },
{ {
sort_order: 60, sort_order: 60,
@@ -144,8 +146,20 @@ const DEFAULT_ENGINEERING_RULES = [
{ {
sort_order: 210, sort_order: 210,
chapter: '4.C', chapter: '4.C',
title: 'Tiroirs intérieurs - hauteurs de façades', title: 'Tiroirs intérieurs - hauteurs de côté (Blum Legrabox)',
details: 'Variantes autorisées: 60, 90, 128, 177, 240 mm.' details:
'Hauteurs de côté autorisées: 66 (N), 90 (M), 128 (K), 177 (C), 240 (F) mm.\n' +
'Hauteurs de niche SMS correspondantes: 80, 106, 144, 193, 257 mm (à partir de 1/6, 1/6, 2/6, 2/6, 3/6 — 1/6 = 127 mm).'
},
{
sort_order: 215,
chapter: '4.C',
title: 'Tiroirs Legrabox - débit 16 mm et perçages CNC',
details:
'Fond 16 mm: L = NL − 10, l = X − 35, feuillure 38 × 8 mm G+D (X = largeur libre corps).\n' +
'Paroi arrière 16 mm: l = X − 38, h = 39/63/101/148/212 mm selon N/M/K/C/F.\n' +
'Coulisses (Système 32, depuis chant avant): 40 kg → 37, 69, 197, 229, 261, 293, 357 · 70 kg → 37, 69, 261, 293, 357.\n' +
'Façade INSERTA: cheville expansible Ø 10 mm, profondeur > 12 mm.'
}, },
{ {
sort_order: 220, sort_order: 220,
+14 -4
View File
@@ -1,15 +1,25 @@
import pool from '../db/index.js'; import pool from '../db/index.js';
const CONFIGURATOR_MATERIAL_FILTER = `
active = 1
AND NOT (
supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele')
OR LOWER(COALESCE(collection, '')) = 'hafele'
OR formex_code LIKE 'HAF-%'
)
`;
/** /**
* GET /api/finishes * GET /api/finishes
* Returns active Formex materials as finishes for the configurator * Returns active panel materials as finishes for the configurator
* Falls back to legacy finishes if no Formex materials exist * Falls back to legacy finishes if no Formex materials exist
*/ */
export async function getFinishes(req, res) { export async function getFinishes(req, res) {
try { try {
// Check if we have active Formex materials // Hardware suppliers can share formex_materials for catalog synchronization,
// but their products must never be offered as panel finishes.
const formexResult = await pool.query( const formexResult = await pool.query(
`SELECT COUNT(*) as count FROM formex_materials WHERE active = 1` `SELECT COUNT(*) as count FROM formex_materials WHERE ${CONFIGURATOR_MATERIAL_FILTER}`
); );
const hasFormexMaterials = (formexResult.rows[0]?.count || 0) > 0; const hasFormexMaterials = (formexResult.rows[0]?.count || 0) > 0;
@@ -32,7 +42,7 @@ export async function getFinishes(req, res) {
dominant_color as hexColor, dominant_color as hexColor,
'formex' as source 'formex' as source
FROM formex_materials FROM formex_materials
WHERE active = 1 WHERE ${CONFIGURATOR_MATERIAL_FILTER}
ORDER BY collection, name ORDER BY collection, name
`); `);
+125
View File
@@ -0,0 +1,125 @@
import pool, { run } from '../db/index.js';
import { DEFAULT_LAYOUT_PRESETS } from '../db/defaultLayouts.js';
const MAX_NAME_LENGTH = 100;
const PRESET_COLUMNS = 'id, seed_key, name, description, config, sort_order, created_at, updated_at';
let seeded = null;
/**
* Synchronise les dispositions intégrées avec leur définition en code (insertion ou mise à jour).
* Une disposition intégrée supprimée reste masquée (active = 0).
*/
function ensureDefaultLayouts() {
if (!seeded) {
seeded = (async () => {
const { rows } = await pool.query(
'SELECT seed_key, name, description, config, sort_order FROM layout_presets WHERE seed_key IS NOT NULL'
);
const existing = new Map(rows.map((r) => [r.seed_key, r]));
for (const [index, preset] of DEFAULT_LAYOUT_PRESETS.entries()) {
const config = JSON.stringify(preset.config);
const row = existing.get(preset.seedKey);
if (row) {
const unchanged = row.name === preset.name && row.description === preset.description
&& row.config === config && Number(row.sort_order) === index;
if (!unchanged) {
await run(
`UPDATE layout_presets
SET name = $1, description = $2, config = $3, sort_order = $4, updated_at = datetime('now')
WHERE seed_key = $5`,
[preset.name, preset.description, config, index, preset.seedKey]
);
}
continue;
}
await run(
`INSERT INTO layout_presets (seed_key, name, description, config, sort_order)
VALUES ($1, $2, $3, $4, $5)`,
[preset.seedKey, preset.name, preset.description, config, index]
);
}
})().catch((err) => {
seeded = null;
throw err;
});
}
return seeded;
}
function parsePreset(row) {
if (!row) return null;
let config = null;
try {
config = JSON.parse(row.config);
} catch { /* config illisible → null */ }
return { ...row, builtin: row.seed_key != null, config };
}
export async function getLayoutPresets(req, res) {
try {
await ensureDefaultLayouts();
const { rows } = await pool.query(
`SELECT ${PRESET_COLUMNS}
FROM layout_presets
WHERE active = 1
ORDER BY sort_order ASC, id ASC`
);
res.json(rows.map(parsePreset));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function createLayoutPreset(req, res) {
try {
const name = String(req.body?.name || '').trim();
const description = String(req.body?.description || '').trim() || null;
const config = req.body?.config;
if (!name) return res.status(400).json({ error: 'Le nom est requis.' });
if (name.length > MAX_NAME_LENGTH) {
return res.status(400).json({ error: `Le nom ne peut pas dépasser ${MAX_NAME_LENGTH} caractères.` });
}
if (!config || typeof config !== 'object' || Array.isArray(config) || !Array.isArray(config.modules) || config.modules.length === 0) {
return res.status(400).json({ error: 'Disposition invalide (au moins une colonne requise).' });
}
await ensureDefaultLayouts();
await run(
`INSERT INTO layout_presets (name, description, config, sort_order)
VALUES ($1, $2, $3, 1000)`,
[name, description, JSON.stringify(config)]
);
const { rows } = await pool.query(
`SELECT ${PRESET_COLUMNS}
FROM layout_presets
ORDER BY id DESC
LIMIT 1`
);
res.status(201).json(parsePreset(rows[0]));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function deleteLayoutPreset(req, res) {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id < 1) {
return res.status(400).json({ error: 'Identifiant invalide.' });
}
const { rows } = await pool.query('SELECT id, seed_key FROM layout_presets WHERE id = $1 LIMIT 1', [id]);
if (rows.length === 0) return res.status(404).json({ error: 'Disposition introuvable.' });
if (rows[0].seed_key != null) {
await run(`UPDATE layout_presets SET active = 0, updated_at = datetime('now') WHERE id = $1`, [id]);
} else {
await run('DELETE FROM layout_presets WHERE id = $1', [id]);
}
res.status(204).end();
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* GET /api/material-image?url=<encoded absolute image url>
*
* Same-origin proxy for remote material swatch images.
*
* The 3D preview turns swatch images into WebGL textures via a <canvas>.
* Browsers taint a canvas (and refuse `getImageData` / texture upload) when the
* source image comes from another origin without CORS headers. Suppliers such
* as Leuba Hiag serve their swatches from their own domain with no
* `Access-Control-Allow-Origin`, so the textures silently fail and every
* element falls back to its default colour.
*
* By streaming the bytes back through our own origin the canvas stays clean and
* the material textures/colours apply correctly in 3D.
*/
/** Only proxy http(s) images from these hosts (defence against SSRF). */
const ALLOWED_HOST_SUFFIXES = [
'leubahiag.ch',
'formex.ch',
'haefele.ch',
'haefele.com',
'haefele.de',
];
/** Small in-memory cache so repeated texture loads don't refetch remotely. */
const cache = new Map(); // url -> { buffer, contentType, expires }
const MAX_CACHE_ENTRIES = 400;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
function isAllowedHost(hostname) {
const host = hostname.toLowerCase();
return ALLOWED_HOST_SUFFIXES.some(
(suffix) => host === suffix || host.endsWith(`.${suffix}`)
);
}
export async function getMaterialImage(req, res) {
const raw = req.query.url;
if (!raw || typeof raw !== 'string') {
return res.status(400).json({ error: 'Missing url parameter' });
}
let target;
try {
target = new URL(raw);
} catch {
return res.status(400).json({ error: 'Invalid url' });
}
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
return res.status(400).json({ error: 'Unsupported protocol' });
}
if (!isAllowedHost(target.hostname)) {
return res.status(403).json({ error: 'Host not allowed' });
}
const key = target.toString();
const now = Date.now();
const cached = cache.get(key);
if (cached && cached.expires > now) {
res.set('Content-Type', cached.contentType);
res.set('Access-Control-Allow-Origin', '*');
res.set('Cache-Control', 'public, max-age=86400');
return res.send(cached.buffer);
}
try {
const response = await fetch(key, { headers: { Accept: 'image/*' } });
if (!response.ok) {
return res.status(502).json({ error: `Upstream ${response.status}` });
}
const contentType = response.headers.get('content-type') || 'image/jpeg';
if (!contentType.startsWith('image/')) {
return res.status(415).json({ error: 'Not an image' });
}
const buffer = Buffer.from(await response.arrayBuffer());
if (cache.size >= MAX_CACHE_ENTRIES) {
const oldestKey = cache.keys().next().value;
if (oldestKey !== undefined) cache.delete(oldestKey);
}
cache.set(key, { buffer, contentType, expires: now + CACHE_TTL_MS });
res.set('Content-Type', contentType);
res.set('Access-Control-Allow-Origin', '*');
res.set('Cache-Control', 'public, max-age=86400');
return res.send(buffer);
} catch (err) {
return res.status(502).json({ error: err.message });
}
}
+64
View File
@@ -8,6 +8,8 @@ import { generateCncProgram } from '../services/cncGenerator.js';
import { validateConfig } from '../services/configValidation.js'; import { validateConfig } from '../services/configValidation.js';
import { generateHardwareList } from '../services/hardwareListEngine.js'; import { generateHardwareList } from '../services/hardwareListEngine.js';
import { generateMprZipBuffer, generateMprPieceDownload } from '../services/mprGenerator.js'; import { generateMprZipBuffer, generateMprPieceDownload } from '../services/mprGenerator.js';
import { generateCuttingPlan } from '../services/cuttingPlanEngine.js';
import { buildCuttingListXlsx } from '../services/exportExcel.js';
/** /**
* POST /api/plans/generate * POST /api/plans/generate
@@ -241,6 +243,68 @@ export async function exportMprPieceRoute(req, res) {
} }
} }
/**
* POST /api/plans/cutting-plan
* Génère le plan de coupe (calepinage) avec % de chute par panneau et global.
*/
export async function generateCuttingPlanRoute(req, res) {
try {
const { config, modules = [], colors = {}, stockBoard, kerfMm, trimMm, allowRotation } = req.body || {};
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
const validation = validateConfig(config);
if (validation.errors.length > 0) {
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
}
const cuttingList = generateCuttingList(config, colors, modules);
const cuttingPlan = generateCuttingPlan(cuttingList, { stockBoard, kerfMm, trimMm, allowRotation });
res.json({ success: true, cuttingPlan, warnings: validation.warnings });
} catch (error) {
console.error('Cutting plan error:', error);
res.status(500).json({ error: error.message || 'Erreur de génération du plan de coupe' });
}
}
/**
* POST /api/plans/export-excel
* Export de la liste de débitage (+ quincaillerie + calepinage) au format Excel (.xlsx).
*/
export async function exportExcelRoute(req, res) {
try {
const { config, modules = [], colors = {} } = req.body || {};
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
const validation = validateConfig(config);
if (validation.errors.length > 0) {
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
}
const cuttingList = generateCuttingList(config, colors, modules);
const cuttingPlan = generateCuttingPlan(cuttingList, {});
let hardware = null;
try {
hardware = await generateHardwareList({ config, modules, colors });
} catch (e) {
console.warn('[export-excel] hardware list unavailable:', e.message);
}
const buffer = await buildCuttingListXlsx({ cuttingList, hardware, cuttingPlan });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', 'attachment; filename="liste-debitage.xlsx"');
res.send(Buffer.from(buffer));
} catch (error) {
console.error('Export Excel error:', error);
res.status(500).json({ error: error.message || 'Erreur d\'export Excel' });
}
}
/** /**
* POST /api/plans/hardware-list * POST /api/plans/hardware-list
* Generate bill of materials for hardware/quincaillerie with prices (best-effort). * Generate bill of materials for hardware/quincaillerie with prices (best-effort).
+111
View File
@@ -0,0 +1,111 @@
import pool, { run } from '../db/index.js';
const MAX_NAME_LENGTH = 100;
function parseDesign(row) {
if (!row) return null;
try {
return { ...row, config: JSON.parse(row.config) };
} catch {
return { ...row, config: null };
}
}
export async function getSavedDesigns(req, res) {
try {
const { rows } = await pool.query(
`SELECT id, name, config, created_at, updated_at
FROM saved_designs
ORDER BY updated_at DESC, id DESC`
);
res.json(rows.map(parseDesign));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function createSavedDesign(req, res) {
try {
const name = String(req.body?.name || '').trim();
const config = req.body?.config;
if (!name) return res.status(400).json({ error: 'Le nom est requis.' });
if (name.length > MAX_NAME_LENGTH) {
return res.status(400).json({ error: `Le nom ne peut pas dépasser ${MAX_NAME_LENGTH} caractères.` });
}
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return res.status(400).json({ error: 'Configuration invalide.' });
}
await run(
`INSERT INTO saved_designs (name, config)
VALUES ($1, $2)`,
[name, JSON.stringify(config)]
);
const { rows } = await pool.query(
`SELECT id, name, config, created_at, updated_at
FROM saved_designs
ORDER BY id DESC
LIMIT 1`
);
res.status(201).json(parseDesign(rows[0]));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function updateSavedDesign(req, res) {
try {
const id = Number(req.params.id);
const name = String(req.body?.name || '').trim();
const config = req.body?.config;
if (!Number.isInteger(id) || id < 1) {
return res.status(400).json({ error: 'Identifiant invalide.' });
}
if (!name) return res.status(400).json({ error: 'Le nom est requis.' });
if (name.length > MAX_NAME_LENGTH) {
return res.status(400).json({ error: `Le nom ne peut pas dépasser ${MAX_NAME_LENGTH} caractères.` });
}
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return res.status(400).json({ error: 'Configuration invalide.' });
}
const { rows } = await pool.query('SELECT id FROM saved_designs WHERE id = $1 LIMIT 1', [id]);
if (rows.length === 0) return res.status(404).json({ error: 'Armoire sauvegardée introuvable.' });
await run(
`UPDATE saved_designs
SET name = $1, config = $2, updated_at = datetime('now')
WHERE id = $3`,
[name, JSON.stringify(config), id]
);
const { rows: updatedRows } = await pool.query(
`SELECT id, name, config, created_at, updated_at
FROM saved_designs
WHERE id = $1
LIMIT 1`,
[id]
);
res.json(parseDesign(updatedRows[0]));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function deleteSavedDesign(req, res) {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id < 1) {
return res.status(400).json({ error: 'Identifiant invalide.' });
}
const { rows } = await pool.query('SELECT id FROM saved_designs WHERE id = $1 LIMIT 1', [id]);
if (rows.length === 0) return res.status(404).json({ error: 'Armoire sauvegardée introuvable.' });
await run('DELETE FROM saved_designs WHERE id = $1', [id]);
res.status(204).end();
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+172
View File
@@ -1,5 +1,6 @@
import { sync, getSyncLogs, getMaterials } from '../services/formexCrawler.js'; import { sync, getSyncLogs, getMaterials } from '../services/formexCrawler.js';
import { syncHaefeleByUrls } from '../services/haefeleCrawler.js'; import { syncHaefeleByUrls } from '../services/haefeleCrawler.js';
import { syncLeubaHiag, LEUBA_HIAG_TARGETS } from '../services/leubaHiagCrawler.js';
import { getSchedulerStatus } from '../services/scheduler.js'; import { getSchedulerStatus } from '../services/scheduler.js';
import pool, { run } from '../db/index.js'; import pool, { run } from '../db/index.js';
@@ -206,6 +207,150 @@ export async function getHaefeleMaterials(req, res) {
} }
} }
/**
* GET /api/sync/leubahiag/targets
* List the curated Leuba Hiag catalog lines available for import.
*/
export function getLeubaHiagTargets(req, res) {
res.json(LEUBA_HIAG_TARGETS.map(({ key, label, tier }) => ({ key, label, tier })));
}
function parseTargetsParam(value) {
if (Array.isArray(value)) return value.filter(Boolean);
if (typeof value === 'string' && value.trim()) {
return value.split(/[\n,;]+/g).map((v) => v.trim()).filter(Boolean);
}
return null;
}
/**
* POST /api/sync/leubahiag
* Trigger a Leuba Hiag curated catalog sync (non-streaming).
* Body: { targets?: string[], activate?: boolean, credentials?: {...} }
*/
export async function triggerLeubaHiagSync(req, res) {
try {
const targets = parseTargetsParam(req.body?.targets);
const activate = req.body?.activate === true || req.body?.activate === '1';
const fetchPrices = req.body?.fetchPrices !== false && req.body?.fetchPrices !== '0';
const credentials = {
username: req.body?.username || req.body?.email,
password: req.body?.password,
};
const result = await syncLeubaHiag({ targets, activate, fetchPrices, credentials }, () => {});
if (!result.success) {
return res.status(500).json({
success: false,
error: result.error || 'Sync Leuba Hiag échouée',
itemsCount: result.itemsCount || 0,
});
}
res.json({
success: true,
message: `Synchronisation Leuba Hiag terminée: ${result.itemsCount} produits`,
itemsCount: result.itemsCount,
createdCount: result.createdCount || 0,
updatedCount: result.updatedCount || 0,
pricedCount: result.pricedCount || 0,
duration: result.duration,
});
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
/**
* GET /api/sync/leubahiag/stream
* SSE endpoint for Leuba Hiag sync progress.
* Query: targets=key1,key2 (optional), activate=1 (optional)
*/
export async function streamLeubaHiagSync(req, res) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
const emit = (data) => {
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
req.on('close', () => {
// client disconnected
});
const targets = parseTargetsParam(req.query.targets);
const activate = req.query.activate === '1' || req.query.activate === 'true';
const fetchPrices = req.query.prices !== '0' && req.query.prices !== 'false';
try {
await syncLeubaHiag({ targets, activate, fetchPrices }, emit);
} catch (err) {
emit({ type: 'error', error: err.message });
}
res.end();
}
/**
* GET /api/leubahiag/materials
* List Leuba Hiag materials that were imported.
*/
export async function getLeubaHiagMaterials(req, res) {
try {
const { search = '', active, page = '1', limit = '25' } = req.query;
const pageNum = Math.max(1, parseInt(page, 10));
const limitNum = Math.min(200, Math.max(1, parseInt(limit, 10)));
const offset = (pageNum - 1) * limitNum;
const whereClauses = [
`(supplier_id = (SELECT id FROM suppliers WHERE code = 'leubahiag') OR formex_code LIKE 'LH-%')`
];
const params = [];
if (search) {
whereClauses.push('(name LIKE ? OR formex_code LIKE ? OR collection LIKE ?)');
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
if (active === '1' || active === 'true') {
whereClauses.push('active = 1');
} else if (active === '0' || active === 'false') {
whereClauses.push('active = 0');
}
const whereSQL = `WHERE ${whereClauses.join(' AND ')}`;
const countResult = await pool.query(
`SELECT COUNT(*) as total FROM formex_materials ${whereSQL}`,
params
);
const total = countResult.rows[0]?.total || 0;
const { rows } = await pool.query(
`SELECT id, formex_code, name, collection, category, thickness_mm, width_mm, height_mm,
price_chf, price_unit, image_url, formex_url, active, last_synced_at
FROM formex_materials
${whereSQL}
ORDER BY collection, name
LIMIT ? OFFSET ?`,
[...params, limitNum, offset]
);
res.json({
materials: rows,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum)
}
});
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/** /**
* GET /api/sync/logs * GET /api/sync/logs
* Get sync history * Get sync history
@@ -439,6 +584,33 @@ export async function updateFormexMaterial(req, res) {
} }
} }
/**
* PATCH /api/formex/materials/:id/active
* Toggle only the `active` flag of a material (lossless: never touches other columns).
* Body: { active: boolean }
*/
export async function setFormexMaterialActive(req, res) {
try {
const { id } = req.params;
const active = req.body?.active === true || req.body?.active === 1 || req.body?.active === '1';
const existing = await pool.query('SELECT id FROM formex_materials WHERE id = ?', [id]);
if (existing.rows.length === 0) {
return res.status(404).json({ error: 'Matériau non trouvé' });
}
await run('UPDATE formex_materials SET active = ? WHERE id = ?', [active ? 1 : 0, id]);
const { rows } = await pool.query(
'SELECT id, formex_code, name, active FROM formex_materials WHERE id = ?',
[id]
);
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/** /**
* DELETE /api/formex/materials/:id * DELETE /api/formex/materials/:id
* Delete a material * Delete a material
@@ -0,0 +1,64 @@
/**
* Restore configurateur.db from the newest non-empty backup.
* Safe to run while the server is up: the guarded saver refuses empty overwrites,
* then restart the server (or let --watch reload) to pick up the restored file.
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import initSqlJs from 'sql.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.join(__dirname, '..', 'data');
const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db');
const backupDir = path.join(dataDir, 'backups');
const SQL = await initSqlJs({
locateFile: (f) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', f)
});
function countMaterials(filePath) {
if (!fs.existsSync(filePath)) return 0;
let db = null;
try {
db = new SQL.Database(fs.readFileSync(filePath));
const r = db.exec('SELECT COUNT(*) FROM formex_materials');
return Number(r[0]?.values?.[0]?.[0] ?? 0);
} catch {
return 0;
} finally {
try { db?.close(); } catch { /* ignore */ }
}
}
const backups = fs.existsSync(backupDir)
? fs.readdirSync(backupDir)
.filter((f) => /^configurateur-.*\.db$/.test(f))
.map((f) => {
const full = path.join(backupDir, f);
return { file: f, full, count: countMaterials(full), mtime: fs.statSync(full).mtimeMs };
})
.filter((b) => b.count > 0)
.sort((a, b) => b.count - a.count || b.mtime - a.mtime)
: [];
if (backups.length === 0) {
console.error('No non-empty backup found under server/data/backups/');
process.exit(1);
}
const best = backups[0];
const currentCount = countMaterials(dbPath);
console.log(`Current DB: ${currentCount} materials`);
console.log(`Restoring from ${best.file} (${best.count} materials)`);
if (fs.existsSync(dbPath) && currentCount > 0) {
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const safety = path.join(backupDir, `configurateur-pre-restore-${stamp}.db`);
fs.copyFileSync(dbPath, safety);
console.log(`Safety copy of current DB → ${safety}`);
}
fs.copyFileSync(best.full, dbPath);
console.log(`Restored. Disk now has ${countMaterials(dbPath)} materials.`);
console.log('Restart the API server so it reloads this file into memory.');
+62
View File
@@ -0,0 +1,62 @@
/**
* Prove the anti-wipe guard: a stale empty in-memory DB must not overwrite
* a richer on-disk materials catalog.
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import initSqlJs from 'sql.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dbPath = path.join(__dirname, '..', 'data', 'configurateur.db');
const SQL = await initSqlJs({
locateFile: (f) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', f)
});
function count(file) {
const db = new SQL.Database(fs.readFileSync(file));
const n = Number(db.exec('SELECT COUNT(*) FROM formex_materials')[0].values[0][0]);
db.close();
return n;
}
const before = count(dbPath);
console.log('disk before:', before);
if (before < 1) {
console.error('FAIL: expected materials on disk before guard test');
process.exit(1);
}
// Simulate a stale empty process writing the whole file (old wipe mode).
const empty = new SQL.Database();
empty.exec(`
CREATE TABLE formex_materials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
formex_code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
collection TEXT NOT NULL
);
`);
// Mimic the NEW guarded save from db/index.js
function guardedSave(database) {
const memCount = Number(database.exec('SELECT COUNT(*) FROM formex_materials')[0]?.values?.[0]?.[0] ?? 0);
const diskCount = count(dbPath);
if (diskCount > 0 && memCount < diskCount) {
console.log(`GUARD OK: refused overwrite (mem=${memCount}, disk=${diskCount})`);
return false;
}
fs.writeFileSync(dbPath, Buffer.from(database.export()));
return true;
}
const wrote = guardedSave(empty);
empty.close();
const after = count(dbPath);
console.log('disk after:', after);
if (wrote || after !== before) {
console.error('FAIL: guard did not protect materials');
process.exit(1);
}
console.log('PASS: scrapes on disk are protected from empty overwrites');
+73 -5
View File
@@ -1,4 +1,11 @@
import { DRAWERS, NICHE } from '../../shared/manufacturing.js'; import {
DRAWERS,
NICHE,
PLINTH,
MAX_PANEL_DIMENSION_MM,
MAX_PANEL_SHORT_SIDE_MM,
validateHorizontalSpans
} from '../../shared/manufacturing.js';
export function validateConfig(config = {}, settings = {}) { export function validateConfig(config = {}, settings = {}) {
const errors = []; const errors = [];
@@ -12,6 +19,53 @@ export function validateConfig(config = {}, settings = {}) {
const modules = Array.isArray(config.modules) ? config.modules : []; const modules = Array.isArray(config.modules) ? config.modules : [];
const allowSliding = String(settings.allow_sliding_doors ?? '0') === '1'; const allowSliding = String(settings.allow_sliding_doors ?? '0') === '1';
const sanitaryGapMm = Number(settings.sanitary_gap_mm || NICHE.sanitaryGapAround); const sanitaryGapMm = Number(settings.sanitary_gap_mm || NICHE.sanitaryGapAround);
const plinthHeightMm = Number(config.plinthHeightMm ?? 100);
const maxPanelLongMm = Number(
settings.panel_max_width_mm ??
settings.max_panel_width_mm ??
settings.max_panel_dimension_mm ??
MAX_PANEL_DIMENSION_MM
);
const maxPanelShortMm = Number(
settings.panel_max_short_side_mm ??
settings.max_panel_short_side_mm ??
MAX_PANEL_SHORT_SIDE_MM
);
if (widthMm > maxPanelLongMm) {
errors.push({
field: 'widthMm',
message: `Largeur maximale ${maxPanelLongMm} mm (capacité CNC).`
});
}
if (depthMm > maxPanelShortMm) {
errors.push({
field: 'depthMm',
message: `Profondeur maximale ${maxPanelShortMm} mm (capacité CNC).`
});
}
if (config.horizontalSpans != null && !Array.isArray(config.horizontalSpans)) {
errors.push({
field: 'horizontalSpans',
message: 'Les traverses horizontales doivent être fournies sous forme de tableau.'
});
} else {
validateHorizontalSpans(config, { maxPanelWidthMm: maxPanelLongMm }).forEach((issue) => {
errors.push({
field: issue.field,
code: issue.code,
message: issue.message
});
});
}
if (!PLINTH.targetHeightsMm.includes(plinthHeightMm)) {
errors.push({
field: 'plinthHeightMm',
message: `Hauteur AXILO 78 invalide. Valeurs admises : ${PLINTH.targetHeightsMm.join(', ')} mm.`
});
}
if (doorType === 'sliding' && !allowSliding) { if (doorType === 'sliding' && !allowSliding) {
errors.push({ field: 'doorType', message: 'Portes coulissantes désactivées.' }); errors.push({ field: 'doorType', message: 'Portes coulissantes désactivées.' });
@@ -35,13 +89,12 @@ export function validateConfig(config = {}, settings = {}) {
const drawerDepthStep = Number(settings.drawer_depth_step_mm || DRAWERS.depthStep); const drawerDepthStep = Number(settings.drawer_depth_step_mm || DRAWERS.depthStep);
/** Même logique que `useWardrobeConfig` : pas de 50 mm sur la profondeur utile, puis réserve tiroir caché. */ /** Même logique que `useWardrobeConfig` : pas de 50 mm sur la profondeur utile, puis réserve tiroir caché. */
const inferredEffectiveDrawerDepthMm = () => { const inferredEffectiveDrawerDepthMm = (drawerType = config.hardware?.drawerType || 'visible') => {
const rawGap = depthMm - NICHE.drawerBackGap; const rawGap = depthMm - NICHE.drawerBackGap;
const clamped = Math.max(drawerDepthMin, Math.min(drawerDepthMax, rawGap)); const clamped = Math.max(drawerDepthMin, Math.min(drawerDepthMax, rawGap));
const steppedMm = Math.round(clamped / drawerDepthStep) * drawerDepthStep; const steppedMm = Math.round(clamped / drawerDepthStep) * drawerDepthStep;
const rawDt = config.hardware?.drawerType || 'visible';
const depthReductionMm = const depthReductionMm =
rawDt === 'hidden' || rawDt === 'hidden_with_handle' ? DRAWERS.hiddenHandleDepthReduction : 0; drawerType === 'hidden' || drawerType === 'hidden_with_handle' ? DRAWERS.hiddenHandleDepthReduction : 0;
return Math.max(350, steppedMm - depthReductionMm); return Math.max(350, steppedMm - depthReductionMm);
}; };
@@ -53,13 +106,27 @@ export function validateConfig(config = {}, settings = {}) {
if (m.hasRod && heightMm < 1200) { if (m.hasRod && heightMm < 1200) {
errors.push({ field: `modules[${index}]`, message: 'Tringle autorisée à partir de 1200 mm de hauteur armoire.' }); errors.push({ field: `modules[${index}]`, message: 'Tringle autorisée à partir de 1200 mm de hauteur armoire.' });
} }
if (isDrawerCol) {
const moduleDepth = inferredEffectiveDrawerDepthMm(m.drawerType ?? config.hardware?.drawerType);
if (moduleDepth < 350 || moduleDepth > drawerDepthMax) {
errors.push({
field: `modules[${index}].drawerType`,
message: `Profondeur tiroir utile hors bornes (350-${drawerDepthMax} mm) pour ce block.`
});
}
}
} }
/** /**
* Profondeur utile tiroir — alignée sur useWardrobeConfig (pas de 50 sur prof. caisse puis −40 si caché). * Profondeur utile tiroir — alignée sur useWardrobeConfig (pas de 50 sur prof. caisse puis −40 si caché).
* Ne pas exiger un multiple de 50 sur la valeur finale : ex. 550 − 40 = 510 mm est valide. * Ne pas exiger un multiple de 50 sur la valeur finale : ex. 550 − 40 = 510 mm est valide.
*/ */
const canonicalEffectiveDrawerDepthMm = inferredEffectiveDrawerDepthMm(); const drawerModules = modules.filter((m) => (m.drawers || 0) > 0 || m.type === 'tiroirs');
const canonicalDrawerDepths = drawerModules.map((m) =>
inferredEffectiveDrawerDepthMm(m.drawerType ?? config.hardware?.drawerType)
);
const canonicalEffectiveDrawerDepthMm =
canonicalDrawerDepths[0] ?? inferredEffectiveDrawerDepthMm();
const effectiveDrawerMinMm = 350; const effectiveDrawerMinMm = 350;
if ( if (
canonicalEffectiveDrawerDepthMm < effectiveDrawerMinMm || canonicalEffectiveDrawerDepthMm < effectiveDrawerMinMm ||
@@ -75,6 +142,7 @@ export function validateConfig(config = {}, settings = {}) {
if ( if (
Number.isFinite(explicitEff) && Number.isFinite(explicitEff) &&
explicitEff > 0 && explicitEff > 0 &&
canonicalDrawerDepths.every((depth) => depth === canonicalEffectiveDrawerDepthMm) &&
Math.abs(explicitEff - canonicalEffectiveDrawerDepthMm) > 2 Math.abs(explicitEff - canonicalEffectiveDrawerDepthMm) > 2
) { ) {
warnings.push({ warnings.push({
File diff suppressed because it is too large Load Diff
+261
View File
@@ -0,0 +1,261 @@
/**
* Cutting Plan Engine (Calepinage / plan de coupe) — APEX
*
* Optimise le placement des pièces de la liste de débitage sur des panneaux de stock
* (par finition + épaisseur) à l'aide d'un algorithme guillotine par étagères
* (First-Fit Decreasing Height). Calcule le pourcentage de chute par panneau et global,
* et produit un SVG de calepinage par panneau pour le fournisseur.
*/
/** Format de panneau de stock par défaut (mm) — mélaminé standard. */
export const DEFAULT_STOCK_BOARD = { widthMm: 2800, heightMm: 2070 };
/** Trait de scie (kerf) entre pièces, mm. */
const DEFAULT_KERF_MM = 3.2;
/** Délignage de rive (trim) sur chaque bord du panneau, mm. */
const DEFAULT_TRIM_MM = 10;
function round(n, d = 3) {
const f = 10 ** d;
return Math.round((Number(n) || 0) * f) / f;
}
/**
* Place une liste de rectangles sur des panneaux successifs (guillotine FFDH).
* @param {Array<{w:number,h:number,label:string,pieceId:string}>} rects
* @param {object} board { usableW, usableH, kerf, trim, boardWidthMm, boardHeightMm }
* @param {boolean} allowRotation
* @returns {Array<object>} boards
*/
function packRectsIntoBoards(rects, board, allowRotation) {
const { usableW, usableH, kerf, trim, boardWidthMm, boardHeightMm } = board;
// Tri par hauteur décroissante (puis largeur) pour FFDH.
const sorted = [...rects].sort((a, b) => (b.h - a.h) || (b.w - a.w));
const boards = [];
function newBoard() {
return { shelves: [], usedY: 0, placements: [] };
}
function tryPlaceOnBoard(b, rect) {
// Orientations candidates.
const orientations = [{ w: rect.w, h: rect.h, rotated: false }];
if (allowRotation && rect.w !== rect.h) {
orientations.push({ w: rect.h, h: rect.w, rotated: true });
}
// 1) Essayer dans une étagère existante.
for (const shelf of b.shelves) {
for (const o of orientations) {
if (o.w <= usableW - shelf.usedX + 1e-6 && o.h <= shelf.height + 1e-6) {
b.placements.push({
xMm: round(trim + shelf.usedX, 1),
yMm: round(trim + shelf.y, 1),
wMm: round(o.w, 1),
hMm: round(o.h, 1),
rotated: o.rotated,
label: rect.label,
pieceId: rect.pieceId
});
shelf.usedX += o.w + kerf;
return true;
}
}
}
// 2) Ouvrir une nouvelle étagère.
for (const o of orientations) {
if (o.h <= usableH - b.usedY + 1e-6 && o.w <= usableW + 1e-6) {
const shelf = { y: b.usedY, height: o.h, usedX: 0 };
b.placements.push({
xMm: round(trim + 0, 1),
yMm: round(trim + shelf.y, 1),
wMm: round(o.w, 1),
hMm: round(o.h, 1),
rotated: o.rotated,
label: rect.label,
pieceId: rect.pieceId
});
shelf.usedX = o.w + kerf;
b.shelves.push(shelf);
b.usedY += o.h + kerf;
return true;
}
}
return false;
}
let current = newBoard();
for (const rect of sorted) {
// Pièce trop grande pour un panneau, même tournée : on la place seule (hors-format signalé).
const fits = rect.w <= usableW + 1e-6 && rect.h <= usableH + 1e-6;
const fitsRotated = allowRotation && rect.h <= usableW + 1e-6 && rect.w <= usableH + 1e-6;
if (!fits && !fitsRotated) {
boards.push(finalizeBoard(current, board));
current = newBoard();
current.placements.push({
xMm: round(trim, 1),
yMm: round(trim, 1),
wMm: round(rect.w, 1),
hMm: round(rect.h, 1),
rotated: false,
label: rect.label,
pieceId: rect.pieceId,
oversize: true
});
boards.push(finalizeBoard(current, board));
current = newBoard();
continue;
}
if (!tryPlaceOnBoard(current, rect)) {
boards.push(finalizeBoard(current, board));
current = newBoard();
tryPlaceOnBoard(current, rect);
}
}
if (current.placements.length) boards.push(finalizeBoard(current, board));
return boards;
function finalizeBoard(b, brd) {
const boardAreaMm2 = brd.boardWidthMm * brd.boardHeightMm;
const usedAreaMm2 = b.placements.reduce((s, p) => s + p.wMm * p.hMm, 0);
const wasteAreaMm2 = Math.max(0, boardAreaMm2 - usedAreaMm2);
return {
boardWidthMm: brd.boardWidthMm,
boardHeightMm: brd.boardHeightMm,
placements: b.placements,
usedAreaM2: round(usedAreaMm2 / 1e6),
boardAreaM2: round(boardAreaMm2 / 1e6),
wasteAreaM2: round(wasteAreaMm2 / 1e6),
wastePercent: round((wasteAreaMm2 / boardAreaMm2) * 100, 1)
};
}
}
/** SVG de calepinage pour un panneau. */
function boardSvg(boardEntry, index) {
const { boardWidthMm, boardHeightMm, placements } = boardEntry;
const pad = 24;
const maxW = 520;
const scale = maxW / boardWidthMm;
const w = boardWidthMm * scale;
const h = boardHeightMm * scale;
const palette = ['#dbeafe', '#fde68a', '#bbf7d0', '#fecaca', '#e9d5ff', '#c7d2fe', '#fed7aa'];
const rects = placements
.map((p, i) => {
const fill = p.oversize ? '#fca5a5' : palette[i % palette.length];
const rx = pad + p.xMm * scale;
const ry = pad + p.yMm * scale;
const rw = Math.max(1, p.wMm * scale);
const rh = Math.max(1, p.hMm * scale);
const label = `${p.label}${p.rotated ? ' ↻' : ''}`;
const fontSize = Math.max(7, Math.min(11, rh / 3, rw / 6));
const text =
rw > 36 && rh > 14
? `<text x="${rx + rw / 2}" y="${ry + rh / 2}" font-size="${fontSize}" text-anchor="middle" dominant-baseline="middle" fill="#1f2937">${escapeXml(label)}</text>`
: '';
return `<rect x="${rx.toFixed(1)}" y="${ry.toFixed(1)}" width="${rw.toFixed(1)}" height="${rh.toFixed(1)}" fill="${fill}" stroke="#374151" stroke-width="0.8"/>${text}`;
})
.join('');
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w + pad * 2} ${h + pad * 2 + 24}" width="${w + pad * 2}" height="${h + pad * 2 + 24}">
<rect x="${pad}" y="${pad}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" fill="#f9fafb" stroke="#111827" stroke-width="1.5"/>
${rects}
<text x="${pad}" y="${h + pad * 2 + 14}" font-size="12" fill="#111827">Panneau ${index + 1} — ${boardWidthMm}×${boardHeightMm} mm — chute ${boardEntry.wastePercent}%</text>
</svg>`;
}
function escapeXml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* Génère le plan de coupe (calepinage) pour toute l'armoire.
* @param {object} cuttingList résultat de generateCuttingList
* @param {object} [options]
* @param {{widthMm:number,heightMm:number}} [options.stockBoard]
* @param {number} [options.kerfMm]
* @param {number} [options.trimMm]
* @param {boolean} [options.allowRotation]
* @returns {object}
*/
export function generateCuttingPlan(cuttingList, options = {}) {
const stock = options.stockBoard || DEFAULT_STOCK_BOARD;
const kerf = Number.isFinite(options.kerfMm) ? options.kerfMm : DEFAULT_KERF_MM;
const trim = Number.isFinite(options.trimMm) ? options.trimMm : DEFAULT_TRIM_MM;
const allowRotation = options.allowRotation !== false;
const usableW = stock.widthMm - 2 * trim;
const usableH = stock.heightMm - 2 * trim;
// Regrouper les pièces par finition + épaisseur (matériaux non mélangeables).
const groupsMap = new Map();
for (const group of (cuttingList?.byFinish || [])) {
for (const p of (group.pieces || [])) {
const wMm = Number(p.rawCutWidthMm || p.widthMm) || 0;
const hMm = Number(p.rawCutHeightMm || p.heightMm) || 0;
const qty = Math.max(1, Math.round(Number(p.quantity) || 1));
if (!(wMm > 0) || !(hMm > 0)) continue;
const key = `${group.finishLabel || 'finition'}__${p.thicknessMm || 0}mm`;
if (!groupsMap.has(key)) {
groupsMap.set(key, {
key,
finishLabel: group.finishLabel || 'Finition',
thicknessMm: Number(p.thicknessMm) || 0,
rects: []
});
}
const bucket = groupsMap.get(key);
for (let i = 0; i < qty; i++) {
bucket.rects.push({ w: wMm, h: hMm, label: p.description || p.elementType || 'pièce', pieceId: p.id });
}
}
}
const allBoards = [];
const groups = [];
for (const bucket of groupsMap.values()) {
const boards = packRectsIntoBoards(bucket.rects, {
usableW, usableH, kerf, trim,
boardWidthMm: stock.widthMm, boardHeightMm: stock.heightMm
}, allowRotation);
const groupBoardAreaM2 = boards.reduce((s, b) => s + b.boardAreaM2, 0);
const groupUsedAreaM2 = boards.reduce((s, b) => s + b.usedAreaM2, 0);
const groupWasteAreaM2 = round(groupBoardAreaM2 - groupUsedAreaM2);
groups.push({
key: bucket.key,
finishLabel: bucket.finishLabel,
thicknessMm: bucket.thicknessMm,
boardCount: boards.length,
usedAreaM2: round(groupUsedAreaM2),
boardAreaM2: round(groupBoardAreaM2),
wasteAreaM2: groupWasteAreaM2,
wastePercent: groupBoardAreaM2 > 0 ? round((groupWasteAreaM2 / groupBoardAreaM2) * 100, 1) : 0
});
boards.forEach((b, i) => {
allBoards.push({
...b,
groupKey: bucket.key,
finishLabel: bucket.finishLabel,
thicknessMm: bucket.thicknessMm,
svg: boardSvg(b, allBoards.length + i)
});
});
}
const totalBoardAreaM2 = round(allBoards.reduce((s, b) => s + b.boardAreaM2, 0));
const totalUsedAreaM2 = round(allBoards.reduce((s, b) => s + b.usedAreaM2, 0));
const totalWasteAreaM2 = round(totalBoardAreaM2 - totalUsedAreaM2);
const totalPlacedPieces = allBoards.reduce((s, b) => s + b.placements.length, 0);
return {
stock: { widthMm: stock.widthMm, heightMm: stock.heightMm, kerfMm: kerf, trimMm: trim, allowRotation },
boards: allBoards,
groups,
totalBoards: allBoards.length,
totalPlacedPieces,
totalUsedAreaM2,
totalBoardAreaM2,
totalWasteAreaM2,
globalWastePercent: totalBoardAreaM2 > 0 ? round((totalWasteAreaM2 / totalBoardAreaM2) * 100, 1) : 0
};
}
+224
View File
@@ -0,0 +1,224 @@
/**
* Export Excel (.xlsx) — APEX
* Génère un classeur OOXML minimal mais valide à partir de la liste de débitage,
* de la quincaillerie et du calepinage, sans dépendance externe (utilise jszip).
*
* Le format .xlsx est une archive ZIP de fichiers XML. On utilise des chaînes
* "inlineStr" pour éviter la table de chaînes partagées.
*/
import JSZip from 'jszip';
function escapeXml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/** Convertit un index de colonne 0-based en lettre Excel (0 -> A, 26 -> AA). */
function colLetter(index) {
let n = index;
let s = '';
do {
s = String.fromCharCode(65 + (n % 26)) + s;
n = Math.floor(n / 26) - 1;
} while (n >= 0);
return s;
}
function cellXml(ref, value, { header = false } = {}) {
if (value === null || value === undefined || value === '') {
return `<c r="${ref}"${header ? ' s="1"' : ''}/>`;
}
if (typeof value === 'number' && Number.isFinite(value)) {
return `<c r="${ref}"${header ? ' s="1"' : ''}><v>${value}</v></c>`;
}
const styleAttr = header ? ' s="1"' : '';
return `<c r="${ref}"${styleAttr} t="inlineStr"><is><t xml:space="preserve">${escapeXml(value)}</t></is></c>`;
}
/**
* @param {{ name:string, columns:string[], rows:Array<Array<string|number>> }} sheet
*/
function sheetXml(sheet) {
const rowsXml = [];
// Header row
const headerCells = sheet.columns
.map((c, i) => cellXml(`${colLetter(i)}1`, c, { header: true }))
.join('');
rowsXml.push(`<row r="1">${headerCells}</row>`);
// Data rows
sheet.rows.forEach((row, r) => {
const cells = row.map((val, i) => cellXml(`${colLetter(i)}${r + 2}`, val)).join('');
rowsXml.push(`<row r="${r + 2}">${cells}</row>`);
});
const colsCount = sheet.columns.length;
const colsXml = `<cols>${Array.from({ length: colsCount }, (_, i) => `<col min="${i + 1}" max="${i + 1}" width="22" customWidth="1"/>`).join('')}</cols>`;
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
${colsXml}
<sheetData>
${rowsXml.join('\n')}
</sheetData>
</worksheet>`;
}
function contentTypesXml(sheetCount) {
const overrides = Array.from({ length: sheetCount }, (_, i) =>
`<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`
).join('');
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
${overrides}
</Types>`;
}
function workbookXml(sheets) {
const sheetEls = sheets
.map((s, i) => `<sheet name="${escapeXml(s.name).slice(0, 31)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`)
.join('');
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>${sheetEls}</sheets>
</workbook>`;
}
function workbookRelsXml(sheets) {
const rels = sheets
.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`)
.join('');
const stylesRid = sheets.length + 1;
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
${rels}
<Relationship Id="rId${stylesRid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`;
}
const ROOT_RELS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`;
const STYLES_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="3"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FFE8EAED"/><bgColor indexed="64"/></patternFill></fill></fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/></cellXfs>
</styleSheet>`;
/**
* Construit un buffer .xlsx à partir d'une liste de feuilles.
* @param {Array<{ name:string, columns:string[], rows:Array<Array<string|number>> }>} sheets
* @returns {Promise<Buffer>}
*/
export async function buildXlsxBuffer(sheets) {
const zip = new JSZip();
zip.file('[Content_Types].xml', contentTypesXml(sheets.length));
zip.file('_rels/.rels', ROOT_RELS_XML);
zip.file('xl/workbook.xml', workbookXml(sheets));
zip.file('xl/_rels/workbook.xml.rels', workbookRelsXml(sheets));
zip.file('xl/styles.xml', STYLES_XML);
sheets.forEach((s, i) => {
zip.file(`xl/worksheets/sheet${i + 1}.xml`, sheetXml(s));
});
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
}
/**
* Construit le classeur Excel de la liste de débitage (+ quincaillerie + calepinage).
* @param {object} params
* @param {object} params.cuttingList
* @param {object} [params.hardware] résultat de generateHardwareList
* @param {object} [params.cuttingPlan] résultat de generateCuttingPlan
* @returns {Promise<Buffer>}
*/
export async function buildCuttingListXlsx({ cuttingList, hardware = null, cuttingPlan = null }) {
const sheets = [];
// Feuille 1 — Débitage
const debitColumns = [
'Finition', 'Pièce', 'L finie (mm)', 'H finie (mm)', 'Ép. (mm)', 'Qté',
'L brute (mm)', 'H brute (mm)', 'Surcote (mm)', 'Surface (m²)',
'Chants (côtés)', 'Sens de collage', 'Usinage'
];
const debitRows = [];
for (const group of (cuttingList?.byFinish || [])) {
for (const p of (group.pieces || [])) {
const banding = p.edgeBanding && p.edgeBanding.type !== 'none'
? `${p.edgeBanding.sides} côtés`
: '—';
const sens = p.edgeBanding?.direction?.label || '—';
const usinage = p.machining ? Object.keys(p.machining).join(', ') : '—';
debitRows.push([
group.finishLabel || '—',
p.description || '',
Number(p.widthMm) || 0,
Number(p.heightMm) || 0,
Number(p.thicknessMm) || 0,
Number(p.quantity) || 0,
Number(p.rawCutWidthMm) || 0,
Number(p.rawCutHeightMm) || 0,
Number(p.surcoteMm) || 0,
Number((p.surfaceM2 || 0).toFixed(3)),
banding,
sens,
usinage
]);
}
}
sheets.push({ name: 'Débitage', columns: debitColumns, rows: debitRows });
// Feuille 2 — Quincaillerie
if (hardware && Array.isArray(hardware.items) && hardware.items.length) {
const hwColumns = ['Désignation', 'Référence', 'Qté', 'Prix unitaire (CHF)', 'Total (CHF)'];
const hwRows = hardware.items.map((it) => [
it.label || it.name || '',
it.code || it.reference || '—',
Number(it.quantity) || 0,
it.unitPriceChf != null ? Number(it.unitPriceChf) : '—',
it.lineTotalChf != null ? Number(it.lineTotalChf) : (it.totalChf != null ? Number(it.totalChf) : '—')
]);
sheets.push({ name: 'Quincaillerie', columns: hwColumns, rows: hwRows });
}
// Feuille 3 — Calepinage / plan de coupe
if (cuttingPlan && Array.isArray(cuttingPlan.boards) && cuttingPlan.boards.length) {
const planColumns = [
'Panneau', 'Format (mm)', 'Pièces placées', 'Surface utilisée (m²)',
'Surface panneau (m²)', 'Chute (m²)', 'Chute (%)'
];
const planRows = cuttingPlan.boards.map((b, i) => [
`Panneau ${i + 1}`,
`${b.boardWidthMm} x ${b.boardHeightMm}`,
(b.placements || []).length,
Number((b.usedAreaM2 || 0).toFixed(3)),
Number((b.boardAreaM2 || 0).toFixed(3)),
Number((b.wasteAreaM2 || 0).toFixed(3)),
Number((b.wastePercent || 0).toFixed(1))
]);
// Ligne de synthèse globale
planRows.push([
'TOTAL',
'',
cuttingPlan.totalPlacedPieces ?? '',
Number((cuttingPlan.totalUsedAreaM2 || 0).toFixed(3)),
Number((cuttingPlan.totalBoardAreaM2 || 0).toFixed(3)),
Number((cuttingPlan.totalWasteAreaM2 || 0).toFixed(3)),
Number((cuttingPlan.globalWastePercent || 0).toFixed(1))
]);
sheets.push({ name: 'Calepinage', columns: planColumns, rows: planRows });
}
return buildXlsxBuffer(sheets);
}
+102 -20
View File
@@ -1,6 +1,7 @@
import pool from '../db/index.js'; import pool from '../db/index.js';
import { generateCuttingList } from './cuttingEngine.js'; import { generateCuttingList } from './cuttingEngine.js';
import { PLINTH, CARCASS_FASTENERS } from '../../shared/manufacturing.js'; import { PLINTH, CARCASS_FASTENERS } from '../../shared/manufacturing.js';
import { LEGRABOX_BOM_RULES } from '../../shared/blumLegrabox.js';
const HAEFELE_PRODUCT_URLS = { const HAEFELE_PRODUCT_URLS = {
hinge_blum_clip_top_blumotion_110: 'https://www.haefele.ch/fr/produkt/charni-re-corps-blum-clip-top-blumotion-110-pose-en-applique/34242663/?MasterSKU=P-00898218', hinge_blum_clip_top_blumotion_110: 'https://www.haefele.ch/fr/produkt/charni-re-corps-blum-clip-top-blumotion-110-pose-en-applique/34242663/?MasterSKU=P-00898218',
@@ -61,6 +62,24 @@ async function priceById(table, id) {
}; };
} }
/** Système de tiroirs Blum avec specs_json (tip_on, load_kg, …) pour les règles BOM Legrabox. */
async function drawerSystemById(id) {
if (!id) return null;
const row = await fetchOne(
'SELECT id, code, name, price_chf, specs_json FROM blum_hardware WHERE id = ? AND active = 1',
[id]
);
if (!row) return null;
return {
id: row.id,
code: row.code,
name: row.name,
unitPriceChf: toMoney(row.price_chf),
source: 'blum_hardware',
specsJson: row.specs_json || null,
};
}
async function findHaefeleMaterialForModel(model) { async function findHaefeleMaterialForModel(model) {
const m = String(model || '').trim(); const m = String(model || '').trim();
if (!m) return null; if (!m) return null;
@@ -142,7 +161,7 @@ async function findHaefeleMaterialByUrl(url) {
}; };
} }
function computeCountsFromCuttingList(cuttingList) { export function computeCountsFromCuttingList(cuttingList) {
const items = new Map(); const items = new Map();
const pieces = Array.isArray(cuttingList?.pieces) ? cuttingList.pieces : []; const pieces = Array.isArray(cuttingList?.pieces) ? cuttingList.pieces : [];
@@ -256,10 +275,20 @@ function computeCountsFromCuttingList(cuttingList) {
// so we do not count it here to avoid misleading totals. // so we do not count it here to avoid misleading totals.
} }
if (p?.elementType === 'tiroirs') { if (p?.elementType === 'tiroirs' || p?.elementType === 'tiroirFacade') {
drawersCount += qty; drawersCount += qty;
} }
// Façades tiroir Legrabox — chevilles expansibles INSERTA (Ø10) selon perçages générés.
if (p?.elementType === 'tiroirFacade' && m.legraboxInserta?.holes?.length && qty > 0) {
addQty(items, 'legrabox_inserta_fixing', {
key: 'legrabox_inserta_fixing',
label: 'Fixation façade Legrabox INSERTA (cheville expansible Ø10)',
quantity: qty * m.legraboxInserta.holes.length,
unit: 'pc',
});
}
// Fixation "dos" (paroi arrière) — 4 côtés (gauche/droite + haut/bas). // Fixation "dos" (paroi arrière) — 4 côtés (gauche/droite + haut/bas).
// Règle atelier (pour une longueur E = L ou H): // Règle atelier (pour une longueur E = L ou H):
// - par côté: 1 pièce à 5cm de chaque extrémité (=> 2) // - par côté: 1 pièce à 5cm de chaque extrémité (=> 2)
@@ -280,6 +309,30 @@ function computeCountsFromCuttingList(cuttingList) {
meta: { model: 'Ixconnect RPC G 13/20' }, meta: { model: 'Ixconnect RPC G 13/20' },
}); });
} }
// AXILO 78 : une platine/pied par centre, et un support de plinthe par pied avant.
if (p?.elementType === 'fond' && m.axiloFootPlates?.centers?.length && qty > 0) {
const centers = m.axiloFootPlates.centers;
const frontCount =
Number(m.axiloFootPlates.frontFootCount) ||
centers.filter((center) => center?.row === 'front').length;
addQty(items, 'axilo_78_press_fit_foot', {
key: 'axilo_78_press_fit_foot',
label: `Pied Häfele AXILO 78 à planter — réf. ${PLINTH.mountingPlate.articleNumber}`,
code: PLINTH.mountingPlate.articleNumber,
quantity: qty * centers.length,
unit: 'pc',
meta: { model: PLINTH.mountingPlate.articleNumber }
});
addQty(items, 'axilo_plinth_support', {
key: 'axilo_plinth_support',
label: `Support de plinthe Häfele AXILO — réf. ${PLINTH.plinthSupport.articleNumber}`,
code: PLINTH.plinthSupport.articleNumber,
quantity: qty * frontCount * PLINTH.plinthSupport.quantityPerFrontFoot,
unit: 'pc',
meta: { model: PLINTH.plinthSupport.articleNumber }
});
}
} }
return { items: Array.from(items.values()), doorCount, drawersCount }; return { items: Array.from(items.values()), doorCount, drawersCount };
@@ -289,20 +342,6 @@ export async function generateHardwareList({ config, modules = [], colors = {} }
const cuttingList = generateCuttingList(config, colors, modules); const cuttingList = generateCuttingList(config, colors, modules);
const { items, doorCount } = computeCountsFromCuttingList(cuttingList); const { items, doorCount } = computeCountsFromCuttingList(cuttingList);
// Add plinth clips (simple deterministic rule)
const widthMm = Number(config?.widthMm) || 0;
if (widthMm > 0) {
const clips = Math.max(0, Math.ceil((widthMm / 1000) * (Number(PLINTH.clipsPerMeter) || 0)));
if (clips > 0) {
items.push({
key: 'plinth_clips',
label: `Clips plinthe (${PLINTH.model})`,
quantity: clips,
unit: 'pc',
});
}
}
// Selected variants pricing (if configured) // Selected variants pricing (if configured)
const handleVariantId = config?.hardware?.handleVariantId; const handleVariantId = config?.hardware?.handleVariantId;
const hingeVariantId = config?.hardware?.hingeVariantId; const hingeVariantId = config?.hardware?.hingeVariantId;
@@ -311,7 +350,7 @@ export async function generateHardwareList({ config, modules = [], colors = {} }
const [handleVariant, hingeVariant, drawerSystem] = await Promise.all([ const [handleVariant, hingeVariant, drawerSystem] = await Promise.all([
priceById('handle_variants', handleVariantId), priceById('handle_variants', handleVariantId),
priceById('hinge_variants', hingeVariantId), priceById('hinge_variants', hingeVariantId),
priceById('blum_hardware', drawerSystemId), drawerSystemById(drawerSystemId),
]); ]);
// Add handles as an item when selected // Add handles as an item when selected
@@ -389,9 +428,9 @@ export async function generateHardwareList({ config, modules = [], colors = {} }
} }
// Drawer system: assume priced “per drawer” // Drawer system: assume priced “per drawer”
if (drawerSystem) { const configModules = Array.isArray(config?.modules) ? config.modules : [];
const totalDrawers = (Array.isArray(config?.modules) ? config.modules : []).reduce((s, m) => s + (Number(m?.drawers) || 0), 0); const totalDrawers = configModules.reduce((s, m) => s + (Number(m?.drawers) || 0), 0);
if (totalDrawers > 0) { if (drawerSystem && totalDrawers > 0) {
items.push({ items.push({
key: `drawer_system:${drawerSystem.id}`, key: `drawer_system:${drawerSystem.id}`,
label: `Système tiroirs — ${drawerSystem.name}`, label: `Système tiroirs — ${drawerSystem.name}`,
@@ -402,6 +441,49 @@ export async function generateHardwareList({ config, modules = [], colors = {} }
code: drawerSystem.code, code: drawerSystem.code,
}); });
} }
// Règles automatiques Blum Legrabox (BOM)
if (totalDrawers > 0) {
// 1) Stabilisation de façade — obligatoire si largeur_corps_mm >= 600 (par tiroir concerné).
const wideDrawers = configModules.reduce((s, m) => {
const n = Number(m?.drawers) || 0;
const w = Number(m?.widthMm) || 0;
return s + (n > 0 && w >= LEGRABOX_BOM_RULES.frontStabiliserMinBodyWidthMm ? n : 0);
}, 0);
if (wideDrawers > 0) {
items.push({
key: 'legrabox_front_stabiliser',
label: 'Stabilisation de façade Legrabox (largeur corps ≥ 600 mm)',
quantity: wideDrawers,
unit: 'pc',
meta: { model: 'ZST.550BA' },
});
}
// 2) Unité de synchronisation — obligatoire avec la variante Tip-On Blumotion.
const openingVariant = String(config?.hardware?.drawerOpeningVariant || '').toLowerCase();
const specsTipOn = (() => {
try {
const specs = drawerSystem?.specsJson ? JSON.parse(drawerSystem.specsJson) : null;
return Boolean(specs?.tip_on);
} catch {
return false;
}
})();
const isTipOnBlumotion =
openingVariant === LEGRABOX_BOM_RULES.syncUnitVariant ||
openingVariant === 'tip-on blumotion' ||
specsTipOn;
if (isTipOnBlumotion) {
const drawerColumns = configModules.filter((m) => (Number(m?.drawers) || 0) > 0).length;
items.push({
key: 'legrabox_tipon_sync_unit',
label: 'Unité de synchronisation Tip-On Blumotion (set)',
quantity: Math.max(1, drawerColumns),
unit: 'set',
meta: { model: 'T60.000D' },
});
}
} }
// Price Häfele items (best-effort by name match) // Price Häfele items (best-effort by name match)
+22 -29
View File
@@ -10,7 +10,11 @@ import {
buildFinishHabillageHoleSpecsMm, buildFinishHabillageHoleSpecsMm,
buildDoorHingeCupHoleSpecsMm, buildDoorHingeCupHoleSpecsMm,
buildDrawerReinforcementHoleSpecsMm, buildDrawerReinforcementHoleSpecsMm,
buildDrawerRenfortPanelIxconnectSpecsMm buildDrawerRenfortPanelIxconnectSpecsMm,
buildLegraboxRailHoleSpecsMm,
buildLegraboxFacadeInsertaSpecsMm,
buildAxiloFootHoleSpecsMm,
buildSystem32HoleSpecsMm
} from '../../shared/cncPanelHoles.js'; } from '../../shared/cncPanelHoles.js';
export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0) { export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0) {
@@ -25,33 +29,8 @@ export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0)); const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0)); const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
if (m.system32?.positions?.length) { for (const spec of buildSystem32HoleSpecsMm(piece, widthMm, heightMm)) {
const frontX = clampX(37); pushHole(spec);
const backX = clampX(widthMm - 37);
const d = Number(m.system32.holeDiameter || 5);
const z = Number(m.system32.holeDepth || piece.thicknessMm || 12);
const throughHole = Boolean(m.system32.throughHole);
for (const pos of m.system32.positions) {
const y = clampY(pos);
pushHole({
xMm: frontX,
yMm: y,
diameterMm: d,
depthMm: z,
type: 'system32',
face: 'face',
toolMode: throughHole ? 'through' : 'blind'
});
pushHole({
xMm: backX,
yMm: y,
diameterMm: d,
depthMm: z,
type: 'system32',
face: 'face',
toolMode: throughHole ? 'through' : 'blind'
});
}
} }
if (['dessus', 'fond'].includes(piece.elementType || '')) { if (['dessus', 'fond'].includes(piece.elementType || '')) {
@@ -60,6 +39,11 @@ export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0
} }
} }
// AXILO 78 Press-fit — quatre trous borgnes par platine, exclusivement en sous-face du fond bas.
for (const spec of buildAxiloFootHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: 'blind' });
}
if (['cotes', 'panneauxInterieurs'].includes(piece.elementType || '')) { if (['cotes', 'panneauxInterieurs'].includes(piece.elementType || '')) {
for (const spec of buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heightMm)) { for (const spec of buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' }); pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
@@ -67,9 +51,18 @@ export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0
for (const spec of buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm, partIndex)) { for (const spec of buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm, partIndex)) {
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' }); pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
} }
for (const spec of buildLegraboxRailHoleSpecsMm(piece, widthMm, heightMm, partIndex)) {
pushHole({ ...spec, toolMode: 'blind' });
}
} }
if (['cotes', 'habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus'].includes(piece.elementType || '')) { if (piece.elementType === 'tiroirFacade') {
for (const spec of buildLegraboxFacadeInsertaSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: 'blind' });
}
}
if (['cotes', 'dessus', 'habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus'].includes(piece.elementType || '')) {
for (const spec of buildFinishHabillageHoleSpecsMm(piece, widthMm, heightMm)) { for (const spec of buildFinishHabillageHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: 'blind' }); pushHole({ ...spec, toolMode: 'blind' });
} }
+533
View File
@@ -0,0 +1,533 @@
/**
* Leuba Hiag catalog crawler.
*
* The Leuba Hiag webshop (https://www.leubahiag.ch) is a Laravel/Bagisto shop whose
* product catalog is served by Algolia. The public catalog search page embeds a
* short-lived "secured" Algolia API key (guest scope) inside the page markup. We read
* that key live on every run (it expires), then page through the Algolia index and
* upsert each product into the shared `formex_materials` table under the `leubahiag`
* supplier.
*
* Notes:
* - Algolia exposes the product catalog but NOT prices. Customer-specific prices come
* from the authenticated `/fr/sales/prices-and-availability` endpoint: we log in with
* the account credentials (LEUBAHIAG_EMAIL/PASSWORD) and, after importing each line,
* fetch the per-SKU panel price and store it in price_chf.
* - Imported items are inactive by default so the catalog rows don't flood the
* configurator finish picker. Activate the wanted decors from the Matériaux admin page.
*/
import pool, { run } from '../db/index.js';
const BASE_URL = 'https://www.leubahiag.ch';
const SEARCH_URL = `${BASE_URL}/fr/catalog/search`;
const CRAWL_DELAY = parseInt(process.env.LEUBAHIAG_CRAWL_DELAY_MS || '300', 10);
const ALGOLIA_HITS_PER_PAGE = 1000;
/**
* Curated catalog lines to import (per the supplier's agreed assortment).
* Each target maps to an Algolia query and/or brand facet filter. The `label` is used
* as the material `collection` so the configurator groups items by recognisable line.
*/
export const LEUBA_HIAG_TARGETS = [
{ key: 'pfleiderer-xpsm', label: 'Pfleiderer XPSM (Antifingerprint)', tier: 'Entrée de gamme', query: 'XPSM', brands: ['Pfleiderer'] },
{ key: 'swiss-krono', label: 'Swiss Krono', tier: 'Entrée de gamme', query: '', brands: ['Swiss Krono'] },
{ key: 'unilin-master-oak', label: 'Unilin Master Oak', tier: 'Milieu de gamme', query: 'Master Oak', brands: ['UNILIN'] },
{ key: 'shinnoki', label: 'Shinnoki', tier: 'Milieu de gamme', query: 'Shinnoki', brands: [] },
{ key: 'cleaf', label: 'Cleaf', tier: 'Haut de gamme', query: '', brands: ['CLEAF'] },
{ key: 'valchromat', label: 'Valchromat', tier: 'Haut de gamme', query: '', brands: ['Valchromat'] },
];
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function browserHeaders(extra = {}) {
return {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'fr-CH,fr;q=0.9,en;q=0.5',
...extra,
};
}
function absoluteUrl(url) {
if (!url) return null;
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `${BASE_URL}${url.startsWith('/') ? '' : '/'}${url}`;
}
/** Parse a "CHF 1'234.50" string into a number, or null. */
function parseCHF(text) {
if (typeof text !== 'string') return null;
const clean = text.replace(/CHF/gi, '').replace(/[\u00a0\u202f'’\s]/g, '').replace(',', '.');
const n = parseFloat(clean);
return Number.isFinite(n) ? n : null;
}
/**
* Authenticated Leuba Hiag (Laravel) session. Used to read customer-specific prices
* from the `prices-and-availability` endpoint (axios sends the XSRF-TOKEN cookie back
* as an X-XSRF-TOKEN header — we replicate that here).
*/
class LeubaHiagSession {
constructor() {
this.cookies = new Map();
this.loggedIn = false;
}
captureCookies(response) {
let raw = [];
if (typeof response.headers.getSetCookie === 'function') {
raw = response.headers.getSetCookie();
} else {
const header = response.headers.get('set-cookie');
if (header) raw = header.split(/,\s*(?=[A-Za-z0-9_\-]+=)/);
}
for (const entry of raw) {
const [pair] = entry.split(';');
const eq = pair.indexOf('=');
if (eq > 0) this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
}
cookieHeader() {
return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
}
xsrfToken() {
const raw = this.cookies.get('XSRF-TOKEN');
return raw ? decodeURIComponent(raw) : '';
}
async login(emit = () => {}, credentials = {}) {
const email = credentials.username || credentials.email
|| process.env.LEUBAHIAG_EMAIL || process.env.LEUBAHIAG_USER;
const password = credentials.password || process.env.LEUBAHIAG_PASSWORD;
if (!email || !password) {
emit({ type: 'log', message: 'Identifiants Leuba Hiag absents : import sans prix.' });
return false;
}
const loginUrl = `${BASE_URL}/fr/customer/auth/login`;
try {
const getRes = await fetch(loginUrl, { headers: browserHeaders(), redirect: 'follow' });
this.captureCookies(getRes);
const html = await getRes.text();
const token = (html.match(/name="_token"\s+value="([^"]+)"/) || [])[1];
if (!token) {
emit({ type: 'log', message: 'Jeton CSRF Leuba Hiag introuvable : import sans prix.' });
return false;
}
const body = new URLSearchParams();
body.set('_token', token);
body.set('email', email);
body.set('password', password);
// redirect:'manual' so we capture Set-Cookie from the post-login 302 (the
// LoggedIn/session cookies live on the redirect response, not the final page).
const postRes = await fetch(loginUrl, {
method: 'POST',
headers: browserHeaders({
'Content-Type': 'application/x-www-form-urlencoded',
Referer: loginUrl,
Origin: BASE_URL,
Cookie: this.cookieHeader(),
}),
body: body.toString(),
redirect: 'manual',
});
this.captureCookies(postRes);
// Confirm the session and refresh the XSRF-TOKEN from the authenticated catalog page.
const verifyRes = await fetch(`${BASE_URL}/fr/catalog/search`, {
headers: browserHeaders({ Cookie: this.cookieHeader() }),
redirect: 'follow',
});
this.captureCookies(verifyRes);
const verifyHtml = await verifyRes.text();
const isLoggedIn = /\\u0022isLoggedIn\\u0022:true/.test(verifyHtml)
|| this.cookies.get('LoggedIn') === '1';
this.loggedIn = isLoggedIn;
emit({
type: 'log',
message: this.loggedIn
? `Connecté à Leuba Hiag (${email}).`
: 'Connexion Leuba Hiag non confirmée : import sans prix.',
});
return this.loggedIn;
} catch (err) {
emit({ type: 'log', message: `Erreur de connexion Leuba Hiag : ${err.message}` });
return false;
}
}
/** POST {skus} to the prices endpoint; returns the per-sku price map (or {}). */
async fetchPrices(skus) {
if (!this.loggedIn || skus.length === 0) return {};
const res = await fetch(`${BASE_URL}/fr/sales/prices-and-availability`, {
method: 'POST',
headers: browserHeaders({
'Content-Type': 'application/json',
Accept: 'application/json, text/plain, */*',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': this.xsrfToken(),
Referer: `${BASE_URL}/fr/catalog/search`,
Origin: BASE_URL,
Cookie: this.cookieHeader(),
}),
body: JSON.stringify({ skus }),
});
this.captureCookies(res);
if (!res.ok) {
throw new Error(`prices-and-availability HTTP ${res.status}`);
}
return res.json();
}
}
/**
* Read the live Algolia connection config (appId, index, secured apiKey) from the
* catalog search page. The key is embedded as `\u0022apiKey\u0022:\u0022...\u0022`
* inside the `window.globalSearchConfigBase` JSON blob.
*/
async function fetchAlgoliaConfig(emit = () => {}) {
const res = await fetch(SEARCH_URL, { headers: browserHeaders(), redirect: 'follow' });
if (!res.ok) {
throw new Error(`Impossible de charger la page catalogue Leuba Hiag (HTTP ${res.status}).`);
}
const html = await res.text();
const appId = (html.match(/\\u0022appId\\u0022:\\u0022([A-Za-z0-9]+)\\u0022/) || [])[1]
|| process.env.LEUBAHIAG_ALGOLIA_APP_ID;
const indexName = (html.match(/\\u0022indexName\\u0022:\\u0022([A-Za-z0-9_]+)\\u0022/) || [])[1]
|| process.env.LEUBAHIAG_ALGOLIA_INDEX;
const apiKey = (html.match(/\\u0022apiKey\\u0022:\\u0022([A-Za-z0-9+/=]+)\\u0022/) || [])[1];
if (!appId || !indexName || !apiKey) {
throw new Error('Clé Algolia introuvable sur la page catalogue Leuba Hiag (structure du site modifiée ?).');
}
emit({ type: 'log', message: `Catalogue Algolia détecté: index ${indexName} (app ${appId}).` });
return { appId, indexName, apiKey };
}
/**
* Run one Algolia query page. `facetFilters` is an array of arrays per Algolia syntax.
*/
async function queryAlgolia({ appId, indexName, apiKey }, { query = '', page = 0, hitsPerPage = ALGOLIA_HITS_PER_PAGE, facetFilters = [] } = {}) {
const url = `https://${appId}-dsn.algolia.net/1/indexes/${indexName}/query`;
const params = new URLSearchParams();
params.set('query', query);
params.set('hitsPerPage', String(hitsPerPage));
params.set('page', String(page));
if (facetFilters.length > 0) {
params.set('facetFilters', JSON.stringify(facetFilters));
}
const res = await fetch(url, {
method: 'POST',
headers: {
'X-Algolia-Application-Id': appId,
'X-Algolia-API-Key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({ params: params.toString() }),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Requête Algolia échouée (HTTP ${res.status})${body ? `: ${body.slice(0, 160)}` : ''}`);
}
return res.json();
}
function firstString(value) {
if (Array.isArray(value)) return value.find((v) => typeof v === 'string') || null;
return typeof value === 'string' ? value : null;
}
function toNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
/**
* Map an Algolia product record to a `formex_materials` row.
* Dimension fields from the index: thickness (mm), length (mm), width (mm).
* @param {string} [collectionOverride] Force the `collection` (curated line label).
*/
function mapHit(hit, collectionOverride) {
const sku = String(hit.sku || hit.objectID || '').trim();
if (!sku) return null;
const categoryPath = Array.isArray(hit.categories_with_path) ? hit.categories_with_path : [];
const category = categoryPath.length > 0
? categoryPath[categoryPath.length - 1]
: (firstString(hit.categories?.level0) || null);
const collection = collectionOverride
|| firstString(hit.brand)
|| firstString(hit.categories?.level0)
|| 'Leuba Hiag';
return {
sku,
materialCode: `LH-${sku}`,
name: hit.name || hit.group_name || sku,
collection,
category,
width_mm: toNumber(hit.width),
height_mm: toNumber(hit.length),
thickness_mm: toNumber(hit.thickness),
image_url: absoluteUrl(hit.image_url),
formex_url: absoluteUrl(hit.url),
price_chf: null,
price_unit: null,
};
}
async function ensureSupplier() {
const existing = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['leubahiag']);
if (existing.rows.length > 0) return existing.rows[0].id;
await run(
'INSERT INTO suppliers (code, name, email, type, active) VALUES (?, ?, ?, ?, ?)',
['leubahiag', 'Leuba Hiag', '[email protected]', 'materials', 1]
);
const created = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['leubahiag']);
return created.rows[0]?.id || null;
}
/**
* Upsert a material. Existing rows keep their `active` flag (so admin activations are
* preserved across re-syncs); new rows are inserted with the provided `active` value.
*/
async function upsertMaterial(material, supplierId, activeForNew) {
const existing = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ?',
[material.materialCode]
);
if (existing.rows.length > 0) {
await run(
`UPDATE formex_materials
SET name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
image_url = ?, formex_url = ?,
supplier_id = COALESCE(?, supplier_id), last_synced_at = datetime('now')
WHERE formex_code = ?`,
[
material.name,
material.collection,
material.category,
material.width_mm,
material.height_mm,
material.thickness_mm,
material.image_url,
material.formex_url,
supplierId,
material.materialCode,
]
);
return { id: existing.rows[0].id, created: false };
}
await run(
`INSERT INTO formex_materials
(formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
image_url, formex_url, price_chf, price_unit, supplier_id, active, last_synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
[
material.materialCode,
material.name,
material.collection,
material.category,
material.width_mm,
material.height_mm,
material.thickness_mm,
material.image_url,
material.formex_url,
material.price_chf ?? null,
material.price_unit ?? null,
supplierId,
activeForNew ? 1 : 0,
]
);
const created = await pool.query('SELECT id FROM formex_materials WHERE formex_code = ?', [material.materialCode]);
return { id: created.rows[0]?.id || null, created: true };
}
/** Update the price of an already-imported material by its SKU. */
async function updateMaterialPrice(sku, priceChf, priceUnit) {
await run(
`UPDATE formex_materials
SET price_chf = ?, price_unit = COALESCE(?, price_unit), last_synced_at = datetime('now')
WHERE formex_code = ?`,
[priceChf, priceUnit, `LH-${sku}`]
);
}
async function logSync(status, itemsCount, error = null) {
await run(
'INSERT INTO sync_logs (source, status, items_count, error) VALUES (?, ?, ?, ?)',
['leubahiag', status, itemsCount, error]
);
}
/**
* Import all products matching one curated target (a single catalog line).
*/
async function importTarget(config, supplierId, target, activate, emit) {
const facetFilters = Array.isArray(target.brands) && target.brands.length > 0
? [target.brands.map((b) => `brand:${b}`)]
: [];
let synced = 0;
let created = 0;
let page = 0;
let nbPages = 1;
let nbHits = 0;
const skus = [];
do {
if (page > 0) await sleep(CRAWL_DELAY);
const data = await queryAlgolia(config, { query: target.query, page, facetFilters });
nbPages = data.nbPages || 1;
nbHits = data.nbHits || 0;
if (page === 0) {
emit({ type: 'log', message: `→ ${target.label} (${target.tier}) : ${nbHits} produit(s).` });
}
const hits = Array.isArray(data.hits) ? data.hits : [];
for (const hit of hits) {
const material = mapHit(hit, target.label);
if (!material) continue;
try {
const result = await upsertMaterial(material, supplierId, activate);
synced++;
if (result.created) created++;
skus.push(material.sku);
} catch (err) {
emit({ type: 'log', message: ` ✗ ${material.materialCode}: ${err.message}` });
}
}
page++;
} while (page < nbPages);
emit({ type: 'log', message: ` ✓ ${target.label} : ${synced} importé(s) (${created} nouveau(x)).` });
return { synced, created, skus };
}
/**
* Fetch and store customer prices for a list of SKUs (in batches).
* Returns the number of items priced.
*/
async function importPrices(session, skus, emit) {
const BATCH = 50;
let priced = 0;
for (let i = 0; i < skus.length; i += BATCH) {
const batch = skus.slice(i, i + BATCH);
try {
const data = await session.fetchPrices(batch);
for (const sku of batch) {
const info = data?.[sku];
if (!info) continue;
const tier = Array.isArray(info.tierprices) ? info.tierprices[0] : null;
const priceChf = parseCHF(tier?.price);
if (priceChf == null) continue;
await updateMaterialPrice(sku, priceChf, 'pièce');
priced++;
}
} catch (err) {
emit({ type: 'log', message: ` ✗ Prix lot ${i / BATCH + 1}: ${err.message}` });
}
emit({ type: 'log', message: ` Prix: ${priced}/${skus.length} récupérés…` });
if (i + BATCH < skus.length) await sleep(CRAWL_DELAY);
}
return priced;
}
/**
* Synchronise the curated Leuba Hiag catalog lines via Algolia.
*
* @param {object} options
* @param {string[]} [options.targets] Subset of target keys to import (default: all).
* @param {boolean} [options.activate] Mark newly imported items active (default false).
* @param {boolean} [options.fetchPrices] Log in and import customer prices (default true).
* @param {object} [options.credentials] { username/email, password } (else env vars).
* @param {function} emit SSE-style event emitter.
*/
export async function syncLeubaHiag(options = {}, emit = () => {}) {
const { targets = null, activate = false, fetchPrices = true, credentials = {} } = options;
const selected = Array.isArray(targets) && targets.length > 0
? LEUBA_HIAG_TARGETS.filter((t) => targets.includes(t.key))
: LEUBA_HIAG_TARGETS;
const start = Date.now();
let synced = 0;
let created = 0;
let priced = 0;
try {
if (selected.length === 0) {
throw new Error('Aucune gamme Leuba Hiag sélectionnée.');
}
const config = await fetchAlgoliaConfig(emit);
const supplierId = await ensureSupplier();
// Log in up-front so prices can be fetched after each line is imported.
const session = new LeubaHiagSession();
if (fetchPrices) {
await session.login(emit, credentials);
}
emit({ type: 'log', message: `${selected.length} gamme(s) à importer.` });
emit({ type: 'progress', current: 0, total: selected.length, collection: 'Leuba Hiag' });
const allSkus = [];
for (let i = 0; i < selected.length; i++) {
const target = selected[i];
emit({ type: 'progress', current: i, total: selected.length, collection: target.label });
try {
const res = await importTarget(config, supplierId, target, activate, emit);
synced += res.synced;
created += res.created;
allSkus.push(...res.skus);
} catch (err) {
emit({ type: 'log', message: ` ✗ ${target.label}: ${err.message}` });
}
if (i < selected.length - 1) await sleep(CRAWL_DELAY);
}
if (session.loggedIn && allSkus.length > 0) {
emit({ type: 'log', message: `Récupération des prix client pour ${allSkus.length} article(s)…` });
priced = await importPrices(session, allSkus, emit);
emit({ type: 'log', message: `✓ ${priced} prix client enregistrés.` });
}
const duration = ((Date.now() - start) / 1000).toFixed(1);
await logSync('success', synced, null);
const result = {
success: true,
itemsCount: synced,
createdCount: created,
updatedCount: synced - created,
pricedCount: priced,
duration,
error: null,
};
emit({ type: 'progress', current: selected.length, total: selected.length, collection: 'Leuba Hiag' });
emit({ type: 'complete', result });
return result;
} catch (err) {
await logSync('error', synced, err.message);
emit({ type: 'error', error: err.message });
return { success: false, itemsCount: synced, error: err.message };
}
}
+345 -58
View File
@@ -11,12 +11,16 @@ import { GROOVE, getBackGroovePlanOrientation, BACK_GROOVE_PLAN_VERTICAL_TYPES }
const MPR_ARTICLE_CODES = Object.freeze({ const MPR_ARTICLE_CODES = Object.freeze({
plintheBas: 'PL_BAS', plintheBas: 'PL_BAS',
plintheGauche: 'PL_G',
plintheDroite: 'PL_D',
bandeauHaut: 'BH', bandeauHaut: 'BH',
dessus: 'BO_U', dessus: 'BO_U',
fond: 'BO_L', fond: 'BO_L',
cotes: 'SE', cotes: 'SE',
cotesDoublage: 'CD', cotesDoublage: 'CD',
fileur: 'FI', fileur: 'FI',
fileurCotes: 'FI_C',
fileurHaut: 'FI_H',
habillageFinitionGauche: 'FR_L_G', habillageFinitionGauche: 'FR_L_G',
habillageFinitionDroite: 'FR_L_D', habillageFinitionDroite: 'FR_L_D',
habillageFinitionDessus: 'FR_H', habillageFinitionDessus: 'FR_H',
@@ -26,7 +30,10 @@ const MPR_ARTICLE_CODES = Object.freeze({
panneauxInterieurs: 'ZA_O', panneauxInterieurs: 'ZA_O',
rayonFixe: 'RF_X', rayonFixe: 'RF_X',
etageres: 'EB_M', etageres: 'EB_M',
renfortsTiroir: 'LABO' renfortsTiroir: 'LABO',
tiroirFond: 'TI_BO',
tiroirDos: 'TI_RW',
tiroirFacade: 'TI_FR'
}); });
function fmtMm(n) { function fmtMm(n) {
@@ -45,6 +52,13 @@ function escQuoted(s) {
return String(s ?? '').replace(/"/g, "'"); return String(s ?? '').replace(/"/g, "'");
} }
/** Désignation « type » pour le bloc [001 (sans suffixe côté/index) — ex. SE_L → SE, ZA_O_2 → ZA. */
function baseDesignation(slug) {
const s = String(slug || '').trim();
const cut = s.indexOf('_');
return cut > 0 ? s.slice(0, cut) : s;
}
/** Scinde ligne « gauche+droit » qty 2 en deux fichiers SE_L / SE_R. */ /** Scinde ligne « gauche+droit » qty 2 en deux fichiers SE_L / SE_R. */
export function flattenPiecesForMprExport(pieces = []) { export function flattenPiecesForMprExport(pieces = []) {
const out = []; const out = [];
@@ -63,6 +77,14 @@ export function flattenPiecesForMprExport(pieces = []) {
} }
let baseSlug = MPR_ARTICLE_CODES[piece.elementType] || String(piece.elementType || '').toUpperCase() || 'PIECE'; let baseSlug = MPR_ARTICLE_CODES[piece.elementType] || String(piece.elementType || '').toUpperCase() || 'PIECE';
if (piece.horizontalSpanId != null) {
const spanId = String(piece.horizontalSpanId).replace(/[^\w-]+/g, '_');
const role = String(piece.horizontalSpanRole || 'panel').toUpperCase();
baseSlug = `HS_${spanId}_${role}_${baseSlug}`;
if (piece.horizontalSpanRole === 'door' && Number.isFinite(Number(piece.doorIndex))) {
baseSlug += `_${Number(piece.doorIndex) + 1}`;
}
}
for (let qi = 0; qi < qty; qi++) { for (let qi = 0; qi < qty; qi++) {
let slug = baseSlug; let slug = baseSlug;
@@ -80,11 +102,14 @@ export function flattenPiecesForMprExport(pieces = []) {
function mprAnglesForPiece(piece) { function mprAnglesForPiece(piece) {
const et = piece.elementType || ''; const et = piece.elementType || '';
if (habillageEtPorte(et)) { const widthMm = toSafe(piece.rawCutWidthMm, piece.widthMm);
const heightMm = toSafe(piece.rawCutHeightMm, piece.heightMm);
const vertical = heightMm > widthMm;
if (vertical) {
return { return {
DRWIN: '270.0', DRWIN: '270.0',
beschGedreht: '0', beschGedreht: '0',
turnMsg: et === 'portes' ? 'DREHEN X-ACHSE' : 'DREHEN X-ACHSE' turnMsg: 'DREHEN X-ACHSE'
}; };
} }
if (['dessus', 'fond'].includes(et)) { if (['dessus', 'fond'].includes(et)) {
@@ -118,7 +143,7 @@ function kantenFlags(piece) {
return { kavo: '1.0', kali: '0.0', kahi: '0.0', kare: '0.0' }; return { kavo: '1.0', kali: '0.0', kahi: '0.0', kare: '0.0' };
} }
function grooveSection(piece, _Lmm, _Bmm) { function grooveSection(piece, Lmm, Bmm) {
const m = piece?.machining || {}; const m = piece?.machining || {};
const orient = piece?.elementType ? getBackGroovePlanOrientation(piece.elementType) : null; const orient = piece?.elementType ? getBackGroovePlanOrientation(piece.elementType) : null;
if (!m.groove || !orient) return []; if (!m.groove || !orient) return [];
@@ -127,26 +152,60 @@ function grooveSection(piece, _Lmm, _Bmm) {
const gd = fmtMm(Number(m.groove.depth ?? GROOVE.back.depth)); const gd = fmtMm(Number(m.groove.depth ?? GROOVE.back.depth));
const abst = fmtMm(Number(m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge)); const abst = fmtMm(Number(m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge));
const vertical = BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType); const vertical = BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType);
const win = vertical ? '180.0' : '270.0'; const axis = Number(abst);
const start = vertical
? [Math.max(0, Lmm - axis), 0]
: [0, Math.max(0, Bmm - axis)];
const end = vertical
? [Math.max(0, Lmm - axis), Bmm]
: [Lmm, Math.max(0, Bmm - axis)];
return [ return [
'<121 \\Block\\', '<121 \\Block\\',
'MNM="Kantenbearbeitungen von oben"', 'MNM="Kantenbearbeitungen von oben"',
'DP="1"', 'DP="1"',
'KO="04"', 'KO="04"',
'<139 \\Komponente\\', '<109 \\Nuten\\',
'IN="RSO/Makro/RSO_Nut_Flaeche.mpr"', 'MNM="Rainure dos"',
'MNM="Nut Flaeche rueckseite"', `XA="${fmtMm(start[0])}"`,
'XA="L"', `YA="${fmtMm(start[1])}"`,
'YA="B"', `XE="${fmtMm(end[0])}"`,
'VA="LA L"', `YE="${fmtMm(end[1])}"`,
`VA="BR ${gw}"`, `NB="${gw}"`,
`VA="TI ${gd}"`, 'RK="NOWRK"',
`VA="ABST ${abst}"`, 'EM="MOD2"',
`VA="WIN ${win}"`, 'TV="0"',
'VA="ANF 0.0"', 'MV="GL"',
'VA="END 0.0"', `TI="${gd}"`,
'VA="MAT MAT"', 'MN="GL"',
'KO="04"'
];
}
function doorHandleGrooveSection(piece, L, B) {
const groove = piece?.machining?.fullHeightHandleGroove;
if (piece?.elementType !== 'portes' || !groove) return [];
const widthMm = Math.max(1, Number(groove.grooveWidthMm) || 8);
const depthMm = Math.max(0.1, Number(groove.grooveDepthMm) || 3);
const offsetMm = Math.max(0, Number(groove.topBottomOffsetMm) || 0);
const hingeSide = piece?.doorPlanHints?.hingeSide === 'right' ? 'right' : 'left';
const xMm = hingeSide === 'right' ? widthMm / 2 : L - widthMm / 2;
const y1Mm = Math.min(B, offsetMm);
const y2Mm = Math.max(y1Mm, B - offsetMm);
return [
'<109 \\Nuten\\',
'MNM="Rainure poignée toute hauteur"',
`XA="${fmtMm(xMm)}"`,
`YA="${fmtMm(y1Mm)}"`,
`XE="${fmtMm(xMm)}"`,
`YE="${fmtMm(y2Mm)}"`,
`NB="${fmtMm(widthMm)}"`,
'RK="NOWRK"',
'EM="MOD2"',
'TV="0"',
'MV="GL"',
`TI="${fmtMm(depthMm)}"`,
'MN="GL"',
'KO="04"' 'KO="04"'
]; ];
} }
@@ -158,15 +217,17 @@ function bohrVertLines(spec, thicknessMm) {
const edgeHint = Boolean(spec.toolMode === 'edge' || spec.face === 'end'); const edgeHint = Boolean(spec.toolMode === 'edge' || spec.face === 'end');
const throughHint = Boolean(spec.toolMode === 'through'); const throughHint = Boolean(spec.toolMode === 'through');
const looksThrough = depthRaw >= thicknessMm - 0.5; const looksThrough = depthRaw >= thicknessMm - 0.5;
const fromUnder = spec.face === 'back';
let bm = 'LS'; let bm = 'LS';
let ti; let ti;
if (throughHint || looksThrough) { if (throughHint || looksThrough) {
bm = 'LSL'; bm = fromUnder ? 'LSLU' : 'LSL';
ti = `${fmtMm(thicknessMm)} + 1`; ti = `${fmtMm(thicknessMm)} + 1`;
} else if (edgeHint && depthRaw >= 38) { } else if (edgeHint && depthRaw >= 38) {
bm = 'LSL'; bm = 'LSL';
ti = fmtMm(depthRaw); ti = fmtMm(depthRaw);
} else { } else {
bm = fromUnder ? 'LSU' : 'LS';
ti = fmtMm(depthRaw); ti = fmtMm(depthRaw);
} }
@@ -192,6 +253,59 @@ function bohrVertLines(spec, thicknessMm) {
]; ];
} }
/**
* Un trou de chant → lignes <103 BohrHoriz (perçage horizontal dans un chant).
* `end` désigne le chant d'entrée dans le repère pièce (origine bas-gauche) :
* left → depuis chant gauche (BM=XP), right → chant droit (BM=XM),
* bottom → chant avant Y=0 (BM=YP), top → chant arrière Y=B (BM=YM).
* ZA = position dans l'épaisseur (axe du trou) : `zMm` si fourni, sinon milieu (D/2).
*/
function bohrHorizLines(spec, L, B, D) {
const du = fmtMm(Math.round((Number(spec.diameterMm) || 8) * 10) / 10);
const ti = fmtMm(Number(spec.depthMm) || 25);
const za = fmtMm(Number.isFinite(Number(spec.zMm)) ? Number(spec.zMm) : D / 2);
let bm;
let xa;
let ya;
switch (spec.end) {
case 'right':
bm = 'XM';
xa = L;
ya = spec.yMm;
break;
case 'bottom':
bm = 'YP';
xa = spec.xMm;
ya = 0;
break;
case 'top':
bm = 'YM';
xa = spec.xMm;
ya = B;
break;
case 'left':
default:
bm = 'XP';
xa = 0;
ya = spec.yMm;
break;
}
return [
'<103 \\BohrHoriz\\',
`MNM="Bohrung ${du} mm"`,
`XA="${fmtMm(xa)}"`,
`YA="${fmtMm(ya)}"`,
`ZA="${za}"`,
`DU="${du}"`,
`TI="${ti}"`,
`BM="${bm}"`,
'AN="1"',
'KO="04"'
];
}
/** Supprime les doublons numériques (tolérance 0.02 mm — sans fusionner trous légitimes proches). */ /** Supprime les doublons numériques (tolérance 0.02 mm — sans fusionner trous légitimes proches). */
function dedupeHoleSpecs(specs, eps = 0.02) { function dedupeHoleSpecs(specs, eps = 0.02) {
const out = []; const out = [];
@@ -200,14 +314,134 @@ function dedupeHoleSpecs(specs, eps = 0.02) {
(o) => (o) =>
Math.abs(o.xMm - s.xMm) < eps && Math.abs(o.xMm - s.xMm) < eps &&
Math.abs(o.yMm - s.yMm) < eps && Math.abs(o.yMm - s.yMm) < eps &&
Math.abs((o.diameterMm || 0) - (s.diameterMm || 0)) < eps Math.abs((o.diameterMm || 0) - (s.diameterMm || 0)) < eps &&
(o.face || 'face') === (s.face || 'face') &&
(o.end || '') === (s.end || '')
); );
if (!hit) out.push(s); if (!hit) out.push(s);
} }
return out; return out;
} }
function buildSingleMprText({ /**
* La fraise à rainurer travaille depuis le dessus de la machine. Pour le
* panneau de dessus, on pose donc la face intérieure caisse vers le haut :
* rainure dos + assemblages caisse dessus, perçages d'habillage dessous.
*/
function orientFaceSpecsForMpr(piece, specs) {
if (piece?.elementType !== 'dessus' || !piece?.machining?.groove) return specs;
return specs.map((spec) => {
if (spec.face === 'face') return { ...spec, face: 'back' };
if (spec.face === 'back') return { ...spec, face: 'face' };
return spec;
});
}
/* ---------------------------------------------------------------------------
* Coupes d'onglet 45° (habillages de finition) :
* - tourillons perpendiculaires à la face d'onglet → <104 BohrUniv (perçage
* spatial : azimut C `CA` + inclinaison agrégat `WI`), sur le système 04.
* (<102 BohrVert exige un plan parallèle au plan supérieur.)
* - trait de scie 45° → <124 Nut_R (« Nuten im A-Winkel » : sciage avec lame
* inclinée WI, macro documenté du format MPR, équivalent Uni_Saeg_...W45
* du postprocesseur DXF HOMAG). Directement sur le plan supérieur (KO 04) :
* pas de plan incliné custom, donc affichage propre et génération NC
* possible avec l'agrégat scie pivotante.
* Géométrie (coordonnées pièce) : la pièce occupe Z=0..D. La scie démarre donc
* explicitement sur la face supérieure ZA=D, puis descend à 45° vers le chant
* extérieur. Les trajectoires sont parcourues de sorte que leur normale gauche
* pointe vers la chute : l'axe de scie est parallèle aux tourillons et le plan
* de lame leur est perpendiculaire. Aucun plan S auxiliaire n'est nécessaire.
* ------------------------------------------------------------------------- */
const MITER_SAW_KERF_MM = 4;
function buildMiterSections({ miterSpecs, L, B, D }) {
const empty = { levelLines: [], opLines: [] };
if (!miterSpecs.length) return empty;
const kerfCenter = MITER_SAW_KERF_MM / 2;
// Trajectoires globales sur KO=04. Pour chaque chant, le côté gauche du
// parcours est l'extérieur de la pièce (la chute).
const SIDES = {
left: {
label: 'gauche',
ca: 0,
entry: (sp) => [D / 2, sp.yMm],
sawPath: [[D - kerfCenter, 0], [D - kerfCenter, B]]
},
right: {
label: 'droite',
ca: 180,
entry: (sp) => [L - D / 2, sp.yMm],
sawPath: [[L - D + kerfCenter, B], [L - D + kerfCenter, 0]]
},
top: {
label: 'haut',
ca: 270,
entry: (sp) => [sp.xMm, B - D / 2],
sawPath: [[0, B - D + kerfCenter], [L, B - D + kerfCenter]]
}
};
const opLines = [];
for (const [side, def] of Object.entries(SIDES)) {
const specs = miterSpecs.filter((sp) => sp.end === side);
if (!specs.length) continue;
/* Tourillons : perçage spatial sur le système 04 (repère pièce). */
for (const sp of specs) {
const [xa, ya] = def.entry(sp);
const du = fmtMm(Number(sp.diameterMm) || 8);
opLines.push(
'<104 \\BohrUniv\\',
`MNM="Tourillon onglet ${def.label} ${du} mm"`,
`XA="${fmtMm(xa)}"`,
`YA="${fmtMm(ya)}"`,
`ZA="${fmtMm(D / 2)}"`,
`CA="${def.ca}"`,
'WI="45"',
// woodWOP compte TI depuis le point XA/YA/ZA le long de l'axe incliné :
// avec l'entrée au centre de la face d'onglet, la profondeur affichée
// vaut le double de la profondeur utile → on émet la moitié.
`TI="${fmtMm((Number(sp.depthMm) || 15) / 2)}"`,
`DU="${du}"`,
'AN="1"',
'F_="STANDARD"',
'KO="04"'
);
}
/* Trait de scie 45° incliné (Nut_R / Nuten im A-Winkel). */
const [start, end] = def.sawPath;
opLines.push(
'<124 \\Nut_R\\',
`MNM="Coupe onglet 45 ${def.label}"`,
`XA="${fmtMm(start[0])}"`,
`YA="${fmtMm(start[1])}"`,
`XE="${fmtMm(end[0])}"`,
`YE="${fmtMm(end[1])}"`,
// KO=04 a son origine en Z=0 sous la pièce ; la face supérieure est Z=D.
`ZA="${fmtMm(D)}"`,
'WI="45"',
`NB="${fmtMm(MITER_SAW_KERF_MM)}"`,
'RK="NOWRK"',
'EM="MOD2"',
'TV="0"',
'MV="GL"',
`TI="${fmtMm(D * Math.SQRT2 + 2)}"`,
'MN="GL"',
'F_="STANDARD"',
'KO="04"'
);
}
return { levelLines: [], opLines };
}
export function buildSingleMprText({
piece, piece,
slug, slug,
qtyIndex, qtyIndex,
@@ -226,43 +460,57 @@ function buildSingleMprText({
const posLabel = `${groupIndex}.${seqGlobal}`; const posLabel = `${groupIndex}.${seqGlobal}`;
const { specs, thicknessMm } = collectHoleSpecsForPiece(piece, L, B, qtyIndex); const { specs, thicknessMm } = collectHoleSpecsForPiece(piece, L, B, qtyIndex);
const unique = dedupeHoleSpecs(specs); const unique = orientFaceSpecsForMpr(piece, dedupeHoleSpecs(specs));
/* Bloc Bohrungen */ // Répartition des perçages par destination d'usinage (repère pièce, origine bas-gauche) :
const holeLines = []; // - onglet 45° (habillages) → plans inclinés dédiés (buildMiterSections),
for (const sp of unique) { // - chant (face='end') → <103 BohrHoriz, regroupés par côté d'entrée,
holeLines.push(...bohrVertLines(sp, thicknessMm)); // - face opposée (face='back') → <102 BohrVert avec cycle WoodWOP « von unten »,
} // - grande face (défaut) → <102 BohrVert « von oben ».
const miterSpecs = unique.filter((sp) => sp.type === 'tourillon_miter' && sp.end);
const dp = Math.max(1, Math.min(98, holeLines.filter((ln) => ln.startsWith('<102')).length)); const rest = unique.filter((sp) => !miterSpecs.includes(sp));
const edgeSpecs = rest.filter((sp) => sp.face === 'end');
const backSpecs = rest.filter((sp) => sp.face === 'back');
const topSpecs = rest.filter((sp) => sp.face !== 'end' && sp.face !== 'back');
const matKm = escQuoted( const matKm = escQuoted(
piece.finishId != null && piece.finishId !== '' ? piece.finishId : defaultMaterialArticle || 'APEX' piece.finishId != null && piece.finishId !== '' ? piece.finishId : defaultMaterialArticle || 'APEX'
); );
// Les opérations restent exprimées dans le repère pièce KO=04. DRWIN tourne
// uniquement la présentation/pose machine (pièces hautes couchées en X).
const miter = buildMiterSections({ miterSpecs, L, B, D });
const bez = escQuoted(baseDesignation(slug));
const hasBack = backSpecs.length > 0;
const lines = []; const lines = [];
lines.push('[H', 'VERSION="4.0 Alpha"', 'FW="1400"', 'FM="1"', 'ZS="40"', 'OP="1"'); lines.push('[H', 'VERSION="4.0 Alpha"', 'FW="1400"', 'FM="1"', 'ZS="40"', 'OP="1"');
// Une pièce plus haute que large est couchée à 270° pour correspondre à
// l'orientation machine; les formules DW conservent son repère d'usinage.
lines.push('[001', 'MAT="2"', 'KM="Materialart"', `DRWIN="${angles.DRWIN}"`, 'KM="Drehwinkel 0,90,180,270"'); lines.push('[001', 'MAT="2"', 'KM="Materialart"', `DRWIN="${angles.DRWIN}"`, 'KM="Drehwinkel 0,90,180,270"');
lines.push(`AUFT="${escQuoted(orderReference)}"`, `KM="AUFTRAG: ${escQuoted(orderReference)}"`); lines.push('AUFT="0"', `KM="AUFTRAG: ${escQuoted(orderReference)}"`);
lines.push(`STKL="${escQuoted(stackListKm)}"`, `KM="STUECKLISTE: ${escQuoted(stackListKm)}"`); lines.push('STKL="0"', `KM="STÜCKLISTE: ${escQuoted(stackListKm)}"`);
lines.push(`POS="${escQuoted(posLabel)}"`, `KM="POSITION: ${escQuoted(posLabel)}"`); lines.push(`POS="${escQuoted(posLabel)}"`, `KM="POSITION: ${escQuoted(posLabel)}"`);
lines.push('BEZ="0"', `KM="Bezeichnung: ${escQuoted(slug)}"`); lines.push('BEZ="0"', `KM="Bezeichnung: ${bez}"`);
lines.push(`L="${fmtMm(L)}"`, 'KM="Laenge in X"', `B="${fmtMm(B)}"`, 'KM="Breite in Y"', `D="${fmtMm(D)}"`, 'KM="Dicke in Z"'); lines.push(`L="${fmtMm(L)}"`, 'KM="Länge in X"', `B="${fmtMm(B)}"`, 'KM="Breite in Y"', `D="${fmtMm(D)}"`, 'KM="Dicke in Z"');
lines.push('ZF="2.0"', 'KM="Zugabe Fraesung"', 'ZB="3.0"', 'KM="Zugabe Bohrung"'); lines.push('ZF="2.0"', 'KM="Zugabe für DurchgangsFräsung"', 'ZB="3.0"', 'KM="Zugabe für DurchgangsBohrung"');
lines.push('RITZEN="1.0"', 'KM="Vorritztiefe mm"'); lines.push('RITZEN="1.0"', 'KM="Vorritztiefe in mm - 0=nicht Vorritzen"');
lines.push(`KAVODI="${kant.kavo}"`, 'KM="Kantendicke vorne"', `KALIDI="${kant.kali}"`, 'KM="Kantendicke links"'); lines.push(`KAVODI="${kant.kavo}"`, 'KM="Kantendicke vorne"', `KALIDI="${kant.kali}"`, 'KM="Kantendicke links"');
lines.push(`KAHIDI="${kant.kahi}"`, 'KM="Kantendicke hinten"', `KAREDI="${kant.kare}"`, 'KM="Kantendicke rechts"'); lines.push(`KAHIDI="${kant.kahi}"`, 'KM="Kantendicke hinten"', `KAREDI="${kant.kare}"`, 'KM="Kantendicke rechts"');
lines.push('FORMAT="0"', 'KM="Formatieren 0 oder 1"'); lines.push('FORMAT="0"', 'KM="Formatieren 0 oder 1"');
lines.push('ZUGLI="0.0"', 'ZUGRE="0.0"', 'ZUGVO="0.0"', 'ZUGHI="0.0"'); lines.push('ZUGLI="0.0"', 'KM="Zugabe für Gehrung links"');
lines.push('DW="IF DRWIN=0 OR DRWIN=90 OR DRWIN=180 THEN DRWIN ELSE 270"'); lines.push('ZUGRE="0.0"', 'KM="Zugabe für Gehrung rechts"');
lines.push('ld="IF DW=90 OR DW=270 THEN B ELSE L"', 'bd="IF DW=90 OR DW=270 THEN L ELSE B"'); lines.push('ZUGVO="0.0"', 'KM="Zugabe für Gehrung vorne"');
lines.push( lines.push('ZUGHI="0.0"', 'KM="Zugabe für Gehrung hinten"');
'offxL="IF DW=180 THEN ZUGRE ELSE ZUGLI"', lines.push('DW="IF DRWIN=0 OR DRWIN=90 OR DRWIN=180 THEN DRWIN ELSE 270"', 'KM="DISABLED Drehwinkel korrigiert bei Fehleingabe"');
'offxB="IF DW=90 THEN ZUGHI ELSE ZUGVO"', lines.push('ld="IF DW=90 OR DW=270 THEN B ELSE L"', 'KM="DISABLED Länge in X (gedreht)"');
'offyL="IF DW=180 THEN ZUGHI ELSE ZUGVO"', lines.push('bd="IF DW=90 OR DW=270 THEN L ELSE B"', 'KM="DISABLED Breite in Y (gedreht)"');
'offyB="IF DW=90 THEN ZUGLI ELSE ZUGRE"' lines.push('offxL="IF DW=180 THEN ZUGRE ELSE ZUGLI"', 'KM="DISABLED Offset in X (0° oder 180°)"');
); lines.push('offxB="IF DW=90 THEN ZUGHI ELSE ZUGVO"', 'KM="DISABLED Offset in X (90° oder 270°)"');
lines.push('offyL="IF DW=180 THEN ZUGHI ELSE ZUGVO"', 'KM="DISABLED Offset in Y (0° oder 180°)"');
lines.push('offyB="IF DW=90 THEN ZUGLI ELSE ZUGRE"', 'KM="DISABLED Offset in Y (90° oder 270°)"');
lines.push( lines.push(
'[K', '[K',
'<00 \\Koordinatensystem\\', '<00 \\Koordinatensystem\\',
@@ -310,9 +558,12 @@ function buildSingleMprText({
'KI="90"', 'KI="90"',
'D2="0"', 'D2="0"',
'MI="0"', 'MI="0"',
...miter.levelLines,
'<100 \\WerkStck\\', '<100 \\WerkStck\\',
'LA="IF DW=90 OR DW=270 THEN B ELSE L"', // WerkStck uses the piece-local dimensions. KO=04 applies DRWIN to the
'BR="IF DW=90 OR DW=270 THEN L ELSE B"', // stock and operations together; swapping here would rotate the stock twice.
`LA="${fmtMm(L)}"`,
`BR="${fmtMm(B)}"`,
'DI="D"', 'DI="D"',
'FNX="IF DW=90 OR DW=270 THEN offxB ELSE offxL"', 'FNX="IF DW=90 OR DW=270 THEN offxB ELSE offxL"',
'FNY="IF DW=90 OR DW=270 THEN offyB ELSE offyL"', 'FNY="IF DW=90 OR DW=270 THEN offyB ELSE offyL"',
@@ -320,20 +571,25 @@ function buildSingleMprText({
'AY="IF DW=90 OR DW=270 THEN ZUGLI+ZUGRE ELSE ZUGVO+ZUGHI"', 'AY="IF DW=90 OR DW=270 THEN ZUGLI+ZUGRE ELSE ZUGVO+ZUGHI"',
'<101 \\Kommentar\\', '<101 \\Kommentar\\',
`KM="AUFTRAG : ${escQuoted(orderReference)}"`, `KM="AUFTRAG : ${escQuoted(orderReference)}"`,
`KM="STUECKLISTE : ${escQuoted(stackListKm)}"`, `KM="STÜCKLISTE : ${escQuoted(stackListKm)}"`,
`KM="POSITION : ${escQuoted(posLabel)}"`, `KM="POSITION : ${escQuoted(posLabel)}"`,
`KM="Bezeichnung: ${escQuoted(slug)}"`, `KM="Bezeichnung: ${escQuoted(slug)}"`,
'KM=""', 'KM=""',
`KM="${escQuoted(angles.turnMsg)}"`, `KM="${escQuoted(angles.turnMsg)}"`,
'KM=""', 'KM=""',
'KM="Teileinfo"', 'KM="Teileinfo"',
`KM="Groesse: ${fmtMm(L)} x ${fmtMm(B)}"`, `KM="Größe: ${fmtMm(L)} x ${fmtMm(B)}"`,
`KM="Staerke: ${fmtMm(D)}"`, `KM="Stärke: ${fmtMm(D)}"`,
`KM=""`, `KM=""`,
`KM="Material: ${matKm}"`, `KM="Material: ${matKm}"`,
'KM="Belag IN: "', 'KM="Belag IN: "',
'KM="Belag AU: "', 'KM="Belag AU: "',
'KM=""', 'KM=""'
);
// Les usinages de sous-face utilisent directement BM=LSU/LSLU et ne
// nécessitent donc aucun retournement de la pièce.
lines.push(
'<139 \\Komponente\\', '<139 \\Komponente\\',
'MNM="Beschriftung"', 'MNM="Beschriftung"',
'IN="RSO/Makro/RSO_Beschriftung.mpr"', 'IN="RSO/Makro/RSO_Beschriftung.mpr"',
@@ -345,7 +601,10 @@ function buildSingleMprText({
'VA="KAREDI KAREDI"', 'VA="KAREDI KAREDI"',
`VA="GEDREHT ${angles.beschGedreht}"`, `VA="GEDREHT ${angles.beschGedreht}"`,
'VA="NCSTOPP 0"', 'VA="NCSTOPP 0"',
'KO="04"', 'KO="04"'
);
// Macro de formatage (RSO_Format) — exécutée seulement si FORMAT=1 (??="FORMAT").
lines.push(
'<139 \\Komponente\\', '<139 \\Komponente\\',
'MNM="Formatierung"', 'MNM="Formatierung"',
'IN="RSO/Makro/RSO_Format.mpr"', 'IN="RSO/Makro/RSO_Format.mpr"',
@@ -363,15 +622,41 @@ function buildSingleMprText({
'VA="KAFRMAX 2.0"', 'VA="KAFRMAX 2.0"',
'VA="MAT MAT"', 'VA="MAT MAT"',
'??="FORMAT"', '??="FORMAT"',
'KO="04"',
'<121 \\Block\\',
'MNM="Bearbeitungen von oben "',
`DP="${dp}"`,
'KO="04"' 'KO="04"'
); );
lines.push(...holeLines); // Bloc « Bearbeitungen von oben » — perçages verticaux sur la grande face.
if (topSpecs.length) {
lines.push('<121 \\Block\\', 'MNM="Bearbeitungen von oben "', `DP="${topSpecs.length}"`, 'KO="04"');
for (const sp of topSpecs) lines.push(...bohrVertLines(sp, thicknessMm));
}
// Bloc « Bearbeitungen von unten » — BM=LSU/LSLU indique réellement à
// WoodWOP que l'outil entre par la sous-face.
if (hasBack) {
lines.push('<121 \\Block\\', 'MNM="Bearbeitungen von unten "', `DP="${backSpecs.length}"`, 'KO="04"');
for (const sp of backSpecs) {
lines.push(...bohrVertLines(sp, thicknessMm));
}
}
// Blocs de perçage sur chant — un bloc par côté d'entrée.
const edgeBlocks = [
['left', 'Bearbeitungen von links '],
['right', 'Bearbeitungen von rechts '],
['bottom', 'Bearbeitungen von vorne '],
['top', 'Bearbeitungen von hinten ']
];
for (const [end, label] of edgeBlocks) {
const group = edgeSpecs.filter((sp) => (sp.end || 'left') === end);
if (!group.length) continue;
lines.push('<121 \\Block\\', `MNM="${label}"`, `DP="${group.length}"`, 'KO="04"');
for (const sp of group) lines.push(...bohrHorizLines(sp, L, B, D));
}
lines.push(...miter.opLines);
lines.push(...grooveSection(piece, L, B)); lines.push(...grooveSection(piece, L, B));
lines.push(...doorHandleGrooveSection(piece, L, B));
lines.push('!'); lines.push('!');
return lines.join('\r\n'); return lines.join('\r\n');
} }
@@ -407,10 +692,12 @@ function buildMprFileRecords(entries, mprOptions) {
} }
async function packMprRecords(records, zipBaseName) { async function packMprRecords(records, zipBaseName) {
// Encodage Windows-1252 (Latin-1) : format attendu par woodWOP pour les
// commentaires KM allemands (ä ö ü ß °), comme dans les fichiers fabricant.
if (records.length === 1) { if (records.length === 1) {
const only = records[0]; const only = records[0];
return { return {
buffer: Buffer.from(only.content, 'utf8'), buffer: Buffer.from(only.content, 'latin1'),
fileName: only.fileName, fileName: only.fileName,
mimeType: 'application/octet-stream', mimeType: 'application/octet-stream',
fileCount: 1 fileCount: 1
@@ -419,7 +706,7 @@ async function packMprRecords(records, zipBaseName) {
const zip = new JSZip(); const zip = new JSZip();
for (const rec of records) { for (const rec of records) {
zip.file(rec.fileName, rec.content); zip.file(rec.fileName, Buffer.from(rec.content, 'latin1'));
} }
const safeBase = safeZipFileName(zipBaseName || 'piece-mpr'); const safeBase = safeZipFileName(zipBaseName || 'piece-mpr');
return { return {
+168 -17
View File
@@ -8,7 +8,10 @@
import { import {
PANEL, GROOVE, SYSTEM32, SYSTEM32_SEPARATORS, HINGE, CONNECTORS, PANEL, GROOVE, SYSTEM32, SYSTEM32_SEPARATORS, HINGE, CONNECTORS,
getHingeCountForDoor, getHingeCupPositions, getDoorHingeSide, HANDLE, getHingeCountForDoor, getHingeCupPositions, getDoorHingeSide, HANDLE,
EDGE_BANDING, REINFORCEMENT, REINFORCEMENT_MACHINING, PLINTH, SHELF_MACHINING EDGE_BANDING, REINFORCEMENT, REINFORCEMENT_MACHINING, PLINTH, SHELF_MACHINING,
resolveHorizontalSpan, validateHorizontalSpans, getSeparatorVerticalSegments,
hasSideHabillage, hasSidePlinthReturn, getAxiloPlinthReturnLengthMm,
getAxiloPlinthFrontSideDeductionMm
} from '../../shared/manufacturing.js'; } from '../../shared/manufacturing.js';
// Constants for drawing // Constants for drawing
@@ -36,6 +39,8 @@ const SHARED_STYLES = `
.renfort { fill: #c9a96e; stroke: #333; stroke-width: 0.5; opacity: 0.7; } .renfort { fill: #c9a96e; stroke: #333; stroke-width: 0.5; opacity: 0.7; }
.detail-label { font-family: Arial, sans-serif; font-size: 7px; fill: #666; } .detail-label { font-family: Arial, sans-serif; font-size: 7px; fill: #666; }
.fh-groove { fill: none; stroke: #0d9488; stroke-width: 1; stroke-dasharray: 3, 2; } .fh-groove { fill: none; stroke: #0d9488; stroke-width: 1; stroke-dasharray: 3, 2; }
.horizontal-span { fill: none; stroke: #b45309; stroke-width: 2; }
.horizontal-span-label { font-family: Arial, sans-serif; font-size: 8px; font-weight: bold; fill: #92400e; }
`; `;
// ── Helpers ────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────
@@ -140,6 +145,19 @@ function generateDimensionLine(x, y, length, label, direction = 'horizontal') {
export function generateAssemblyPlan(config, modules = []) { export function generateAssemblyPlan(config, modules = []) {
const { widthMm, heightMm, plinthHeightMm, doorType } = config; const { widthMm, heightMm, plinthHeightMm, doorType } = config;
const bodyHeight = heightMm - plinthHeightMm; const bodyHeight = heightMm - plinthHeightMm;
const planModules = modules.length > 0 ? modules : (Array.isArray(config.modules) ? config.modules : []);
const spanConfig = { ...config, modules: planModules };
const spanIssues =
Array.isArray(config.horizontalSpans) && config.horizontalSpans.length
? validateHorizontalSpans(spanConfig)
: [];
const invalidSpanIds = new Set(
spanIssues.map((issue) => issue.spanId).filter((id) => id != null).map(String)
);
const resolvedSpans = (Array.isArray(config.horizontalSpans) ? config.horizontalSpans : [])
.filter((span) => span?.id != null && !invalidSpanIds.has(String(span.id)))
.map((span) => resolveHorizontalSpan(span, spanConfig))
.filter(Boolean);
const S = DRAWING_SCALE; const S = DRAWING_SCALE;
const svgWidth = widthMm * S + MARGIN * 2; const svgWidth = widthMm * S + MARGIN * 2;
@@ -154,19 +172,49 @@ export function generateAssemblyPlan(config, modules = []) {
const oX = MARGIN; const oX = MARGIN;
const oY = MARGIN; const oY = MARGIN;
// Plinth const habLeft = hasSideHabillage(config, 'left');
const habRight = hasSideHabillage(config, 'right');
const returnLeft = hasSidePlinthReturn(config, 'left');
const returnRight = hasSidePlinthReturn(config, 'right');
const habT = PANEL.structure * S;
const isNiche = config?.installationType === 'niche';
const leftDeductMm = isNiche ? 0 : getAxiloPlinthFrontSideDeductionMm(config, 'left');
const rightDeductMm = isNiche ? 0 : getAxiloPlinthFrontSideDeductionMm(config, 'right');
const sideSetbackMm = PLINTH.frontSetbackMm;
// Plinth — shortened where side cladding or a recessed plinth return runs to the floor
const plinthY = oY + bodyHeight * S; const plinthY = oY + bodyHeight * S;
svg += `<rect x="${oX}" y="${plinthY}" width="${widthMm * S}" height="${plinthHeightMm * S}" class="plinth" />`; const plinthX = oX + leftDeductMm * S;
const plinthW = (widthMm - leftDeductMm - rightDeductMm) * S;
svg += `<rect x="${plinthX}" y="${plinthY}" width="${plinthW}" height="${plinthHeightMm * S}" class="plinth" />`;
// Body // Body
svg += `<rect x="${oX}" y="${oY}" width="${widthMm * S}" height="${bodyHeight * S}" class="body" />`; svg += `<rect x="${oX}" y="${oY}" width="${widthMm * S}" height="${bodyHeight * S}" class="body" />`;
if (habLeft) {
svg += `<rect x="${oX}" y="${oY}" width="${habT}" height="${heightMm * S}" class="body" />`;
} else if (returnLeft) {
svg += `<rect x="${oX + sideSetbackMm * S}" y="${plinthY}" width="${habT}" height="${plinthHeightMm * S}" class="plinth" />`;
}
if (habRight) {
svg += `<rect x="${oX + widthMm * S - habT}" y="${oY}" width="${habT}" height="${heightMm * S}" class="body" />`;
} else if (returnRight) {
svg += `<rect x="${oX + widthMm * S - (sideSetbackMm + PANEL.structure) * S}" y="${plinthY}" width="${habT}" height="${plinthHeightMm * S}" class="plinth" />`;
}
// Module separators // Module separators
if (modules.length > 0) { if (planModules.length > 0) {
let currentX = oX; let currentX = oX;
for (let i = 0; i < modules.length - 1; i++) { for (let i = 0; i < planModules.length - 1; i++) {
currentX += modules[i].widthMm * S; currentX += planModules[i].widthMm * S;
if (resolvedSpans.length === 0) {
svg += `<line x1="${currentX}" y1="${oY}" x2="${currentX}" y2="${plinthY}" class="separator" />`; svg += `<line x1="${currentX}" y1="${oY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
} else {
for (const segment of getSeparatorVerticalSegments(spanConfig, i)) {
const y1 = plinthY - segment.topMm * S;
const y2 = plinthY - segment.bottomMm * S;
svg += `<line x1="${currentX}" y1="${y1}" x2="${currentX}" y2="${y2}" class="separator" />`;
}
}
} }
} }
@@ -182,9 +230,9 @@ export function generateAssemblyPlan(config, modules = []) {
svg += `<rect x="${oX + i * panelWidth * 0.95 + 5}" y="${oY + 5}" width="${panelWidth * 0.9}" height="${(bodyHeight - 10) * S}" class="door" />`; svg += `<rect x="${oX + i * panelWidth * 0.95 + 5}" y="${oY + 5}" width="${panelWidth * 0.9}" height="${(bodyHeight - 10) * S}" class="door" />`;
} }
} else { } else {
const blockCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2); const blockCount = planModules.length > 0 ? planModules.length : (config.blocks?.length || 2);
let currentX = oX; let currentX = oX;
const items = modules.length > 0 ? modules : (config.blocks || []); const items = planModules.length > 0 ? planModules : (config.blocks || []);
const hw = config.hardware || {}; const hw = config.hardware || {};
const fullHeightHandle = hw.handleType === 'full_height'; const fullHeightHandle = hw.handleType === 'full_height';
const widthDeductionMm = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0; const widthDeductionMm = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0;
@@ -193,24 +241,116 @@ export function generateAssemblyPlan(config, modules = []) {
for (let i = 0; i < blockCount; i++) { for (let i = 0; i < blockCount; i++) {
const bw = items[i]?.widthMm || (widthMm / blockCount); const bw = items[i]?.widthMm || (widthMm / blockCount);
const doorWmm = Math.max(100, Math.floor(widthMm / blockCount) - 4 - widthDeductionMm);
const modulePx = bw * S; const modulePx = bw * S;
const doorWmm = Math.max(
100,
Math.floor(resolvedSpans.length === 0 ? widthMm / blockCount : bw) -
4 -
widthDeductionMm
);
const doorWPx = doorWmm * S; const doorWPx = doorWmm * S;
const doorX = currentX + (modulePx - doorWPx) / 2; const doorX = currentX + (modulePx - doorWPx) / 2;
const doorY = oY + gapMm * S;
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWPx}" height="${doorHmm * S}" class="door" />`;
const hingeSide = getDoorHingeSide(i, blockCount, hw); const hingeSide = getDoorHingeSide(i, blockCount, hw);
if (resolvedSpans.length === 0 || planModules.length === 0) {
const doorY = oY + gapMm * S;
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWPx}" height="${doorHmm * S}" class="door" />`;
svg += drawHingeMarkers(doorX, doorY, doorWmm, doorHmm, S, hingeSide); svg += drawHingeMarkers(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
if (fullHeightHandle) { if (fullHeightHandle) {
svg += drawFullHeightHandleGrooveElevation(doorX, doorY, doorWmm, doorHmm, S, hingeSide); svg += drawFullHeightHandleGrooveElevation(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
} }
} else {
const module = planModules[i] || {};
let heights = Array.isArray(module.blockHeightsMm)
? module.blockHeightsMm.map(Number).filter((height) => Number.isFinite(height) && height > 0)
: [];
if (!heights.length) {
const stacked = module.stacked === true ||
(module.stacked == null && config.stackEnabled === true);
const bottom = Number(module.stackBottomHeightMm ?? config.stackBottomHeightMm);
heights = stacked && bottom > 0 && bottom < bodyHeight
? [bottom, bodyHeight - bottom]
: [bodyHeight];
}
const blockDoors = Array.isArray(module.blockDoors) ? module.blockDoors : null;
let bottomMm = 0;
heights.forEach((height, blockIndex) => {
const topMm = bottomMm + height;
const covered = resolvedSpans.some((span) =>
span.moduleIndexes.includes(i) &&
bottomMm >= span.bottomMm - 0.5 &&
topMm <= span.topMm + 0.5
);
const hasDoor =
blockDoors && typeof blockDoors[blockIndex] === 'boolean'
? blockDoors[blockIndex]
: true;
if (hasDoor && !covered) {
const segmentDoorHeightMm = Math.max(80, height - 4);
const doorY = plinthY - topMm * S + gapMm * S;
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWPx}" height="${segmentDoorHeightMm * S}" class="door" />`;
svg += drawHingeMarkers(
doorX, doorY, doorWmm, segmentDoorHeightMm, S, hingeSide
);
if (fullHeightHandle) {
svg += drawFullHeightHandleGrooveElevation(
doorX, doorY, doorWmm, segmentDoorHeightMm, S, hingeSide
);
}
}
bottomMm = topMm;
});
}
currentX += modulePx; currentX += modulePx;
} }
} }
for (const span of resolvedSpans) {
const x1 = oX + span.leftMm * S;
const x2 = oX + span.rightMm * S;
const yBottom = plinthY - span.bottomMm * S;
const yTop = plinthY - span.topMm * S;
svg += `<line x1="${x1}" y1="${yBottom}" x2="${x2}" y2="${yBottom}" class="horizontal-span" />`;
svg += `<line x1="${x1}" y1="${yTop}" x2="${x2}" y2="${yTop}" class="horizontal-span" />`;
svg += `<text x="${(x1 + x2) / 2}" y="${(yBottom + yTop) / 2}" class="horizontal-span-label" text-anchor="middle">Portée horizontale — colonnes ${span.moduleIndexes.map((index) => index + 1).join('–')}</text>`;
const doorMode = span.span?.doorMode || 'none';
if (doorType !== 'sliding' && doorMode !== 'none') {
const hw = config.hardware || {};
const fullHeightHandle = hw.handleType === 'full_height';
const widthDeductionMm = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0;
const doorHeightMm = Math.max(120, span.topMm - span.bottomMm - 4);
const doorY = yTop + 2 * S;
const defs = doorMode === 'single'
? [{ leftMm: span.leftMm, widthMm: span.widthMm }]
: [
{
leftMm: span.leftMm,
widthMm: span.widthMm / 2,
hingeSide: 'left'
},
{
leftMm: span.leftMm + span.widthMm / 2,
widthMm: span.widthMm / 2,
hingeSide: 'right'
}
];
defs.forEach((door, doorIndex) => {
const doorWidthMm = Math.max(100, Math.floor(door.widthMm) - 4 - widthDeductionMm);
const doorX = oX + door.leftMm * S + (door.widthMm - doorWidthMm) * S / 2;
const hingeSide = door.hingeSide || getDoorHingeSide(doorIndex, defs.length, hw);
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWidthMm * S}" height="${doorHeightMm * S}" class="door" data-horizontal-span-door="${doorMode}" data-hinge-side="${hingeSide}" />`;
svg += drawHingeMarkers(doorX, doorY, doorWidthMm, doorHeightMm, S, hingeSide);
if (fullHeightHandle) {
svg += drawFullHeightHandleGrooveElevation(
doorX, doorY, doorWidthMm, doorHeightMm, S, hingeSide
);
}
});
}
}
// Reinforcements (Forteresse 16 mm): // Reinforcements (Forteresse 16 mm):
// width = armoire width - 2×19 (entre côtés), // width = armoire width - 2×19 (entre côtés),
// one at top + one at bottom (offset 19 mm), // one at top + one at bottom (offset 19 mm),
@@ -257,12 +397,23 @@ export function generateSidePlan(config) {
const oX = MARGIN; const oX = MARGIN;
const oY = MARGIN; const oY = MARGIN;
// Plinth const habLeft = hasSideHabillage(config, 'left');
const plinthY = oY + bodyHeight * S; const returnLeft = hasSidePlinthReturn(config, 'left');
svg += `<rect x="${oX}" y="${plinthY}" width="${depthMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
// Body const plinthY = oY + bodyHeight * S;
const plinthSetbackMm = PLINTH.frontSetbackMm;
if (habLeft) {
// Habillage gauche jusqu'au sol : pas de plinthe visible de ce côté.
svg += `<rect x="${oX}" y="${oY}" width="${depthMm * S}" height="${heightMm * S}" class="body" />`;
} else {
if (returnLeft) {
const returnLenMm = getAxiloPlinthReturnLengthMm(depthMm);
svg += `<rect x="${oX + plinthSetbackMm * S}" y="${plinthY}" width="${returnLenMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
} else {
svg += `<rect x="${oX + plinthSetbackMm * S}" y="${plinthY}" width="${PLINTH.panelThicknessMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
}
svg += `<rect x="${oX}" y="${oY}" width="${depthMm * S}" height="${bodyHeight * S}" class="body" />`; svg += `<rect x="${oX}" y="${oY}" width="${depthMm * S}" height="${bodyHeight * S}" class="body" />`;
}
// Back panel (8mm) shown as a thin rectangle at the back // Back panel (8mm) shown as a thin rectangle at the back
const backX = oX + (depthMm - PANEL.back) * S; const backX = oX + (depthMm - PANEL.back) * S;
+374 -20
View File
@@ -4,13 +4,50 @@
* Specs APEX 4.0 importées de shared/manufacturing.js * Specs APEX 4.0 importées de shared/manufacturing.js
*/ */
import { PANEL, DRAWERS, NICHE, getHingeCountForDoor } from '../../shared/manufacturing.js'; import {
PANEL,
DRAWERS,
NICHE,
HANDLE,
PLINTH,
getAxiloPlinthPanelHeightMm,
hasSidePlinthReturn,
getAxiloPlinthReturnLengthMm,
getHingeCountForDoor,
resolveHorizontalSpan,
validateHorizontalSpans,
usesBlockConstruction,
MAX_PANEL_DIMENSION_MM
} from '../../shared/manufacturing.js';
function numberOr(value, fallback) { function numberOr(value, fallback) {
const n = typeof value === 'number' ? value : Number(value); const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : fallback; return Number.isFinite(n) ? n : fallback;
} }
function resolvedHorizontalSpansForConfig(config) {
if (!Array.isArray(config?.horizontalSpans) || config.horizontalSpans.length === 0) return [];
const invalidIds = new Set(
validateHorizontalSpans(config)
.map((issue) => issue?.spanId)
.filter((id) => id != null)
.map(String)
);
return config.horizontalSpans
.filter((span) => span?.id != null && !invalidIds.has(String(span.id)))
.map((span) => resolveHorizontalSpan(span, config))
.filter(Boolean);
}
function spanCoversBlock(resolvedSpans, moduleIndex, bottomMm, topMm) {
const eps = 0.5;
return resolvedSpans.some((span) =>
span.moduleIndexes.includes(moduleIndex) &&
bottomMm >= span.bottomMm - eps &&
topMm <= span.topMm + eps
);
}
/** /**
* Calcule la surface utile des éléments visibles (portes, fileurs, bandeau, plinthe, côtés doublage). * Calcule la surface utile des éléments visibles (portes, fileurs, bandeau, plinthe, côtés doublage).
* Épaisseur panneaux 19 mm (référence APEX 4.0). * Épaisseur panneaux 19 mm (référence APEX 4.0).
@@ -26,14 +63,70 @@ function calculateVisibleSurface(config, panelThicknessMm = PANEL.structure) {
if (doorType === 'sliding' && slidingPanelsCount) { if (doorType === 'sliding' && slidingPanelsCount) {
const panelWidth = widthMm / slidingPanelsCount; const panelWidth = widthMm / slidingPanelsCount;
surface += slidingPanelsCount * (panelWidth * (heightMm - plinthHeightMm)) * 2; // deux faces par vantail surface += slidingPanelsCount * (panelWidth * (heightMm - plinthHeightMm)) * 2; // deux faces par vantail
} else if (Array.isArray(config.modules) && config.modules.length > 0) {
// 1 porte par block AVEC porte (mod.blockDoors); block sans porte = niche ouverte, pas de façade.
const bodyMm = heightMm - plinthHeightMm;
const resolvedSpans = resolvedHorizontalSpansForConfig(config);
for (let moduleIndex = 0; moduleIndex < config.modules.length; moduleIndex++) {
const m = config.modules[moduleIndex];
const colW = Number(m?.widthMm) || widthMm / config.modules.length;
const arr = Array.isArray(m?.blockHeightsMm)
? m.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0)
: null;
let heights;
if (arr && arr.length > 0) {
heights = arr;
} else {
const stacked = typeof m?.stacked === 'boolean' ? m.stacked : config.stackEnabled === true;
heights = stacked && bodyMm >= 600
? [Number(m?.stackBottomHeightMm ?? config.stackBottomHeightMm) || 1000]
.flatMap((b) => [b, bodyMm - b])
: [bodyMm];
}
const doors = Array.isArray(m?.blockDoors) ? m.blockDoors : null;
let blockBottomMm = 0;
heights.forEach((h, k) => {
const blockTopMm = blockBottomMm + h;
const covered = spanCoversBlock(resolvedSpans, moduleIndex, blockBottomMm, blockTopMm);
blockBottomMm = blockTopMm;
const hasDoor = doors && typeof doors[k] === 'boolean' ? doors[k] : true;
if (hasDoor && !covered) surface += colW * h * 2; // deux faces
});
}
const fullHeightDeduction =
config.hardware?.handleType === 'full_height' ? HANDLE.fullHeightWidthDeductionMm : 0;
for (const span of resolvedSpans) {
const doorMode = span.span?.doorMode || 'none';
if (doorMode === 'none') continue;
const doorHeightMm = Math.max(120, span.topMm - span.bottomMm - 4);
if (doorMode === 'single') {
const doorWidthMm = Math.max(100, span.widthMm - 4 - fullHeightDeduction);
surface += doorWidthMm * doorHeightMm * 2;
} else if (doorMode === 'perColumn') {
for (let leafIndex = 0; leafIndex < 2; leafIndex++) {
const doorWidthMm = Math.max(
100,
Math.floor(span.widthMm / 2) - 4 - fullHeightDeduction
);
surface += doorWidthMm * doorHeightMm * 2;
}
}
}
} else { } else {
const doorsCount = blocks.length <= 1 ? Math.min(2, Math.ceil((blocks[0]?.widthMm || widthMm) / 600)) : blocks.length * 2; const doorsCount = blocks.length <= 1 ? Math.min(2, Math.ceil((blocks[0]?.widthMm || widthMm) / 600)) : blocks.length * 2;
const doorWidth = widthMm / Math.max(1, doorsCount); const doorWidth = widthMm / Math.max(1, doorsCount);
surface += doorsCount * (doorWidth * (heightMm - plinthHeightMm)) * 2; surface += doorsCount * (doorWidth * (heightMm - plinthHeightMm)) * 2;
} }
// Plinthe (face avant) // Plinthe (face avant + retours latéraux s'il n'y a pas d'habillage)
surface += widthMm * plinthHeightMm; const plinthH = getAxiloPlinthPanelHeightMm(plinthHeightMm);
surface += widthMm * plinthH;
if (hasSidePlinthReturn(config, 'left')) {
surface += getAxiloPlinthReturnLengthMm(depthMm) * plinthH;
}
if (hasSidePlinthReturn(config, 'right')) {
surface += getAxiloPlinthReturnLengthMm(depthMm) * plinthH;
}
// Bandeau et fileurs si niche // Bandeau et fileurs si niche
if (isNiche) { if (isNiche) {
@@ -188,18 +281,69 @@ function calculateModuleCosts(modules, moduleCatalog) {
* @param {Object} config - Config with door and drawer counts * @param {Object} config - Config with door and drawer counts
* @returns {Object} - Hardware costs breakdown * @returns {Object} - Hardware costs breakdown
*/ */
/**
* Hauteurs (mm) des portes battantes: 1 porte par block AVEC porte (mod.blockDoors),
* par colonne. Colonne empilée sans flags portes → 1 porte par block.
*/
function hingedDoorHeightsMm(config) {
const bodyMm = (Number(config.heightMm) || 2400) - (Number(config.plinthHeightMm) || 100);
const mods = Array.isArray(config.modules) && config.modules.length > 0 ? config.modules : null;
const cols = mods ?? Array.from({ length: config.blocks?.length || 2 }, () => null);
const resolvedSpans = mods ? resolvedHorizontalSpansForConfig(config) : [];
const out = [];
for (let moduleIndex = 0; moduleIndex < cols.length; moduleIndex++) {
const m = cols[moduleIndex];
const arr = Array.isArray(m?.blockHeightsMm)
? m.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0)
: null;
let heights;
if (arr && arr.length > 0) {
heights = arr;
} else {
const stacked = typeof m?.stacked === 'boolean' ? m.stacked : config.stackEnabled === true;
if (stacked && bodyMm >= 600) {
const bottom = Number(m?.stackBottomHeightMm ?? config.stackBottomHeightMm) || 1000;
heights = [bottom, bodyMm - bottom];
} else {
heights = [bodyMm];
}
}
const doors = Array.isArray(m?.blockDoors) ? m.blockDoors : null;
let blockBottomMm = 0;
heights.forEach((h, k) => {
const blockTopMm = blockBottomMm + h;
const covered = spanCoversBlock(resolvedSpans, moduleIndex, blockBottomMm, blockTopMm);
blockBottomMm = blockTopMm;
const hasDoor = doors && typeof doors[k] === 'boolean' ? doors[k] : true;
if (hasDoor && !covered) out.push(Math.max(80, h - 4));
});
}
for (const span of resolvedSpans) {
const doorMode = span.span?.doorMode || 'none';
const count =
doorMode === 'single' ? 1 :
doorMode === 'perColumn' ? 2 : 0;
const heightMm = Math.max(120, span.topMm - span.bottomMm - 4);
for (let index = 0; index < count; index++) out.push(heightMm);
}
return out;
}
function calculateHardwareCosts(hardware, hardwareData, config) { function calculateHardwareCosts(hardware, hardwareData, config) {
const { handles = [], hinges = [], blum = [] } = hardwareData; const { handles = [], hinges = [], blum = [] } = hardwareData;
const breakdown = []; const breakdown = [];
let totalChf = 0; let totalChf = 0;
// Handle costs - one per door const hingedDoorHeights = config.doorType === 'sliding' ? [] : hingedDoorHeightsMm(config);
// Handle costs - one per door (blocks sans porte exclus)
if (hardware.handleVariantId) { if (hardware.handleVariantId) {
const handle = handles.find(h => h.id === hardware.handleVariantId); const handle = handles.find(h => h.id === hardware.handleVariantId);
if (handle) { if (handle) {
const doorCount = config.doorType === 'sliding' const doorCount = config.doorType === 'sliding'
? (config.slidingPanelsCount || 2) ? (config.slidingPanelsCount || 2)
: (config.modules?.length || config.blocks?.length || 2); : hingedDoorHeights.length;
if (doorCount > 0) {
const cost = handle.price_chf * doorCount; const cost = handle.price_chf * doorCount;
totalChf += cost; totalChf += cost;
breakdown.push({ breakdown.push({
@@ -211,15 +355,17 @@ function calculateHardwareCosts(hardware, hardwareData, config) {
}); });
} }
} }
}
// Hinge costs - dynamic quantity from APEX 4.0 matrix // Hinge costs - dynamic quantity from APEX 4.0 matrix, par porte (hauteur du block)
if (hardware.hingeVariantId && config.doorType !== 'sliding') { if (hardware.hingeVariantId && config.doorType !== 'sliding') {
const hinge = hinges.find(h => h.id === hardware.hingeVariantId); const hinge = hinges.find(h => h.id === hardware.hingeVariantId);
if (hinge) { if (hinge) {
const doorCount = config.modules?.length || config.blocks?.length || 2; const quantity = hingedDoorHeights.reduce(
const doorHeightMm = (config.heightMm || 2400) - (config.plinthHeightMm || 100) - 4; (sum, doorHeightMm) => sum + getHingeCountForDoor({ heightMm: doorHeightMm }),
const hingesPerDoor = getHingeCountForDoor({ heightMm: doorHeightMm }); 0
const quantity = doorCount * hingesPerDoor; );
if (quantity > 0) {
const cost = hinge.price_chf * quantity; const cost = hinge.price_chf * quantity;
totalChf += cost; totalChf += cost;
breakdown.push({ breakdown.push({
@@ -231,6 +377,7 @@ function calculateHardwareCosts(hardware, hardwareData, config) {
}); });
} }
} }
}
// Drawer system costs - one per drawer // Drawer system costs - one per drawer
if (hardware.drawerSystemId) { if (hardware.drawerSystemId) {
@@ -265,6 +412,24 @@ function calculateHardwareCosts(hardware, hardwareData, config) {
*/ */
export function calculatePrice(config, data) { export function calculatePrice(config, data) {
const errors = []; const errors = [];
const horizontalSpanIssues =
Array.isArray(config?.horizontalSpans) && config.horizontalSpans.length
? validateHorizontalSpans(config)
: [];
horizontalSpanIssues.forEach((issue) => errors.push({
field: issue.field,
message: issue.message
}));
const invalidHorizontalSpanIds = new Set(
horizontalSpanIssues
.map((issue) => issue.spanId)
.filter((id) => id != null)
.map(String)
);
const resolvedHorizontalSpans = (Array.isArray(config?.horizontalSpans) ? config.horizontalSpans : [])
.filter((span) => span?.id != null && !invalidHorizontalSpanIds.has(String(span.id)))
.map((span) => resolveHorizontalSpan(span, config))
.filter(Boolean);
const settingsArr = data.settings || []; const settingsArr = data.settings || [];
const settings = {}; const settings = {};
for (const r of settingsArr) { for (const r of settingsArr) {
@@ -274,7 +439,7 @@ export function calculatePrice(config, data) {
} }
const kitFeeChf = numberOr(settings.kit_preparation_fee_chf, 350); const kitFeeChf = numberOr(settings.kit_preparation_fee_chf, 350);
const plinthMin = numberOr(settings.plinth_height_min_mm, 60); const plinthMin = numberOr(settings.plinth_height_min_mm, 60);
const plinthMax = numberOr(settings.plinth_height_max_mm, 160); const plinthMax = numberOr(settings.plinth_height_max_mm, 200);
const slidingMinWidth = numberOr(settings.sliding_doors_min_width_mm, 1200); const slidingMinWidth = numberOr(settings.sliding_doors_min_width_mm, 1200);
const allowSlidingDoors = String(settings.allow_sliding_doors ?? '0') === '1'; const allowSlidingDoors = String(settings.allow_sliding_doors ?? '0') === '1';
const standardHandleAxisMm = numberOr(settings.standard_handle_axis_mm, 1000); const standardHandleAxisMm = numberOr(settings.standard_handle_axis_mm, 1000);
@@ -287,14 +452,154 @@ export function calculatePrice(config, data) {
doorType, slidingPanelsCount, finishId, serviceOptionIds = [], doorType, slidingPanelsCount, finishId, serviceOptionIds = [],
columnMode: columnModeRaw columnMode: columnModeRaw
} = config; } = config;
const columnMode = columnModeRaw === 'block' ? 'block' : 'separations'; // Règle: dos → caisson block ; sinon côté ou rayonnage → block ; sinon séparations.
// Legacy: `mod.hasBack` absent → `config.hasBack` global; les deux absents → columnMode envoyé.
const legacyBackDefault = typeof config.hasBack === 'boolean'
? config.hasBack
: columnModeRaw === 'block';
// Empilage vertical PAR COLONNE — modèle N blocks: mod.blockHeightsMm / mod.blockBacks
// (legacy 2 blocks: mod.stacked + mod.stackBottomHeightMm; défauts config.stackEnabled / config.stackBottomHeightMm).
const STACK_MIN_MM = 300;
const STACK_MAX_MM = MAX_PANEL_DIMENSION_MM; // panneau max → au-delà, empilage obligatoire
const bodyHeightForStackMm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 100);
const minBlocksRequired = Math.max(1, Math.ceil(bodyHeightForStackMm / STACK_MAX_MM));
const maxBlocksAllowed = Math.max(1, Math.floor(bodyHeightForStackMm / STACK_MIN_MM));
/** Hauteurs de blocks d'une colonne (bas → haut) telles qu'envoyées (sans normalisation silencieuse). */
const columnBlockHeights = (modules || []).map((m) => {
const arr = Array.isArray(m?.blockHeightsMm)
? m.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0)
: null;
if (arr && arr.length > 0) return arr;
const stacked = typeof m?.stacked === 'boolean' ? m.stacked : config.stackEnabled === true;
if (stacked && bodyHeightForStackMm >= STACK_MIN_MM * 2) {
const bottom = Number(m?.stackBottomHeightMm ?? config.stackBottomHeightMm) || 1000;
return [bottom, bodyHeightForStackMm - bottom];
}
return [bodyHeightForStackMm];
});
const columnBlockCounts = columnBlockHeights.map((hs) => hs.length);
const anyStacked = columnBlockCounts.some((n) => n >= 2) ||
((modules || []).length === 0 && config.stackEnabled === true);
if (anyStacked && bodyHeightForStackMm < STACK_MIN_MM * 2) {
errors.push({
field: 'stackEnabled',
message: `Empilage vertical impossible: hauteur de caisse insuffisante (min. ${STACK_MIN_MM * 2} mm hors plinthe).`
});
} else {
columnBlockHeights.forEach((hs, i) => {
if (hs.length < minBlocksRequired) {
errors.push({
field: 'blockHeightsMm',
message: `Colonne ${i + 1}: hauteur de caisse ${bodyHeightForStackMm} mm > panneau max ${STACK_MAX_MM} mm — au moins ${minBlocksRequired} blocks empilés obligatoires.`
});
return;
}
if (hs.length > maxBlocksAllowed) {
errors.push({
field: 'blockHeightsMm',
message: `Colonne ${i + 1}: trop de blocks (max. ${maxBlocksAllowed} pour ${bodyHeightForStackMm} mm, min. ${STACK_MIN_MM} mm par block).`
});
return;
}
if (hs.length >= 2) {
const sum = hs.reduce((a, b) => a + b, 0);
if (hs.some((h) => h < STACK_MIN_MM || h > STACK_MAX_MM)) {
errors.push({
field: 'blockHeightsMm',
message: `Colonne ${i + 1}: hauteur de block invalide (entre ${STACK_MIN_MM} et ${STACK_MAX_MM} mm par block).`
});
} else if (Math.abs(sum - bodyHeightForStackMm) > 2) {
errors.push({
field: 'blockHeightsMm',
message: `Colonne ${i + 1}: la somme des blocks (${sum} mm) doit correspondre à la hauteur de caisse (${bodyHeightForStackMm} mm).`
});
}
}
});
}
const stackEnabled = anyStacked && bodyHeightForStackMm >= STACK_MIN_MM * 2;
const columnBackFlags = (modules || []).map((m) =>
typeof m?.hasBack === 'boolean' ? m.hasBack : legacyBackDefault
);
/** Dos par block d'une colonne (bas → haut). */
const columnBlockBacks = (modules || []).map((m, i) => {
const n = columnBlockCounts[i];
const arr = Array.isArray(m?.blockBacks) ? m.blockBacks : null;
return Array.from({ length: n }, (_, k) => {
if (arr && typeof arr[k] === 'boolean') return arr[k];
if (k === n - 1 && n >= 2 && typeof m?.hasBackTop === 'boolean') return m.hasBackTop;
return columnBackFlags[i];
});
});
// Zone du dessus (dernier block, ou zone unique).
const columnBackTopFlags = columnBlockBacks.map((backs, i) =>
backs.length > 0 ? backs[backs.length - 1] : columnBackFlags[i]
);
let blockJointCount = 0;
for (let i = 0; i + 1 < columnBlockBacks.length; i++) {
const leftMod = (modules || [])[i];
const rightMod = (modules || [])[i + 1];
const leftBlock = usesBlockConstruction(leftMod, (columnBlockBacks[i] || []).some(Boolean));
const rightBlock = usesBlockConstruction(rightMod, (columnBlockBacks[i + 1] || []).some(Boolean));
if (!leftBlock || !rightBlock) continue;
const n = Math.min(columnBlockBacks[i].length, columnBlockBacks[i + 1].length);
blockJointCount += n;
}
const allColumnsBack =
columnBlockBacks.length > 0 &&
columnBlockBacks.every((backs) => backs.every(Boolean));
const anyColumnBack =
columnBlockBacks.some((backs) => backs.some(Boolean)) ||
(columnBlockBacks.length === 0 && legacyBackDefault);
const columnMode = allColumnsBack ? 'block' : (anyColumnBack ? 'mixte' : 'separations');
// Habillages obligatoires (installation libre, configs récentes avec flags dos explicites):
// - colonne d'extrémité empilée → habillage de ce côté seulement
// - une zone du dessus (niveau haut, ou zone unique) sans dos → dessus de doublage obligatoire
const hasExplicitBackFlags =
typeof config.hasBack === 'boolean' || (modules || []).some((m) => typeof m?.hasBack === 'boolean');
if (installationType === 'free' && (modules || []).length > 0 && hasExplicitBackFlags) {
const extOn = config.exteriorFinishesEnabled !== false;
const sideDefaults = config.sideFinishesEnabled !== false;
const sideL = config.sideFinishLeftEnabled ?? sideDefaults;
const sideR = config.sideFinishRightEnabled ?? sideDefaults;
const topOn = config.topFinishEnabled !== false;
const leftOuterStacked = columnBlockCounts[0] >= 2;
const rightOuterStacked = columnBlockCounts[columnBlockCounts.length - 1] >= 2;
if (leftOuterStacked && (!extOn || !sideL)) {
errors.push({
field: 'sideFinishLeftEnabled',
message: 'Colonne gauche empilée (plusieurs blocks en hauteur) : habillage gauche obligatoire.'
});
}
if (rightOuterStacked && (!extOn || !sideR)) {
errors.push({
field: 'sideFinishRightEnabled',
message: 'Colonne droite empilée (plusieurs blocks en hauteur) : habillage droit obligatoire.'
});
}
if (columnBackTopFlags.some((b) => !b) && (!extOn || !topOn)) {
errors.push({
field: 'topFinishEnabled',
message: 'Une zone du dessus (niveau haut) sans dos : dessus de doublage obligatoire.'
});
}
}
// Determine if using modules (V2) or blocks (legacy) // Determine if using modules (V2) or blocks (legacy)
const useModules = modules && modules.length > 0; const useModules = modules && modules.length > 0;
// Plinthe 60–160 mm // Plinthe (bornes paramétrables, défaut 60–200 mm)
if (plinthHeightMm < plinthMin || plinthHeightMm > plinthMax) { if (plinthHeightMm < plinthMin || plinthHeightMm > plinthMax) {
errors.push({ field: 'plinthHeightMm', message: `La hauteur de plinthe doit être entre ${plinthMin} et ${plinthMax} mm.` }); errors.push({ field: 'plinthHeightMm', message: `La hauteur de plinthe doit être entre ${plinthMin} et ${plinthMax} mm.` });
} else if (!PLINTH.targetHeightsMm.includes(Number(plinthHeightMm))) {
errors.push({
field: 'plinthHeightMm',
message: `Hauteur AXILO 78 invalide. Valeurs admises : ${PLINTH.targetHeightsMm.join(', ')} mm.`
});
} }
// Portes coulissantes : largeur totale >= 1200 mm // Portes coulissantes : largeur totale >= 1200 mm
@@ -431,18 +736,19 @@ export function calculatePrice(config, data) {
// Renforts Forteresse — one pair (haut+bas) per panneau vertical de structure // Renforts Forteresse — one pair (haut+bas) per panneau vertical de structure
// Séparations: 2 côtés + (n−1) séparateurs = n+1 panneaux verticaux // Séparations: 2 côtés + (n−1) séparateurs = n+1 panneaux verticaux
// Block (x caisses): chaque caisse a 2 montants → 2n panneaux verticaux (plus de bois qu’en séparations) // Chaque jonction caisson↔caisson (dos ou rayonnage adjacents) ajoute 1 montant (double montant)
const columnCount = useModules const columnCount = useModules
? Math.max(1, modules.length) ? Math.max(1, modules.length)
: Math.max(1, blocks.length || 1); : Math.max(1, blocks.length || 1);
const verticalStructurePanelCount = columnMode === 'block' const legacyBlockJoints = !useModules && columnModeRaw === 'block'
? 2 * columnCount ? Math.max(0, columnCount - 1)
: 2 + Math.max(0, columnCount - 1); : blockJointCount;
const verticalStructurePanelCount = 2 + Math.max(0, columnCount - 1) + legacyBlockJoints;
const reinforcementCount = verticalStructurePanelCount * 2; const reinforcementCount = verticalStructurePanelCount * 2;
// Surcoût panneaux 19 mm (structure): en mode block, (n−1) montants de plus qu’en séparations // Surcoût panneaux 19 mm (structure): 1 montant supplémentaire par jonction caisson↔caisson
const bodyHeightMm = (heightMm || 0) - (plinthHeightMm || 100); const bodyHeightMm = (heightMm || 0) - (plinthHeightMm || 100);
const extraBlockVerticalPanels = columnMode === 'block' && columnCount > 1 ? columnCount - 1 : 0; const extraBlockVerticalPanels = legacyBlockJoints;
const extraBlockStructureSurfaceM2 = extraBlockVerticalPanels > 0 const extraBlockStructureSurfaceM2 = extraBlockVerticalPanels > 0
? (extraBlockVerticalPanels * depthMm * bodyHeightMm) / 1_000_000 ? (extraBlockVerticalPanels * depthMm * bodyHeightMm) / 1_000_000
: 0; : 0;
@@ -451,6 +757,51 @@ export function calculatePrice(config, data) {
: 0; : 0;
const blockExtraStructureChf = Math.round(extraBlockStructureSurfaceM2 * structureUnitPriceM2 * 100) / 100; const blockExtraStructureChf = Math.round(extraBlockStructureSurfaceM2 * structureUnitPriceM2 * 100) / 100;
// Empilage vertical PAR COLONNE: chaque jonction d'une colonne empilée ajoute 2 panneaux
// horizontaux (dessus du block inférieur + fond du block supérieur) à sa largeur intérieure.
let stackExtraStructureSurfaceM2 = 0;
if (stackEnabled) {
if (useModules) {
(modules || []).forEach((m, i) => {
const colW = Math.max(0, (Number(m?.widthMm) || widthMm / Math.max(1, modules.length)) - 2 * PANEL.structure);
if (resolvedHorizontalSpans.length === 0) {
const joints = Math.max(0, columnBlockCounts[i] - 1);
stackExtraStructureSurfaceM2 += (joints * 2 * colW * depthMm) / 1_000_000;
return;
}
const boundaries = [0];
columnBlockHeights[i].forEach((height) => boundaries.push(boundaries[boundaries.length - 1] + height));
for (let boundaryIndex = 1; boundaryIndex < boundaries.length - 1; boundaryIndex++) {
const boundaryMm = boundaries[boundaryIndex];
const spanAtBottom = resolvedHorizontalSpans.some((span) =>
span.moduleIndexes.includes(i) && Math.abs(span.bottomMm - boundaryMm) <= 0.5
);
const spanAtTop = resolvedHorizontalSpans.some((span) =>
span.moduleIndexes.includes(i) && Math.abs(span.topMm - boundaryMm) <= 0.5
);
const retainedFaces = 2 - Number(spanAtBottom) - Number(spanAtTop);
stackExtraStructureSurfaceM2 += (retainedFaces * colW * depthMm) / 1_000_000;
}
});
} else {
stackExtraStructureSurfaceM2 = (2 * Math.max(0, widthMm - 2 * PANEL.structure) * depthMm) / 1_000_000;
}
}
if (resolvedHorizontalSpans.length > 0) {
for (const span of resolvedHorizontalSpans) {
const spanPanelWidthMm = Math.max(0, span.widthMm - 2 * PANEL.structure);
stackExtraStructureSurfaceM2 += (2 * spanPanelWidthMm * depthMm) / 1_000_000;
if (span.span?.hasBack) {
// Back panels are 8 mm; price their surface proportionally to the 19 mm structure rate.
stackExtraStructureSurfaceM2 +=
(spanPanelWidthMm * Math.max(0, span.topMm - span.bottomMm - 2 * PANEL.structure)) /
1_000_000 *
(PANEL.back / PANEL.structure);
}
}
}
const stackExtraStructureChf = Math.round(stackExtraStructureSurfaceM2 * structureUnitPriceM2 * 100) / 100;
// Each reinforcement is 100mm × innerDepth × 16mm — price as fraction of a panel // Each reinforcement is 100mm × innerDepth × 16mm — price as fraction of a panel
const reinforcementSurfaceM2 = (PANEL.reinforcementWidth * (depthMm - PANEL.structure - 20)) / 1_000_000 * reinforcementCount; const reinforcementSurfaceM2 = (PANEL.reinforcementWidth * (depthMm - PANEL.structure - 20)) / 1_000_000 * reinforcementCount;
// Use 50% of panel price per m² for 16mm material (thinner, cheaper) // Use 50% of panel price per m² for 16mm material (thinner, cheaper)
@@ -464,7 +815,7 @@ export function calculatePrice(config, data) {
if (opt) optionsChf += Number(opt.price_chf) || 0; if (opt) optionsChf += Number(opt.price_chf) || 0;
} }
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf + reinforcementChf + blockExtraStructureChf; const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf + reinforcementChf + blockExtraStructureChf + stackExtraStructureChf;
return { return {
totalChf: Math.round(totalChf * 100) / 100, totalChf: Math.round(totalChf * 100) / 100,
@@ -479,6 +830,9 @@ export function calculatePrice(config, data) {
columnMode, columnMode,
verticalStructurePanelCount, verticalStructurePanelCount,
blockExtraStructureChf, blockExtraStructureChf,
stackExtraStructureChf,
stackEnabled,
stackedColumnCount: columnBlockCounts.filter((n) => n >= 2).length,
optionsChf, optionsChf,
hardwareChf, hardwareChf,
hardwareBreakdown, hardwareBreakdown,
File diff suppressed because it is too large Load Diff
+368
View File
@@ -0,0 +1,368 @@
/**
* APEX 4.0 — Blum LEGRABOX pure
* Données d'intégration : débit panneaux 16 mm, perçage CNC caisson (coulisses),
* perçage façades (INSERTA/EXPANDO), norme suisse SMS, génération de commande (BOM)
* et accessoires intérieurs.
*
* Source : dossier « INTÉGRATION DATA - APEX 4.0 » (modules usinage CNC / SMS / BOM /
* accessoires) + catalogue technique Blum. Toutes les cotes en mm.
*/
/** Épaisseur de référence stricte des fonds et parois arrière de tiroir. */
export const LEGRABOX_PANEL_THICKNESS_MM = 16;
/**
* Systèmes de hauteur Legrabox (N/M/K/C/F).
* - sideHeightMm : hauteur de côté (profil métallique) — « hauteur_cote_mm »
* - nicheHeightMm : hauteur de niche / encombrement façade min — « hauteur_des_niches_mm »
* - backHeightMm : hauteur C de la paroi arrière 16 mm — « hauteur_C_mm_selon_systeme »
* - hch : code hauteur commercial (S/M/L/XL/XXL)
* - smsFromSixths : norme suisse SMS — disponible à partir de n/6 (1/6 = 127 mm)
*/
export const LEGRABOX_SYSTEMS = Object.freeze({
N: Object.freeze({ key: 'N', hch: 'S', sideHeightMm: 66, nicheHeightMm: 80, backHeightMm: 39, smsFromSixths: 1 }),
M: Object.freeze({ key: 'M', hch: 'M', sideHeightMm: 90, nicheHeightMm: 106, backHeightMm: 63, smsFromSixths: 1 }),
K: Object.freeze({ key: 'K', hch: 'L', sideHeightMm: 128, nicheHeightMm: 144, backHeightMm: 101, smsFromSixths: 2 }),
C: Object.freeze({ key: 'C', hch: 'XL', sideHeightMm: 177, nicheHeightMm: 193, backHeightMm: 148, smsFromSixths: 2 }),
F: Object.freeze({ key: 'F', hch: 'XXL', sideHeightMm: 240, nicheHeightMm: 257, backHeightMm: 212, smsFromSixths: 3 })
});
/** Hauteurs de côté sélectionnables (remplace l'ancien 60 mm par 66 mm — système N). */
export const LEGRABOX_SIDE_HEIGHTS_MM = Object.freeze([66, 90, 128, 177, 240]);
/** Norme suisse SMS — division en sixièmes. */
export const SMS = Object.freeze({
sixthMm: 127,
description: 'Système de mesure suisse (Schweizer Mass-System) — division en sixièmes (1/6 = 127 mm)'
});
/** @returns {object|null} système Legrabox pour une hauteur de côté (66/90/128/177/240). */
export function getLegraboxSystemForSideHeight(sideHeightMm) {
const h = Number(sideHeightMm);
for (const sys of Object.values(LEGRABOX_SYSTEMS)) {
if (sys.sideHeightMm === h) return sys;
}
return null;
}
/**
* Hauteur de niche (encombrement façade min) pour une hauteur de côté.
* Hauteur inconnue → valeur inchangée (compat anciennes configs).
*/
export function legraboxNicheHeightMm(sideHeightMm) {
const sys = getLegraboxSystemForSideHeight(sideHeightMm);
return sys ? sys.nicheHeightMm : Math.max(0, Number(sideHeightMm) || 0);
}
// ── 1. Débit panneaux — épaisseur 16 mm ─────────────────────────────────
/**
* Fond de tiroir 16 mm.
* longueur A = NL − 10 · largeur B = X − 35 (X = largeur libre du corps).
* Usinage spécial : feuillure/renfort 38 × 8 mm à gauche et à droite.
* @param {number} nominalLengthMm — longueur nominale NL de la coulisse
* @param {number} internalWidthMm — largeur libre du corps (X)
*/
export function legraboxBottomPanelMm(nominalLengthMm, internalWidthMm) {
const nl = Math.max(0, Number(nominalLengthMm) || 0);
const x = Math.max(0, Number(internalWidthMm) || 0);
if (nl <= 10 || x <= 35) return null;
return {
lengthMm: nl - 10,
widthMm: x - 35,
thicknessMm: LEGRABOX_PANEL_THICKNESS_MM,
rebate: {
widthMm: 38,
depthMm: 8,
sides: ['left', 'right'],
note: 'Fond feuilluré/renforcé à gauche et à droite : 38 × 8 mm'
}
};
}
/**
* Paroi arrière (dos de tiroir) 16 mm.
* largeur B = X − 38 · hauteur C selon système (N 39 / M 63 / K 101 / C 148 / F 212).
* @param {number} internalWidthMm — largeur libre du corps (X)
* @param {string} systemKey — 'N' | 'M' | 'K' | 'C' | 'F'
*/
export function legraboxBackPanelMm(internalWidthMm, systemKey) {
const x = Math.max(0, Number(internalWidthMm) || 0);
const sys = LEGRABOX_SYSTEMS[systemKey];
if (!sys || x <= 38) return null;
return {
widthMm: x - 38,
heightMm: sys.backHeightMm,
thicknessMm: LEGRABOX_PANEL_THICKNESS_MM
};
}
// ── 2. Perçage CNC — coulisses sur caisson (Système 32) ────────────────
/**
* Positions de perçage cumulées (axe X) en mm depuis le bord AVANT du montant,
* pour le vissage des coulisses, selon capacité de charge.
* Perçage pilote Système 32 : Ø5 × 13 mm (vis à tête plate recommandées par Blum).
*/
export const LEGRABOX_RAIL_DRILLING = Object.freeze({
compatibleSideHeightsMm: LEGRABOX_SIDE_HEIGHTS_MM,
holeDiameterMm: 5,
holeDepthMm: 13,
/**
* Axe vertical de la ligne de vissage du profil de corps, au-dessus du bas
* de la niche tiroir. Hypothèse atelier (catalogue Blum) — à confirmer au gabarit ZML.3710.
*/
screwLineAboveNicheBottomMm: 9.5,
byLoadKg: Object.freeze({
40: Object.freeze([37, 69, 197, 229, 261, 293, 357]),
70: Object.freeze([37, 69, 261, 293, 357])
})
});
/**
* Positions X (depuis le chant avant du panneau vertical) pour une capacité donnée,
* bornées à la profondeur utile du panneau.
* @param {40|70} loadKg
* @param {number} [maxXMm] — profondeur maximum perçable (ex. profondeur panneau − retrait)
*/
export function getLegraboxRailHolePositionsMm(loadKg = 40, maxXMm = Infinity) {
const base = LEGRABOX_RAIL_DRILLING.byLoadKg[Number(loadKg) === 70 ? 70 : 40];
const max = Number.isFinite(Number(maxXMm)) ? Number(maxXMm) : Infinity;
return base.filter((x) => x <= max);
}
// ── 3. Perçage CNC — façades (INSERTA / cheville expansible) ────────────
/**
* Matrice de perçage façade — fixation INSERTA (cheville expansible Ø10, prof. > 12 mm).
* Cotes catalogue conservées telles quelles (validation programmeur CNC) :
* - minFrontHeightMm (A) : encombrement min de la façade
* - coteC / coteD : positions verticales des 2 perçages du support, depuis le bas façade
* - coteB : rangées supplémentaires (façades hautes C / F), depuis le bas façade
* - coteE : retrait horizontal de l'axe depuis la référence côté tiroir
* - coteG : cote catalogue complémentaire (référence gabarit)
*/
export const LEGRABOX_FACADE_INSERTA = Object.freeze({
toolNote: 'Cheville expansible Ø 10 mm, profondeur de perçage > 12 mm',
holeDiameterMm: 10,
holeDepthMm: 13,
bySystem: Object.freeze({
N: Object.freeze({ minFrontHeightMm: 80, coteC: 45.5, coteD: 16, coteE: 23.4, coteG: 14 }),
M: Object.freeze({ minFrontHeightMm: 106, coteC: 51, coteD: 32, coteE: 23.4, coteG: 14 }),
K: Object.freeze({ minFrontHeightMm: 144, coteC: 51, coteD: 32, coteE: 23.4, coteG: 14 }),
C: Object.freeze({ minFrontHeightMm: 193, coteB: Object.freeze([147, 179]), coteC: 51, coteD: 32, coteE: 24.4, coteG: 14 }),
F: Object.freeze({ minFrontHeightMm: 257, coteB: Object.freeze([179, 211, 243]), coteC: 51, coteD: 32, coteE: 24.4, coteG: 14 })
})
});
/**
* Positions XY des perçages INSERTA sur une façade (repère : origine bas-gauche façade).
* Interprétation atelier : 2 perçages verticaux par côté (y = coteD bas, y = coteC haut,
* depuis le bas façade) + rangées coteB pour C/F. Axe horizontal à coteE de la
* référence côté de tiroir (bord de la largeur libre X), soit
* x = (largeur façade − X)/2 + coteE depuis chaque bord de façade.
* @param {string} systemKey — 'N'…'F'
* @param {number} facadeWidthMm
* @param {number} facadeHeightMm
* @param {number} internalWidthMm — largeur libre du corps X
* @returns {{ xMm:number, yMm:number, role:string }[]}
*/
export function buildLegraboxFacadeInsertaHolePositionsMm(systemKey, facadeWidthMm, facadeHeightMm, internalWidthMm) {
const spec = LEGRABOX_FACADE_INSERTA.bySystem[systemKey];
const W = Math.max(0, Number(facadeWidthMm) || 0);
const H = Math.max(0, Number(facadeHeightMm) || 0);
const X = Math.max(0, Number(internalWidthMm) || 0);
if (!spec || W <= 0 || H <= 0 || X <= 0 || W <= X - 2 * spec.coteE) return [];
const inset = Math.max(spec.coteG, (W - X) / 2 + spec.coteE);
const xs = [
{ xMm: Math.round(inset * 10) / 10, side: 'gauche' },
{ xMm: Math.round((W - inset) * 10) / 10, side: 'droite' }
];
const ys = [
{ yMm: spec.coteD, role: 'bas' },
{ yMm: spec.coteC, role: 'haut' },
...(Array.isArray(spec.coteB) ? spec.coteB.map((b, i) => ({ yMm: b, role: `rangee_B${i + 1}` })) : [])
].filter((p) => p.yMm > 0 && p.yMm < H);
const out = [];
for (const { xMm, side } of xs) {
for (const { yMm, role } of ys) {
out.push({ xMm, yMm, role: `${side}_${role}` });
}
}
return out;
}
// ── 4. Montage / ajustement ──────────────────────────────────────────────
export const LEGRABOX_ASSEMBLY = Object.freeze({
montageFacade: 'Sans outils',
demontageFacade: 'Tournevis',
reglageHauteur: "Depuis l'intérieur (devant)",
reglageInclinaison: 'Depuis le bas (devant)',
technologiesOuvertureFermeture: Object.freeze([
'Blumotion (Fermeture automatique avec amortissement)',
'Tip-on Blumotion (Ouverture automatique avec amortissement intégré)',
'Tip-on (Système d\u2019ouverture mécanique)'
])
});
// ── 5. Génération de commande (BOM) ─────────────────────────────────────
export const LEGRABOX_ORDER_OPTIONS = Object.freeze({
couleursChassis: Object.freeze([
'blanc soie',
'argent polaire',
'gris orion',
'noir terra',
'acier inoxydable (anti-empreintes digitales)'
]),
typesDeTiroir: Object.freeze([
'Type de façade du tiroir (Standard)',
'Type de tiroir intérieur (Anglais)'
]),
variantesOuvertureFermeture: Object.freeze(['Blumotion', 'Tip-On Blumotion', 'Tip-On']),
fixationsFacadeDisponibles: Object.freeze(['Vis (Douille d\u2019écartement)', 'Expan-do T']),
etatDeLivraison: Object.freeze(['F', 'C', 'S'])
});
/** Règles métier strictes (regles_automatiques_requises). */
export const LEGRABOX_BOM_RULES = Object.freeze({
/** Ajouter « Stabilisation de façade » si largeur_corps_mm >= 600. */
frontStabiliserMinBodyWidthMm: 600,
/** Ajouter « Unité de synchronisation » si variante Tip-On Blumotion. */
syncUnitVariant: 'tip_on_blumotion'
});
/**
* Longueurs nominales disponibles (mm) par système et capacité de charge.
* N absent du dossier BOM — longueurs issues du catalogue Blum (40 kg uniquement).
* Exception : K (hauteur L) indisponible en acier inoxydable.
*/
export const LEGRABOX_NOMINAL_LENGTHS_MM = Object.freeze({
N: Object.freeze({ 40: Object.freeze([400, 450, 500, 550]), 70: Object.freeze([]) }),
M: Object.freeze({ 40: Object.freeze([270, 300, 350, 400, 450, 500, 550, 600]), 70: Object.freeze([450, 500, 550, 600, 650]) }),
K: Object.freeze({ 40: Object.freeze([300, 350, 400, 450, 500, 550, 600]), 70: Object.freeze([450, 500, 550, 600]) }),
C: Object.freeze({ 40: Object.freeze([270, 300, 350, 400, 450, 500, 550, 600]), 70: Object.freeze([450, 500, 550, 600, 650]) }),
F: Object.freeze({ 40: Object.freeze([400, 450, 500, 550, 600]), 70: Object.freeze([450, 500, 550, 600, 650]) })
});
/** Couleur châssis indisponible : hauteur K (L) en acier inoxydable. */
export function isLegraboxChassisColorAvailable(systemKey, chassisColor) {
const isInox = String(chassisColor || '').toLowerCase().includes('inox');
return !(systemKey === 'K' && isInox);
}
/**
* Plus grande longueur nominale disponible pour un système / capacité, bornée par la
* profondeur utile (profondeur intérieure min = NL + 3 mm gérée par l'appelant).
* @returns {number|null}
*/
export function getLegraboxNominalLengthMm(systemKey, availableDepthMm, loadKg = 40) {
const table = LEGRABOX_NOMINAL_LENGTHS_MM[systemKey];
if (!table) return null;
const list = table[Number(loadKg) === 70 ? 70 : 40] || [];
const depth = Math.max(0, Number(availableDepthMm) || 0);
let best = null;
for (const nl of list) {
if (nl <= depth && (best == null || nl > best)) best = nl;
}
return best;
}
// ── 6. Accessoires et subdivisions intérieures (up-sell) ────────────────
export const LEGRABOX_INTERIOR_ACCESSORIES = Object.freeze({
garnituresUniversellesPlastique: Object.freeze({
label: 'Garniture universelle plastique',
nominalLengthCompatibleMm: 500,
couleurs: Object.freeze(['Anthracite pearl', 'Blanc grainé']),
modelesParHauteur: Object.freeze({
h40: Object.freeze({
hauteurMm: 40,
largeursCorpsCompatiblesMm: Object.freeze([600, 800, 900]),
note: 'Particulièrement adapté sous les champs d\u2019induction'
}),
h52: Object.freeze({
hauteurMm: 52,
largeursCorpsCompatiblesMm: Object.freeze([500, 600, 800, 900])
})
})
}),
assortimentsPorteCouverts: Object.freeze({
label: 'Assortiment porte-couverts modulaire',
typeFixation: 'Autocollant avec insert en feutre',
couleurs: Object.freeze(['Lie de vin', 'Anthracite']),
configurations: Object.freeze([
Object.freeze({ pieces: 50, composition: '12 couteaux, 12 fourchettes, 12 cuillères, 12 cuillères à café, 2 grandes pièces' }),
Object.freeze({ pieces: 70, composition: '12 couteaux, 12 fourchettes, 12 cuillères, 12 cuillères à café, 12 fourchettes de cuisine, 6 petites pièces, 4 grandes pièces' }),
Object.freeze({ pieces: 90, composition: '24 couteaux, 24 fourchettes, 12 cuillères, 12 cuillères à café, 12 fourchettes de cuisine, 6 grandes pièces' })
])
}),
garnituresReglablesBoisInox: Object.freeze({
label: 'Garniture réglable hêtre / acier inox',
finition: 'Hêtre / acier inox',
nominalLengthCompatibleMm: 500,
profondeurReelleMm: 478,
modeles: Object.freeze({
bacEtroit: Object.freeze({ largeurCorpsCibleMm: [300, 400], largeurReelleAjustableMm: [200, 300] }),
bacLarge: Object.freeze({ largeurCorpsCibleMm: [450, 700], largeurReelleAjustableMm: [350, 600] })
})
}),
rangeCouvertsBoisHetre: Object.freeze({
label: 'Range-couverts réglable hêtre (laqué)',
finition: 'Hêtre (laqué)',
nominalLengthsCompatiblesMm: Object.freeze([500, 550, 600, 650]),
/** Profondeur réelle = longueur nominale − 28 mm */
profondeurReelleDeductionMm: 28,
modeles: Object.freeze({
bacEtroit: Object.freeze({ largeurCorpsCibleMm: [300, 400], largeurReelleAjustableMm: [200, 300] }),
bacLarge: Object.freeze({ largeurCorpsCibleMm: [450, 700], largeurReelleAjustableMm: [350, 600] })
})
})
});
/**
* Suggestions d'accessoires intérieurs compatibles avec la géométrie du tiroir.
* @param {{ bodyWidthMm:number, nominalLengthMm:number }} params — largeur de corps (KB) et NL
* @returns {{ key:string, label:string, detail:string }[]}
*/
export function getLegraboxAccessorySuggestions({ bodyWidthMm, nominalLengthMm }) {
const kb = Math.max(0, Number(bodyWidthMm) || 0);
const nl = Math.max(0, Number(nominalLengthMm) || 0);
const out = [];
const A = LEGRABOX_INTERIOR_ACCESSORIES;
if (nl === A.garnituresUniversellesPlastique.nominalLengthCompatibleMm) {
for (const [k, m] of Object.entries(A.garnituresUniversellesPlastique.modelesParHauteur)) {
if (m.largeursCorpsCompatiblesMm.includes(kb)) {
out.push({
key: `garniture_plastique_${k}`,
label: `${A.garnituresUniversellesPlastique.label} h${m.hauteurMm} mm`,
detail: m.note || `Largeur corps ${kb} mm, NL ${nl} mm`
});
}
}
}
const inRange = ([min, max]) => kb >= min && kb <= max;
if (nl === A.garnituresReglablesBoisInox.nominalLengthCompatibleMm) {
if (inRange(A.garnituresReglablesBoisInox.modeles.bacEtroit.largeurCorpsCibleMm)) {
out.push({ key: 'garniture_bois_inox_etroit', label: `${A.garnituresReglablesBoisInox.label} — bac étroit`, detail: `Largeur réelle ajustable 200–300 mm` });
}
if (inRange(A.garnituresReglablesBoisInox.modeles.bacLarge.largeurCorpsCibleMm)) {
out.push({ key: 'garniture_bois_inox_large', label: `${A.garnituresReglablesBoisInox.label} — bac large`, detail: `Largeur réelle ajustable 350–600 mm` });
}
}
if (A.rangeCouvertsBoisHetre.nominalLengthsCompatiblesMm.includes(nl)) {
const prof = nl - A.rangeCouvertsBoisHetre.profondeurReelleDeductionMm;
if (inRange(A.rangeCouvertsBoisHetre.modeles.bacEtroit.largeurCorpsCibleMm)) {
out.push({ key: 'range_couverts_hetre_etroit', label: `${A.rangeCouvertsBoisHetre.label} — bac étroit`, detail: `Profondeur réelle ${prof} mm` });
}
if (inRange(A.rangeCouvertsBoisHetre.modeles.bacLarge.largeurCorpsCibleMm)) {
out.push({ key: 'range_couverts_hetre_large', label: `${A.rangeCouvertsBoisHetre.label} — bac large`, detail: `Profondeur réelle ${prof} mm` });
}
}
return out;
}
+268 -62
View File
@@ -12,12 +12,19 @@ import {
HINGE, HINGE,
tourillonFaceDrillDepthMm, tourillonFaceDrillDepthMm,
DRAWER_REINFORCEMENT, DRAWER_REINFORCEMENT,
DRAWER_RENFORT_PANEL_IXCONNECT DRAWER_RENFORT_PANEL_IXCONNECT,
getBackGrooveInnerEndInsetFromBackEdgeMm
} from './manufacturing.js'; } from './manufacturing.js';
/** Perçage ferrure en face — Ø8 mm (même diamètre que tourillon / Ixconnect face) */ /** Perçage ferrure en face — Ø8 mm (même diamètre que tourillon / Ixconnect face) */
export const CARCASS_FERRURE_FACE_DIAMETER_MM = 8; export const CARCASS_FERRURE_FACE_DIAMETER_MM = 8;
/** Axe X du trou Inbus sur le panneau qui porte le long perçage Ixconnect en chant. */
function ixconnectInbusXFromEndMm({ endSide, widthMm, clampX }) {
const offset = Number(CONNECTORS.ixconnect.lockScrewInbus?.offsetFromAssemblyEdgeMm) || 25;
return clampX(endSide === 'left' ? offset : Number(widthMm) - offset);
}
/** Montants / séparateurs : rangées Ixconnect jonction dessus·fond — retrait depuis bords haut/bas (mm, origine Y = bas pièce en G-code). */ /** Montants / séparateurs : rangées Ixconnect jonction dessus·fond — retrait depuis bords haut/bas (mm, origine Y = bas pièce en G-code). */
export const IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM = 23; export const IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM = 23;
@@ -32,6 +39,98 @@ export function mapJunctionXAcrossWidthMm(widthMm, xmm) {
return clamped; return clamped;
} }
/**
* Machining X is stored in finished-panel millimetres (origin = left). Saw blanks
* (`rawCutWidthMm`) are longer; that extra sits on +X, so right-edge features
* (montants, AXILO) look too far from the right until they are re-anchored.
* Features within `edgeBandMm` of the finished right edge keep the same inset
* on the workpiece; everything else stays left-origin.
*/
export function mapFinishedXOntoWorkpieceMm(xMm, finishedWidthMm, workpieceWidthMm, edgeBandMm = 120) {
const x = Number(xMm) || 0;
const fw = Math.max(0, Number(finishedWidthMm) || 0);
const ww = Math.max(0, Number(workpieceWidthMm) || 0);
if (ww < 0.5) return x;
if (fw < 0.5 || Math.abs(ww - fw) < 0.05) return Math.max(0, Math.min(ww, x));
const distRight = fw - x;
const distLeft = x;
if (distRight <= edgeBandMm && distRight <= distLeft + 0.05) {
return Math.max(0, Math.min(ww, ww - distRight));
}
return Math.max(0, Math.min(ww, x));
}
/** AXILO 78 Press-fit holes on the underside of a bottom panel. */
export function buildAxiloFootHoleSpecsMm(piece, widthMm, heightMm) {
const axilo = piece?.machining?.axiloFootPlates;
if ((piece?.elementType || '') !== 'fond' || !axilo?.centers?.length) return [];
const finishedW = Number(piece?.widthMm) > 0 ? Number(piece.widthMm) : widthMm;
const diameterMm = Number(axilo.holeDiameterMm) || 10;
const depthMm = Number(axilo.holeDepthMm) || 12;
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
const out = [];
for (const center of axilo.centers) {
for (const offset of axilo.holeOffsetsFromCenterMm || []) {
out.push({
xMm: clampX(
mapFinishedXOntoWorkpieceMm(
Number(center.xMm) + Number(offset.x || 0),
finishedW,
widthMm
)
),
yMm: clampY(Number(center.yMm) + Number(offset.y || 0)),
diameterMm,
depthMm,
type: 'axilo_press_fit',
face: 'back',
row: center.row
});
}
}
return out;
}
/** System 32 rows; the rear row clears the back-groove zone when present. */
export function buildSystem32HoleSpecsMm(piece, widthMm, heightMm) {
const system32 = piece?.machining?.system32;
if (!system32?.positions?.length) return [];
const finishedW = Number(piece?.widthMm) > 0 ? Number(piece.widthMm) : widthMm;
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
const mapX = (v) => clampX(mapFinishedXOntoWorkpieceMm(v, finishedW, widthMm));
const backClearanceMm = piece?.machining?.groove
? getBackGrooveInnerEndInsetFromBackEdgeMm()
: 0;
const xRows = [
mapX(37),
mapX(Math.max(0, finishedW - 37 - backClearanceMm))
];
const diameterMm = Number(system32.holeDiameter) || 5;
const depthMm = Number(system32.holeDepth || piece?.thicknessMm) || 12;
const toolMode = system32.throughHole ? 'through' : 'blind';
const out = [];
for (const pos of system32.positions) {
for (const xMm of xRows) {
out.push({
xMm,
yMm: clampY(pos),
diameterMm,
depthMm,
type: 'system32',
face: 'face',
toolMode
});
}
}
return out;
}
/** Même logique que `getCarcassDowelStationsForPlan` dans cuttingEngine.js */ /** Même logique que `getCarcassDowelStationsForPlan` dans cuttingEngine.js */
export function getCarcassDowelStationsForPlan(cf, edgeLengthMm) { export function getCarcassDowelStationsForPlan(cf, edgeLengthMm) {
const L = Math.max(1, Number(edgeLengthMm) || 0); const L = Math.max(1, Number(edgeLengthMm) || 0);
@@ -50,14 +149,22 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
const out = []; const out = [];
const m = piece?.machining || {}; const m = piece?.machining || {};
const thicknessMm = Math.max(1, Number(piece?.thicknessMm) || 19); const thicknessMm = Math.max(1, Number(piece?.thicknessMm) || 19);
if (!['dessus', 'fond'].includes(piece.elementType || '')) return out; const et = piece.elementType || '';
if (!['dessus', 'fond'].includes(et)) return out;
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0)); const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0)); const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
/** Largeur « caisse » (finie) pour repères jonctions — peut être < widthMm (brut CNC). */ /** Largeur « caisse » (finie) pour repères jonctions — peut être < widthMm (brut CNC). */
const refW = const refW =
Number(piece?.widthMm) > 0 ? Math.min(widthMm, Number(piece.widthMm)) : widthMm; Number(piece?.widthMm) > 0 ? Math.min(widthMm, Number(piece.widthMm)) : widthMm;
const mapX = (xmm) => clampX(mapFinishedXOntoWorkpieceMm(xmm, refW, widthMm));
const edgeTolMm = 0.5; const edgeTolMm = 0.5;
/**
* Face d’entrée en épaisseur pour jonctions caisse (côtés / séparateurs) :
* — dessus : sous-face (intérieur caisse) → FACE=back (MPR von unten / 3D fromBottom)
* — fond : face supérieure (intérieur caisse) → FACE=face
*/
const carcassFace = et === 'dessus' ? 'back' : 'face';
const cf = m.carcassFasteners; const cf = m.carcassFasteners;
if (cf && (cf.dowelStations?.length || cf.positionsAlongJointMm?.length)) { if (cf && (cf.dowelStations?.length || cf.positionsAlongJointMm?.length)) {
@@ -69,11 +176,15 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
const zFerr = Number(cf.ferrureDepthInThicknessMm ?? CARCASS_FASTENERS.ferrureDepthInThicknessMm); const zFerr = Number(cf.ferrureDepthInThicknessMm ?? CARCASS_FASTENERS.ferrureDepthInThicknessMm);
const zFerrEdge = Number(cf.ferrureDepthAlongLengthMm ?? 46); const zFerrEdge = Number(cf.ferrureDepthAlongLengthMm ?? 46);
for (const xmm of jax) { for (let junctionIndex = 0; junctionIndex < jax.length; junctionIndex++) {
const xmm = jax[junctionIndex];
const rawX = clampX(xmm); const rawX = clampX(xmm);
const isEnd = rawX <= edgeTolMm || rawX >= refW - edgeTolMm; const explicitFaceMode = cf.junctionFaceModes?.[junctionIndex];
const isEnd = explicitFaceMode
? explicitFaceMode === 'end'
: rawX <= edgeTolMm || rawX >= refW - edgeTolMm;
const endSide = rawX <= refW / 2 ? 'left' : 'right'; const endSide = rawX <= refW / 2 ? 'left' : 'right';
const x = clampX(mapJunctionXAcrossWidthMm(widthMm, xmm)); const x = mapX(mapJunctionXAcrossWidthMm(refW, xmm));
for (const st of stations) { for (const st of stations) {
const along = st.holesAlongMm || []; const along = st.holesAlongMm || [];
for (let hi = 0; hi < along.length; hi++) { for (let hi = 0; hi < along.length; hi++) {
@@ -87,7 +198,7 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
diameterMm: dTour, diameterMm: dTour,
depthMm: zTour, depthMm: zTour,
type: 'tourillon', type: 'tourillon',
face: isEnd ? 'end' : 'face', face: isEnd ? 'end' : carcassFace,
...(isEnd ? { end: endSide } : {}) ...(isEnd ? { end: endSide } : {})
}); });
} else { } else {
@@ -98,32 +209,25 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
diameterMm: CARCASS_FERRURE_FACE_DIAMETER_MM, diameterMm: CARCASS_FERRURE_FACE_DIAMETER_MM,
depthMm: isEnd ? zFerrEdge : zFerr, depthMm: isEnd ? zFerrEdge : zFerr,
type: 'ixconnect', type: 'ixconnect',
face: isEnd ? 'end' : 'face', face: isEnd ? 'end' : carcassFace,
...(isEnd ? { end: endSide } : {}) ...(isEnd ? { end: endSide } : {})
}); });
/* Trou Inbus : même règle que connecteur T–IX–T (un par Ixconnect ferrure). Sans ceci, les jeux côté rainure /* Le Ø6 d'accès appartient au même panneau que le long Ø8 percé en chant. */
* (souvent issus de ferrures caisse seules) n’ont pas de trou violet Inbus en plan / G-code / 3D. */ if (isEnd) {
{
const lock = CONNECTORS.ixconnect.lockScrewInbus; const lock = CONNECTORS.ixconnect.lockScrewInbus;
const dLock = Number(lock?.diameter) || 6; const dLock = Number(lock?.diameter) || 6;
const zLock = Number(lock?.depth) || 9; const zLock = Number(lock?.depth) || 9;
const epsMm = 0.1;
let xInbus = clampX(x);
if (isEnd) {
const depthAlongMm = Math.min(zFerrEdge, widthMm * 0.48, 80);
xInbus =
endSide === 'left'
? depthAlongMm / 2 + epsMm
: widthMm - depthAlongMm / 2 - epsMm;
xInbus = clampX(xInbus);
}
out.push({ out.push({
xMm: xInbus, xMm: ixconnectInbusXFromEndMm({
endSide,
widthMm,
clampX
}),
yMm, yMm,
diameterMm: dLock, diameterMm: dLock,
depthMm: zLock, depthMm: zLock,
type: 'ixconnect_inbus', type: 'ixconnect_inbus',
face: 'face' face: carcassFace
}); });
} }
} }
@@ -134,7 +238,14 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
const ch = m.connectorHoles; const ch = m.connectorHoles;
const joints = ch?.ixconnectJointsAlongDepth; const joints = ch?.ixconnectJointsAlongDepth;
if (joints?.length) { /* Quand les ferrures caisse (carcassFasteners) sont déjà posées, elles portent le trio
* T–IX–T + Inbus le long de chaque jonction. Réémettre ixconnectJointsAlongDepth
* (motif bord avant 20/50/80) doublonne le jeu avant — d’où deux trous au centre Ixconnect. */
const carcassOwnsHorizontalJoints =
Array.isArray(cf?.dowelStations) && cf.dowelStations.length > 0
? true
: Array.isArray(cf?.positionsAlongJointMm) && cf.positionsAlongJointMm.length > 0;
if (joints?.length && !carcassOwnsHorizontalJoints) {
const dTour = Number(ch.tourillonDiameter || CONNECTORS.tourillonDiameter || 8); const dTour = Number(ch.tourillonDiameter || CONNECTORS.tourillonDiameter || 8);
const zTour = tourillonFaceDrillDepthMm(thicknessMm); const zTour = tourillonFaceDrillDepthMm(thicknessMm);
const dFace = Number(ch.faceHole?.diameter || CONNECTORS.ixconnect.faceHole.diameter || 8); const dFace = Number(ch.faceHole?.diameter || CONNECTORS.ixconnect.faceHole.diameter || 8);
@@ -148,14 +259,15 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
* clampX(axe droit) recule sous refW−ε et la jonction droite n’est plus « extrémité » → un seul Inbus. */ * clampX(axe droit) recule sous refW−ε et la jonction droite n’est plus « extrémité » → un seul Inbus. */
const axisNominal = Number(j.axisAcrossWidthMm) || 0; const axisNominal = Number(j.axisAcrossWidthMm) || 0;
const rawAcross = clampX(axisNominal); const rawAcross = clampX(axisNominal);
const isEnd = const isEnd = j.junctionFace
axisNominal <= edgeTolMm || axisNominal >= refW - edgeTolMm; ? j.junctionFace === 'end'
: axisNominal <= edgeTolMm || axisNominal >= refW - edgeTolMm;
const endSide = rawAcross <= refW / 2 ? 'left' : 'right'; const endSide = rawAcross <= refW / 2 ? 'left' : 'right';
const x = isEnd const x = isEnd
? rawAcross <= edgeTolMm ? rawAcross <= edgeTolMm
? 0 ? 0
: refW : widthMm
: clampX(mapJunctionXAcrossWidthMm(widthMm, j.axisAcrossWidthMm)); : mapX(mapJunctionXAcrossWidthMm(refW, j.axisAcrossWidthMm));
const seq = [ const seq = [
{ axis: j.tourillon1Axis, d: dTour, z: isEnd ? zTourEdge : zTour, type: 'tourillon' }, { axis: j.tourillon1Axis, d: dTour, z: isEnd ? zTourEdge : zTour, type: 'tourillon' },
{ axis: j.ixconnectAxis, d: isEnd ? dEdge : dFace, z: isEnd ? zEdge : zFace, type: 'ixconnect' }, { axis: j.ixconnectAxis, d: isEnd ? dEdge : dFace, z: isEnd ? zEdge : zFace, type: 'ixconnect' },
@@ -170,37 +282,27 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
diameterMm: h.d, diameterMm: h.d,
depthMm: h.z, depthMm: h.z,
type: h.type, type: h.type,
face: isEnd ? 'end' : 'face', face: isEnd ? 'end' : carcassFace,
...(isEnd ? { end: endSide } : {}) ...(isEnd ? { end: endSide } : {})
}); });
} }
/* Trou de serrage Inbus : un par Ixconnect — même (Y) que l’axe Ixconnect ; X aligné sur le centre du /* Le Ø6 d'accès appartient au même panneau que le long Ø8 percé en chant. */
* perçage tel qu’affiché en 3D (même formule que Cnc3DViewer pour FACE=end : min(prof chant, 48 % largeur, 80 mm) / 2). if (isEnd) {
* Sur jonction face : X = x du trio. Sur extrémité (montant) : entrée toujours face grande (perpendiculaire),
* mais X ne peut pas rester 0/refW sinon le cylindre est sur le bord au lieu du centre du violet sur chant. */
{
const lock = CONNECTORS.ixconnect.lockScrewInbus; const lock = CONNECTORS.ixconnect.lockScrewInbus;
const dLock = Number(lock?.diameter) || 6; const dLock = Number(lock?.diameter) || 6;
const zLock = Number(lock?.depth) || 9; const zLock = Number(lock?.depth) || 9;
const yIx = clampY(Number(j.ixconnectAxis));
const epsMm = 0.1;
let xInbus = clampX(x);
if (isEnd) {
const depthAlongMm = Math.min(zEdge, widthMm * 0.48, 80);
xInbus =
endSide === 'left'
? depthAlongMm / 2 + epsMm
: widthMm - depthAlongMm / 2 - epsMm;
xInbus = clampX(xInbus);
}
out.push({ out.push({
xMm: xInbus, xMm: ixconnectInbusXFromEndMm({
yMm: yIx, endSide,
widthMm,
clampX
}),
yMm: clampY(Number(j.ixconnectAxis)),
diameterMm: dLock, diameterMm: dLock,
depthMm: zLock, depthMm: zLock,
type: 'ixconnect_inbus', type: 'ixconnect_inbus',
face: 'face' face: carcassFace
}); });
} }
} }
@@ -369,6 +471,81 @@ export function buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm
return []; return [];
} }
/**
* Coulisses Legrabox — perçages de vissage sur montants / séparateurs.
* Repère plan pièce : X depuis chant AVANT (origine bas-gauche), Y depuis bas panneau.
* `machining.drawerSlideDrilling` : { holeDiameterMm, holeDepthMm, positionsXFromFrontMm,
* cotes: { leftInnerFace: { railAxesYMm }, rightInnerFace: { railAxesYMm } },
* separators: [{ faceTowardLowerModule: { railAxesYMm }, faceTowardHigherModule: { railAxesYMm } }] }
* @param {number} [partIndex] — cotes: 0 = montant gauche, 1 = droit ; séparateurs: index
*/
export function buildLegraboxRailHoleSpecsMm(piece, widthMm, heightMm, partIndex = 0) {
const et = piece?.elementType || '';
const m = piece?.machining?.drawerSlideDrilling;
if (!m?.positionsXFromFrontMm?.length) return [];
const d = Number(m.holeDiameterMm) || 5;
const z = Number(m.holeDepthMm) || 13;
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
const out = [];
const pushFace = (axesYMm, faceKey, tag) => {
if (!Array.isArray(axesYMm) || !axesYMm.length) return;
for (const y of axesYMm) {
for (const x of m.positionsXFromFrontMm) {
out.push({
xMm: clampX(x),
yMm: clampY(y),
diameterMm: d,
depthMm: z,
type: `legrabox_rail${tag ? `_${tag}` : ''}`,
face: faceKey
});
}
}
};
if (et === 'cotes' && m.cotes) {
const L = m.cotes.leftInnerFace;
const R = m.cotes.rightInnerFace;
const hasL = Array.isArray(L?.railAxesYMm) && L.railAxesYMm.length > 0;
const hasR = Array.isArray(R?.railAxesYMm) && R.railAxesYMm.length > 0;
const face = hasL && hasR ? (partIndex === 0 ? L : R) : hasL ? L : hasR ? R : null;
pushFace(face?.railAxesYMm, 'face');
return out;
}
if (et === 'panneauxInterieurs' && Array.isArray(m.separators)) {
const sep = m.separators[partIndex];
if (!sep) return [];
pushFace(sep.faceTowardLowerModule?.railAxesYMm, 'face', 'sep_lower');
pushFace(sep.faceTowardHigherModule?.railAxesYMm, 'back', 'sep_higher');
return out;
}
return [];
}
/**
* Façades tiroir Legrabox — fixation INSERTA (cheville expansible Ø10, prof. 13 mm).
* Repère plan façade : origine bas-gauche, X/Y en mm (face arrière de la façade).
*/
export function buildLegraboxFacadeInsertaSpecsMm(piece, widthMm, heightMm) {
if ((piece?.elementType || '') !== 'tiroirFacade') return [];
const ins = piece?.machining?.legraboxInserta;
if (!ins?.holes?.length) return [];
const d = Number(ins.holeDiameterMm) || 10;
const z = Number(ins.holeDepthMm) || 13;
return ins.holes.map((h) => ({
xMm: Math.max(0, Math.min(widthMm, Number(h.xMm) || 0)),
yMm: Math.max(0, Math.min(heightMm, Number(h.yMm) || 0)),
diameterMm: d,
depthMm: z,
type: 'legrabox_inserta',
face: 'face'
}));
}
export function buildDrawerReinforcementHoleSpecsMm(piece, widthMm, heightMm) { export function buildDrawerReinforcementHoleSpecsMm(piece, widthMm, heightMm) {
if ((piece?.elementType || '') !== 'renfortsTiroir') return []; if ((piece?.elementType || '') !== 'renfortsTiroir') return [];
const ix = piece?.machining?.drawerReinforcement?.ixconnectFixing; const ix = piece?.machining?.drawerReinforcement?.ixconnectFixing;
@@ -402,6 +579,19 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
const H = Math.max(1, Number(heightMm) || 1); const H = Math.max(1, Number(heightMm) || 1);
const W = Math.max(1, Number(widthMm) || 1); const W = Math.max(1, Number(widthMm) || 1);
const finishedW =
Number(piece?.widthMm) > 0 ? Math.min(W, Number(piece.widthMm)) : W;
const backClearanceMm =
et === 'cotes' && piece?.machining?.groove
? getBackGrooveInnerEndInsetFromBackEdgeMm()
: 0;
const backReferenceMm = Math.max(0, finishedW - backClearanceMm);
const mapAxisX = (axisMm) =>
mapFinishedXOntoWorkpieceMm(
mapJunctionXAcrossWidthMm(finishedW, axisMm),
finishedW,
W
);
const inset = IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM; const inset = IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM;
if (H < 2 * inset + 5) return out; if (H < 2 * inset + 5) return out;
@@ -420,9 +610,9 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
const t2 = Number(p.tourillon2Axis); const t2 = Number(p.tourillon2Axis);
const ix = Number(p.ixconnectAxis); const ix = Number(p.ixconnectAxis);
const mirrorBack = const mirrorBack =
W >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM && t2 > t1; finishedW >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM && t2 > t1;
if (et === 'panneauxInterieurs') { if (et === 'panneauxInterieurs' || (et === 'cotes' && ch.verticalJointMode === 'edge')) {
const seqFront = [ const seqFront = [
{ axis: t1, d: dTour, z: zTourEdge, type: 'tourillon' }, { axis: t1, d: dTour, z: zTourEdge, type: 'tourillon' },
{ axis: ix, d: dEdge, z: zIxEdge, type: 'ixconnect' }, { axis: ix, d: dEdge, z: zIxEdge, type: 'ixconnect' },
@@ -430,16 +620,21 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
]; ];
const seqBack = mirrorBack const seqBack = mirrorBack
? [ ? [
{ axis: W - t2, d: dTour, z: zTourEdge, type: 'tourillon' }, { axis: backReferenceMm - t2, d: dTour, z: zTourEdge, type: 'tourillon' },
{ axis: W - ix, d: dEdge, z: zIxEdge, type: 'ixconnect' }, { axis: backReferenceMm - ix, d: dEdge, z: zIxEdge, type: 'ixconnect' },
{ axis: W - t1, d: dTour, z: zTourEdge, type: 'tourillon' } { axis: backReferenceMm - t1, d: dTour, z: zTourEdge, type: 'tourillon' }
] ]
: null; : null;
const lock = CONNECTORS.ixconnect.lockScrewInbus;
const dLock = Number(lock?.diameter) || 6;
const zLock = Number(lock?.depth) || 9;
const inbusOffset = Number(lock?.offsetFromAssemblyEdgeMm) || 25;
const pushEndRow = (yMm, seq, endSide) => { const pushEndRow = (yMm, seq, endSide) => {
const cy = clampY(yMm); const cy = clampY(yMm);
const inbusY = clampY(endSide === 'bottom' ? inbusOffset : H - inbusOffset);
for (const h of seq) { for (const h of seq) {
const xMm = mapJunctionXAcrossWidthMm(W, Number(h.axis)); const xMm = mapAxisX(Number(h.axis));
out.push({ out.push({
xMm, xMm,
yMm: cy, yMm: cy,
@@ -449,6 +644,16 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
face: 'end', face: 'end',
end: endSide end: endSide
}); });
if (h.type === 'ixconnect') {
out.push({
xMm,
yMm: inbusY,
diameterMm: dLock,
depthMm: zLock,
type: 'ixconnect_inbus',
face: 'face'
});
}
} }
}; };
@@ -460,7 +665,8 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
return out; return out;
} }
/* cotes : face */ /* cotes sans habillage latéral : logement face uniquement.
* L'accès Inbus est sur la pièce opposée qui porte le long perçage en chant. */
const seqFront = [ const seqFront = [
{ axis: t1, d: dTour, z: zTour, type: 'tourillon' }, { axis: t1, d: dTour, z: zTour, type: 'tourillon' },
{ axis: ix, d: dFace, z: zFace, type: 'ixconnect' }, { axis: ix, d: dFace, z: zFace, type: 'ixconnect' },
@@ -468,15 +674,15 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
]; ];
const seqBack = mirrorBack const seqBack = mirrorBack
? [ ? [
{ axis: W - t2, d: dTour, z: zTour, type: 'tourillon' }, { axis: backReferenceMm - t2, d: dTour, z: zTour, type: 'tourillon' },
{ axis: W - ix, d: dFace, z: zFace, type: 'ixconnect' }, { axis: backReferenceMm - ix, d: dFace, z: zFace, type: 'ixconnect' },
{ axis: W - t1, d: dTour, z: zTour, type: 'tourillon' } { axis: backReferenceMm - t1, d: dTour, z: zTour, type: 'tourillon' }
] ]
: null; : null;
const pushFaceRow = (yMm, seq) => { const pushFaceRow = (yMm, seq) => {
const cy = clampY(yMm); const cy = clampY(yMm);
for (const h of seq) { for (const h of seq) {
const xMm = mapJunctionXAcrossWidthMm(W, Number(h.axis)); const xMm = mapAxisX(Number(h.axis));
out.push({ out.push({
xMm, xMm,
yMm: cy, yMm: cy,
@@ -487,10 +693,10 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
}); });
} }
}; };
for (const yMm of yRows) { pushFaceRow(yRows[0], seqFront);
pushFaceRow(yMm, seqFront); if (seqBack) pushFaceRow(yRows[0], seqBack);
if (seqBack) pushFaceRow(yMm, seqBack); pushFaceRow(yRows[1], seqFront);
} if (seqBack) pushFaceRow(yRows[1], seqBack);
return out; return out;
} }
+922 -14
View File
File diff suppressed because it is too large Load Diff
+350
View File
@@ -0,0 +1,350 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
addUpperBlockAboveHorizontalSpan,
getModuleVerticalBoundariesMm,
resizeHorizontalSpanUpperBlock,
resolveHorizontalSpan,
validateHorizontalSpans,
usesBlockConstruction,
columnUsesBlockConstruction,
horizontalConstructionRuns,
getAxiloPlateCentersMm,
getAxiloLateralCenterInsetMm,
getAxiloPlinthFrontSideDeductionMm,
PLINTH
} from './manufacturing.js';
import { validateConfig } from '../server/services/configValidation.js';
import { generateCuttingList } from '../server/services/cuttingEngine.js';
function makeConfig(columnCount, spanHeightMm = 900) {
const bottomMm = 300;
const topMm = bottomMm + spanHeightMm;
const bodyMm = 1800;
const modules = Array.from({ length: columnCount }, (_, index) => ({
id: `col-${index + 1}`,
type: 'penderie',
moduleCode: 'penderie',
widthMm: 600,
minWidth: 300,
maxWidth: 1200,
shelves: 0,
drawers: 0,
hasRod: false,
blockHeightsMm: [bottomMm, spanHeightMm, bodyMm - topMm],
blockBacks: [true, index % 2 === 0, false],
blockDoors: [true, false, true],
hasBack: true,
hasBackTop: false
}));
return {
installationType: 'free',
doorType: 'hinged',
widthMm: columnCount * 600,
heightMm: 1900,
depthMm: 600,
plinthHeightMm: 100,
effectiveDrawerDepthMm: 600,
hardware: { drawerType: 'visible', handleBoreHeightMm: 1000 },
modules,
horizontalSpans: [{
id: 'span-main',
moduleIds: modules.map((module) => module.id),
bottomMm,
topMm,
hasBack: true,
doorMode: 'none',
type: 'open'
}]
};
}
function assertSynchronizedUpperBlock(config, expectedSpanTopMm, expectedUpperHeightMm) {
const resolved = resolveHorizontalSpan(config.horizontalSpans[0], config);
assert.equal(resolved.topMm, expectedSpanTopMm);
for (const module of config.modules) {
const boundaries = getModuleVerticalBoundariesMm(module, config);
assert.ok(boundaries.includes(expectedSpanTopMm));
assert.ok(boundaries.includes(1200));
const upperIndex = boundaries.indexOf(expectedSpanTopMm);
assert.equal(boundaries[upperIndex + 1] - boundaries[upperIndex], expectedUpperHeightMm);
assert.equal(module.blockDoors[upperIndex], true);
resolved.coveredBlocks
.find((covered) => covered.moduleId === module.id)
.blockIndexes
.forEach((blockIndex) => assert.equal(module.blockDoors[blockIndex], false));
assert.equal(module.blockHeightsMm.reduce((sum, height) => sum + height, 0), 1800);
}
}
for (const columnCount of [2, 3]) {
test(`${columnCount}-column span adds and resizes a synchronized upper block`, () => {
const original = makeConfig(columnCount);
const added = addUpperBlockAboveHorizontalSpan(original, 'span-main');
assert.equal(added.ok, true);
assert.notEqual(added.config, original);
assert.deepEqual(original.modules[0].blockHeightsMm, [300, 900, 600]);
assertSynchronizedUpperBlock(added.config, 900, 300);
const resized = resizeHorizontalSpanUpperBlock(added.config, 'span-main', 450);
assert.equal(resized.ok, true);
assertSynchronizedUpperBlock(resized.config, 750, 450);
assert.deepEqual(validateHorizontalSpans(resized.config), []);
assert.deepEqual(validateConfig(resized.config).errors, []);
const cuttingList = generateCuttingList(resized.config, {}, resized.config.modules);
assert.ok(Array.isArray(cuttingList.pieces));
assert.ok(cuttingList.pieces.length > 0);
});
}
test('too-short span rejects upper block without mutation', () => {
const config = makeConfig(2, 500);
const snapshot = structuredClone(config);
const result = addUpperBlockAboveHorizontalSpan(config, 'span-main');
assert.equal(result.ok, false);
assert.equal(result.code, 'span_too_short');
assert.deepEqual(config, snapshot);
});
test('resize rejects non-step heights without mutation', () => {
const added = addUpperBlockAboveHorizontalSpan(makeConfig(2), 'span-main');
assert.equal(added.ok, true);
const snapshot = structuredClone(added.config);
const result = resizeHorizontalSpanUpperBlock(added.config, 'span-main', 425);
assert.equal(result.ok, false);
assert.equal(result.code, 'invalid_height');
assert.deepEqual(added.config, snapshot);
});
test('construction: dos → block ; sinon côté ou rayonnage → block', () => {
assert.equal(usesBlockConstruction({ type: 'penderie' }, true), true);
assert.equal(usesBlockConstruction({ type: 'penderie' }, false), false);
assert.equal(usesBlockConstruction({ type: 'tiroirs' }, false), false);
assert.equal(usesBlockConstruction({ type: 'rayonnage' }, false), true);
assert.equal(usesBlockConstruction({ moduleCode: 'rayonnage' }, false), true);
assert.equal(usesBlockConstruction({ type: 'penderie', shelves: 1 }, false), false);
assert.equal(usesBlockConstruction({ type: 'penderie', shelves: 4 }, false), true);
assert.equal(usesBlockConstruction({ type: 'mixte', shelves: 2 }, false), true);
const penderie = { type: 'penderie', shelves: 1, hasBack: false, blockBacks: [false] };
const rayonnage = { type: 'rayonnage', hasBack: false, blockBacks: [false] };
const shelvedPenderie = { type: 'penderie', shelves: 4, hasBack: false, blockBacks: [false] };
const withDos = { type: 'penderie', hasBack: true, blockBacks: [true] };
const config = { hasBack: false };
assert.equal(columnUsesBlockConstruction(penderie, config), false);
assert.equal(columnUsesBlockConstruction(rayonnage, config), true);
assert.equal(columnUsesBlockConstruction(shelvedPenderie, config), true);
assert.equal(columnUsesBlockConstruction(withDos, config), true);
const modules = [
{ type: 'penderie', widthMm: 600, hasBack: false, blockBacks: [false] },
{ type: 'penderie', widthMm: 600, hasBack: false, blockBacks: [false] },
{ type: 'rayonnage', widthMm: 500, hasBack: false, blockBacks: [false] },
{ type: 'penderie', widthMm: 600, hasBack: false, blockBacks: [false] }
];
assert.deepEqual(
horizontalConstructionRuns(modules, config, 'top').map(({ start, end, isBlock }) => ({ start, end, isBlock })),
[
{ start: 0, end: 1, isBlock: false },
{ start: 2, end: 2, isBlock: true },
{ start: 3, end: 3, isBlock: false }
]
);
const allPenderie = modules.map((m, i) => (
i === 2 ? { ...m, type: 'penderie', moduleCode: 'penderie', shelves: 0 } : m
));
assert.deepEqual(
horizontalConstructionRuns(allPenderie, config, 'top').map(({ start, end, isBlock }) => ({ start, end, isBlock })),
[{ start: 0, end: 3, isBlock: false }]
);
});
test('horizontal block at shell top replaces per-column roofs with one panel', () => {
const modules = [1, 2, 3].map((index) => ({
id: `col-${index}`,
type: 'rayonnage',
widthMm: 600,
blockHeightsMm: [1800],
blockBacks: [false]
}));
const config = {
widthMm: 1800,
heightMm: 1900,
plinthHeightMm: 100,
modules,
horizontalSpans: [{
id: 'span-top',
moduleIds: ['col-1', 'col-2'],
bottomMm: 0,
topMm: 1800,
hasBack: false,
doorMode: 'none',
type: 'open'
}]
};
assert.deepEqual(
horizontalConstructionRuns(modules, config, 'top')
.map(({ start, end, horizontalSpanId }) => ({ start, end, horizontalSpanId })),
[
{ start: 0, end: 1, horizontalSpanId: 'span-top' },
{ start: 2, end: 2, horizontalSpanId: undefined }
]
);
});
test('cutting list: merged dessus for separations, split for rayonnage', () => {
const baseModule = (index, type) => ({
id: `col-${index + 1}`,
type,
moduleCode: type,
widthMm: 600,
minWidth: 300,
maxWidth: 1200,
shelves: type === 'rayonnage' ? 4 : 0,
drawers: 0,
hasRod: type === 'penderie',
blockHeightsMm: [1800],
blockBacks: [false],
blockDoors: [true],
hasBack: false,
hasBackTop: false
});
const configOf = (types) => ({
installationType: 'free',
doorType: 'hinged',
widthMm: types.length * 600,
heightMm: 1900,
depthMm: 600,
plinthHeightMm: 100,
hasBack: false,
columnMode: 'separations',
hardware: { drawerType: 'visible', handleBoreHeightMm: 1000 },
modules: types.map((type, index) => baseModule(index, type)),
horizontalSpans: []
});
const dessusPieces = (config) =>
generateCuttingList(config, {}, config.modules).pieces.filter((piece) => piece.elementType === 'dessus');
const merged = dessusPieces(configOf(['penderie', 'penderie', 'penderie']));
assert.equal(merged.length, 1);
assert.match(merged[0].id, /col1-3/);
const split = dessusPieces(configOf(['penderie', 'rayonnage', 'penderie']));
assert.equal(split.length, 3);
});
test('AXILO feet: 4 corners plus a pair every 600mm on the armoire', () => {
const inset = getAxiloLateralCenterInsetMm(true);
const narrow = getAxiloPlateCentersMm(500, 610, {
leftEdgeCenterInsetMm: inset,
rightEdgeCenterInsetMm: inset
});
assert.equal(narrow.length, 4, 'A bay shorter than 600mm keeps only the 4 corner feet');
const wide = getAxiloPlateCentersMm(2362, 610, {
leftEdgeCenterInsetMm: inset,
rightEdgeCenterInsetMm: inset
});
assert.equal(wide.length, 10, '2400mm armoire: 4 corners + 3 intermediate pairs');
const xs = [...new Set(wide.map((c) => c.xMm))].sort((a, b) => a - b);
assert.equal(xs.length, 5);
for (let i = 1; i < xs.length; i++) {
assert.ok(xs[i] - xs[i - 1] <= PLINTH.maxFootSpacingMm + 0.01);
}
const left = getAxiloPlateCentersMm(761, 610, {
leftEdgeCenterInsetMm: inset,
rightEdgeCenterInsetMm: inset,
spanWidthMm: 2362,
panelOffsetMm: 0,
assignMinMm: 0,
assignMaxMm: 781,
assignMaxInclusive: false
});
const mid = getAxiloPlateCentersMm(759, 610, {
leftEdgeCenterInsetMm: inset,
rightEdgeCenterInsetMm: inset,
spanWidthMm: 2362,
panelOffsetMm: 801.5,
assignMinMm: 781,
assignMaxMm: 1581,
assignMaxInclusive: false
});
const right = getAxiloPlateCentersMm(761, 610, {
leftEdgeCenterInsetMm: inset,
rightEdgeCenterInsetMm: inset,
spanWidthMm: 2362,
panelOffsetMm: 1601.5,
assignMinMm: 1581,
assignMaxMm: 2362,
assignMaxInclusive: true
});
assert.equal(left.length + mid.length + right.length, 10, 'Split fonds share the armoire foot count');
});
test('grooved bottom staggers the rear Ixconnect row away from AXILO holes', () => {
const config = makeConfig(1);
const bottom = generateCuttingList(config, {}, config.modules).pieces.find(
(piece) =>
piece.elementType === 'fond' &&
piece.machining?.groove &&
piece.machining?.axiloFootPlates &&
piece.machining?.carcassFasteners
);
assert.ok(bottom, 'Expected a grooved bottom with AXILO and carcass machining');
const rearFootYs = bottom.machining.axiloFootPlates.centers
.filter((center) => center.row === 'rear')
.flatMap((center) =>
bottom.machining.axiloFootPlates.holeOffsetsFromCenterMm.map(
(offset) => center.yMm + offset.y
)
);
const rearJoint = bottom.machining.carcassFasteners.dowelStations.at(-1);
const minimumAxisClearanceMm = Math.min(
...rearJoint.holesAlongMm.flatMap((jointY) =>
rearFootYs.map((footY) => Math.abs(jointY - footY))
)
);
assert.ok(
minimumAxisClearanceMm >= 14,
`Rear T–IX–T and AXILO axes need clearance; received ${minimumAxisClearanceMm} mm`
);
});
test('niche front plinth spans the full wardrobe width', () => {
const config = {
...makeConfig(3),
installationType: 'niche',
exteriorFinishesEnabled: true,
sideFinishLeftEnabled: true,
sideFinishRightEnabled: true
};
assert.equal(getAxiloPlinthFrontSideDeductionMm(config, 'left'), 0);
assert.equal(getAxiloPlinthFrontSideDeductionMm(config, 'right'), 0);
const plinth = generateCuttingList(config, {}, config.modules).pieces
.find((piece) => piece.elementType === 'plintheBas');
assert.ok(plinth);
assert.equal(plinth.widthMm, config.widthMm);
const cuttingList = generateCuttingList(config, {}, config.modules);
const sideFillers = cuttingList.pieces.find((piece) => piece.elementType === 'fileurCotes');
const topFiller = cuttingList.pieces.find((piece) => piece.elementType === 'fileurHaut');
assert.ok(sideFillers);
assert.equal(sideFillers.widthMm, 50);
assert.equal(sideFillers.heightMm, config.heightMm + 30);
assert.equal(sideFillers.quantity, 2);
assert.ok(topFiller);
assert.equal(topFiller.widthMm, config.widthMm + 60);
assert.equal(topFiller.heightMm, 50);
assert.equal(topFiller.quantity, 1);
});