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