v2.1
Ship configurator updates with catalog DB included for server deploy. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
+2
-1
@@ -1,5 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
server/data/
|
server/data/*
|
||||||
.env
|
.env
|
||||||
.claude/
|
.claude/
|
||||||
*.docx
|
*.docx
|
||||||
@@ -7,6 +7,7 @@ server/data/
|
|||||||
~WRL*.tmp
|
~WRL*.tmp
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
!server/data/configurateur.db
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Login from './pages/Login';
|
|||||||
import Admin from './pages/Admin';
|
import Admin from './pages/Admin';
|
||||||
import Materials from './pages/Materials';
|
import Materials from './pages/Materials';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
|
import Legal from './pages/Legal';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -16,6 +17,10 @@ export default function App() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/mentions-legales" element={<Legal />} />
|
||||||
|
<Route path="/confidentialite" element={<Legal />} />
|
||||||
|
<Route path="/conditions-generales" element={<Legal />} />
|
||||||
|
<Route path="/reglements" element={<Legal />} />
|
||||||
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
|
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
|
||||||
<Route path="/configurateur" element={<ProtectedRoute><Configurator /></ProtectedRoute>} />
|
<Route path="/configurateur" element={<ProtectedRoute><Configurator /></ProtectedRoute>} />
|
||||||
<Route path="/materiaux" element={<ProtectedRoute><Materials /></ProtectedRoute>} />
|
<Route path="/materiaux" element={<ProtectedRoute><Materials /></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -117,6 +117,58 @@ export async function calculatePrice(config) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchSavedDesigns() {
|
||||||
|
const res = await fetch(`${API_BASE}/saved-designs`);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDesign(name, config) {
|
||||||
|
const res = await fetch(`${API_BASE}/saved-designs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, config })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSavedDesign(id, name, config) {
|
||||||
|
const res = await fetch(`${API_BASE}/saved-designs/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, config })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSavedDesign(id) {
|
||||||
|
const res = await fetch(`${API_BASE}/saved-designs/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLayoutPresets() {
|
||||||
|
const res = await fetch(`${API_BASE}/layout-presets`);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLayoutPreset(name, config) {
|
||||||
|
const res = await fetch(`${API_BASE}/layout-presets`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, config })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLayoutPreset(id) {
|
||||||
|
const res = await fetch(`${API_BASE}/layout-presets/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchOrders() {
|
export async function fetchOrders() {
|
||||||
const res = await fetch(`${API_BASE}/orders`);
|
const res = await fetch(`${API_BASE}/orders`);
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
@@ -245,6 +297,46 @@ export async function fetchHaefeleMaterials({ search = '', active = null, page =
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Leuba Hiag catalog sync API
|
||||||
|
|
||||||
|
export async function fetchLeubaHiagTargets() {
|
||||||
|
const res = await fetch(`${API_BASE}/sync/leubahiag/targets`);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLeubaHiagSyncStreamUrl({ targets = [], activate = false, fetchPrices = true } = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const cleaned = (targets || []).filter(Boolean);
|
||||||
|
if (cleaned.length > 0) params.set('targets', cleaned.join(','));
|
||||||
|
if (activate) params.set('activate', '1');
|
||||||
|
if (!fetchPrices) params.set('prices', '0');
|
||||||
|
const query = params.toString();
|
||||||
|
return query ? `${API_BASE}/sync/leubahiag/stream?${query}` : `${API_BASE}/sync/leubahiag/stream`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerLeubaHiagSync(payload) {
|
||||||
|
const res = await fetch(`${API_BASE}/sync/leubahiag`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLeubaHiagMaterials({ search = '', active = null, page = 1, limit = 25 } = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
if (active !== null) params.set('active', active ? '1' : '0');
|
||||||
|
params.set('page', String(page));
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/leubahiag/materials?${params.toString()}`);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
// Formex Materials CRUD
|
// Formex Materials CRUD
|
||||||
|
|
||||||
export async function fetchFormexMaterials({ search = '', collection = '', category = '', supplier = '', active = null, page = 1, limit = 25 } = {}) {
|
export async function fetchFormexMaterials({ search = '', collection = '', category = '', supplier = '', active = null, page = 1, limit = 25 } = {}) {
|
||||||
@@ -294,6 +386,16 @@ export async function updateFormexMaterial(id, data) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setFormexMaterialActive(id, active) {
|
||||||
|
const res = await fetch(`${API_BASE}/formex/materials/${id}/active`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ active: !!active })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteFormexMaterial(id) {
|
export async function deleteFormexMaterial(id) {
|
||||||
const res = await fetch(`${API_BASE}/formex/materials/${id}`, {
|
const res = await fetch(`${API_BASE}/formex/materials/${id}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
Shape,
|
Shape,
|
||||||
Vector3
|
Vector3
|
||||||
} from 'three';
|
} from 'three';
|
||||||
import { OrbitControls } from '@react-three/drei';
|
import { Html, OrbitControls } from '@react-three/drei';
|
||||||
import {
|
import {
|
||||||
buildTopBottomPlanHoleSpecsMm,
|
buildTopBottomPlanHoleSpecsMm,
|
||||||
buildVerticalPanelIxconnectFaceHoleSpecsMm,
|
buildVerticalPanelIxconnectFaceHoleSpecsMm,
|
||||||
@@ -21,9 +21,13 @@ import {
|
|||||||
buildFinishHabillageHoleSpecsMm,
|
buildFinishHabillageHoleSpecsMm,
|
||||||
buildDoorHingeCupHoleSpecsMm,
|
buildDoorHingeCupHoleSpecsMm,
|
||||||
buildDrawerReinforcementHoleSpecsMm,
|
buildDrawerReinforcementHoleSpecsMm,
|
||||||
buildDrawerRenfortPanelIxconnectSpecsMm
|
buildDrawerRenfortPanelIxconnectSpecsMm,
|
||||||
|
buildLegraboxRailHoleSpecsMm,
|
||||||
|
buildLegraboxFacadeInsertaSpecsMm,
|
||||||
|
buildAxiloFootHoleSpecsMm,
|
||||||
|
buildSystem32HoleSpecsMm
|
||||||
} from '@shared/cncPanelHoles.js';
|
} from '@shared/cncPanelHoles.js';
|
||||||
import { GROOVE, getBackGroovePlanOrientation, getBackGrooveInnerEndInsetFromBackEdgeMm } from '@shared/manufacturing.js';
|
import { GROOVE, getBackGroovePlanOrientation } from '@shared/manufacturing.js';
|
||||||
import styles from './Cnc3DViewer.module.css';
|
import styles from './Cnc3DViewer.module.css';
|
||||||
|
|
||||||
/** Matches Canvas camera defaults so switching items without a saved view resets correctly. */
|
/** Matches Canvas camera defaults so switching items without a saved view resets correctly. */
|
||||||
@@ -118,7 +122,7 @@ function PersistedOrbitControls({ storageKey }) {
|
|||||||
makeDefault
|
makeDefault
|
||||||
enablePan
|
enablePan
|
||||||
enableZoom
|
enableZoom
|
||||||
zoomSpeed={-0.6}
|
zoomSpeed={0.9}
|
||||||
enableRotate
|
enableRotate
|
||||||
rotateSpeed={0.6}
|
rotateSpeed={0.6}
|
||||||
screenSpacePanning
|
screenSpacePanning
|
||||||
@@ -254,9 +258,12 @@ function AxisAlignAnimator({ alignId, alignAxis, fallbackDistance }) {
|
|||||||
|
|
||||||
function parseRawSize(gcode) {
|
function parseRawSize(gcode) {
|
||||||
const lines = (gcode || '').split(/\r?\n/);
|
const lines = (gcode || '').split(/\r?\n/);
|
||||||
|
let fromRaw = null;
|
||||||
for (const raw of lines) {
|
for (const raw of lines) {
|
||||||
const line = String(raw || '');
|
const line = String(raw || '');
|
||||||
if (!line.startsWith('; SIZE_RAW:')) continue;
|
const isFinished = line.startsWith('; SIZE:');
|
||||||
|
const isRaw = line.startsWith('; SIZE_RAW:');
|
||||||
|
if (!isFinished && !isRaw) continue;
|
||||||
const mx = line.match(/X=([0-9]+(\.[0-9]+)?)/i);
|
const mx = line.match(/X=([0-9]+(\.[0-9]+)?)/i);
|
||||||
const my = line.match(/Y=([0-9]+(\.[0-9]+)?)/i);
|
const my = line.match(/Y=([0-9]+(\.[0-9]+)?)/i);
|
||||||
const mt = line.match(/T=([0-9]+(\.[0-9]+)?)/i);
|
const mt = line.match(/T=([0-9]+(\.[0-9]+)?)/i);
|
||||||
@@ -264,10 +271,33 @@ function parseRawSize(gcode) {
|
|||||||
const heightM = (my ? Number(my[1]) : 0) / 1000;
|
const heightM = (my ? Number(my[1]) : 0) / 1000;
|
||||||
const thickM = (mt ? Number(mt[1]) : 16) / 1000;
|
const thickM = (mt ? Number(mt[1]) : 16) / 1000;
|
||||||
if (widthM > 0 && heightM > 0) {
|
if (widthM > 0 && heightM > 0) {
|
||||||
return { widthM, heightM, thickM };
|
const parsed = { widthM, heightM, thickM };
|
||||||
|
if (isFinished) return parsed;
|
||||||
|
fromRaw = parsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return fromRaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scene size must be the finished panel. SIZE_RAW includes saw surcote (+ banding
|
||||||
|
* compensation) piled on +X/+Y, which makes right-edge holes (montants, AXILO) look
|
||||||
|
* too far from the edge even though their coordinates are already in finished mm.
|
||||||
|
*/
|
||||||
|
function panelSceneSize(piece, gcode) {
|
||||||
|
const fromGcode = parseRawSize(gcode);
|
||||||
|
const widthMm = Number(piece?.widthMm);
|
||||||
|
const heightMm = Number(piece?.heightMm);
|
||||||
|
const thicknessMm = Number(piece?.thicknessMm);
|
||||||
|
if (widthMm > 0 && heightMm > 0) {
|
||||||
|
const thickMm = thicknessMm > 0 ? thicknessMm : ((fromGcode?.thickM || 0) * 1000) || 19;
|
||||||
|
return {
|
||||||
|
widthM: widthMm / 1000,
|
||||||
|
heightM: heightMm / 1000,
|
||||||
|
thickM: thickMm / 1000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return fromGcode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseHoles(gcode) {
|
function parseHoles(gcode) {
|
||||||
@@ -301,9 +331,10 @@ function planHoleSpecsToSceneHoles(piece, rawSize, partIndex = 0) {
|
|||||||
if (!piece?.machining || !rawSize) return [];
|
if (!piece?.machining || !rawSize) return [];
|
||||||
const wMm = rawSize.widthM * 1000;
|
const wMm = rawSize.widthM * 1000;
|
||||||
const hMm = rawSize.heightM * 1000;
|
const hMm = rawSize.heightM * 1000;
|
||||||
|
const mirrorDepthAxis = ['dessus', 'fond'].includes(piece?.elementType || '');
|
||||||
const mapSpec = (spec) => ({
|
const mapSpec = (spec) => ({
|
||||||
xM: spec.xMm / 1000,
|
xM: spec.xMm / 1000,
|
||||||
yM: spec.yMm / 1000,
|
yM: (mirrorDepthAxis ? hMm - spec.yMm : spec.yMm) / 1000,
|
||||||
dM: spec.diameterMm / 1000,
|
dM: spec.diameterMm / 1000,
|
||||||
zM: spec.depthMm / 1000,
|
zM: spec.depthMm / 1000,
|
||||||
type: spec.type,
|
type: spec.type,
|
||||||
@@ -312,12 +343,16 @@ function planHoleSpecsToSceneHoles(piece, rawSize, partIndex = 0) {
|
|||||||
});
|
});
|
||||||
return [
|
return [
|
||||||
...buildTopBottomPlanHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
|
...buildTopBottomPlanHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
|
||||||
|
...buildAxiloFootHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
|
||||||
|
...buildSystem32HoleSpecsMm(piece, wMm, hMm).map(mapSpec),
|
||||||
...buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
|
...buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
|
||||||
...buildShelfMobileHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
|
...buildShelfMobileHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
|
||||||
...buildFinishHabillageHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
|
...buildFinishHabillageHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
|
||||||
...buildDoorHingeCupHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
|
...buildDoorHingeCupHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
|
||||||
...buildDrawerReinforcementHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
|
...buildDrawerReinforcementHoleSpecsMm(piece, wMm, hMm).map(mapSpec),
|
||||||
...buildDrawerRenfortPanelIxconnectSpecsMm(piece, wMm, hMm, partIndex).map(mapSpec)
|
...buildDrawerRenfortPanelIxconnectSpecsMm(piece, wMm, hMm, partIndex).map(mapSpec),
|
||||||
|
...buildLegraboxRailHoleSpecsMm(piece, wMm, hMm, partIndex).map(mapSpec),
|
||||||
|
...buildLegraboxFacadeInsertaSpecsMm(piece, wMm, hMm).map(mapSpec)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,10 +455,38 @@ function ixconnectInbusFaceFromBottomThick(etPiece, h) {
|
|||||||
return (
|
return (
|
||||||
h.type === 'ixconnect_inbus' &&
|
h.type === 'ixconnect_inbus' &&
|
||||||
(etPiece === 'dessus' || etPiece === 'fond') &&
|
(etPiece === 'dessus' || etPiece === 'fond') &&
|
||||||
h.face === 'face'
|
(h.face === 'face' || h.face === 'back')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dessus — entrée en épaisseur :
|
||||||
|
* - SC habillage (accouplement bandeau) → face extérieure (haut, y=t)
|
||||||
|
* - jonctions caisse (côtés / séparateurs / inbus) → face caisse (bas, y=0, même côté que la rainure)
|
||||||
|
*/
|
||||||
|
function dessusFaceHoleFromBottom(h) {
|
||||||
|
if (!h || h.face === 'end') return null;
|
||||||
|
if (h.type === 'ixconnect_finish') return false;
|
||||||
|
// SC 8/25 finish depth is 12.5 mm — keep on top even if type stayed `ixconnect`.
|
||||||
|
if (
|
||||||
|
h.type === 'ixconnect' &&
|
||||||
|
h.face === 'face' &&
|
||||||
|
Math.abs((Number(h.zM) || 0) * 1000 - 12.5) < 0.35
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
h.type === 'tourillon' ||
|
||||||
|
h.type === 'ixconnect' ||
|
||||||
|
h.type === 'ixconnect_inbus' ||
|
||||||
|
h.type === 'ferrure' ||
|
||||||
|
h.face === 'back'
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Couleur des faces usinées (rainure) — pas le bois apparent extérieur. */
|
/** Couleur des faces usinées (rainure) — pas le bois apparent extérieur. */
|
||||||
const GROOVE_CUT_FACE_COLOR = '#0d9488';
|
const GROOVE_CUT_FACE_COLOR = '#0d9488';
|
||||||
|
|
||||||
@@ -443,7 +506,6 @@ function getBackGrooveSceneParams(rawSize, piece) {
|
|||||||
const gw = (m.width ?? GROOVE.back.width) / 1000;
|
const gw = (m.width ?? GROOVE.back.width) / 1000;
|
||||||
const gd = (m.depth ?? GROOVE.back.depth) / 1000;
|
const gd = (m.depth ?? GROOVE.back.depth) / 1000;
|
||||||
const axis = (m.axisFromPanelEdge ?? m.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge) / 1000;
|
const axis = (m.axisFromPanelEdge ?? m.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge) / 1000;
|
||||||
const endInsetM = getBackGrooveInnerEndInsetFromBackEdgeMm() / 1000;
|
|
||||||
const eps = 0.00025;
|
const eps = 0.00025;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -454,9 +516,8 @@ function getBackGrooveSceneParams(rawSize, piece) {
|
|||||||
*/
|
*/
|
||||||
if (orient === 'vertical') {
|
if (orient === 'vertical') {
|
||||||
const xC = w - axis;
|
const xC = w - axis;
|
||||||
// Côtés extérieurs: la rainure ne doit pas aller sous/au-dessus du dessus+fond (19mm chacun) → -2×19mm.
|
// Side-panel back grooves run through the complete panel length.
|
||||||
const extraEndInsetM = piece?.elementType === 'cotes' ? (Number(piece?.thicknessMm) || 19) / 1000 : 0;
|
const zLen = h;
|
||||||
const zLen = Math.max(0.05, h - 2 * (endInsetM + extraEndInsetM));
|
|
||||||
const yTop = t;
|
const yTop = t;
|
||||||
const yBot = t - gd;
|
const yBot = t - gd;
|
||||||
const min = new Vector3(xC - gw / 2, yBot - eps, h / 2 - zLen / 2);
|
const min = new Vector3(xC - gw / 2, yBot - eps, h / 2 - zLen / 2);
|
||||||
@@ -472,7 +533,9 @@ function getBackGrooveSceneParams(rawSize, piece) {
|
|||||||
return { min, max, innerFaces };
|
return { min, max, innerFaces };
|
||||||
}
|
}
|
||||||
|
|
||||||
const zC = h - axis;
|
// Les panneaux horizontaux sont affichés avec l'avant côté +Z (repères Gauche/Droite).
|
||||||
|
// La rainure est cotée depuis le bord arrière : elle se place donc à +axis, pas à h-axis.
|
||||||
|
const zC = axis;
|
||||||
const et = piece.elementType || '';
|
const et = piece.elementType || '';
|
||||||
const opensFromBottom = et === 'dessus' || et === 'bandeauHaut';
|
const opensFromBottom = et === 'dessus' || et === 'bandeauHaut';
|
||||||
const y0 = 0.0002;
|
const y0 = 0.0002;
|
||||||
@@ -663,9 +726,12 @@ function buildPanelHoleDiscardUniforms(holes, rawSize, piece) {
|
|||||||
const rawDepth = Math.max(0, Number(h.zM) || 0);
|
const rawDepth = Math.max(0, Number(h.zM) || 0);
|
||||||
let depth = Math.min(t * 0.999, Math.max(0.0008, rawDepth));
|
let depth = Math.min(t * 0.999, Math.max(0.0008, rawDepth));
|
||||||
if (h.type === 'tourillon') depth = Math.min(depth, 0.013);
|
if (h.type === 'tourillon') depth = Math.min(depth, 0.013);
|
||||||
const sepFromBottom = ixconnectInbusFaceFromBottomThick(etPiece, h)
|
const dessusDir = etPiece === 'dessus' ? dessusFaceHoleFromBottom(h) : null;
|
||||||
|
const sepFromBottom = dessusDir != null
|
||||||
|
? dessusDir
|
||||||
|
: ixconnectInbusFaceFromBottomThick(etPiece, h)
|
||||||
? horizontalSeparatorFaceHoleFromBottomThick(etPiece)
|
? horizontalSeparatorFaceHoleFromBottomThick(etPiece)
|
||||||
: (etPiece === 'dessus' || etPiece === 'fond' || etPiece === 'bandeauHaut') &&
|
: etPiece === 'bandeauHaut' &&
|
||||||
h.face === 'face' &&
|
h.face === 'face' &&
|
||||||
!isHorizontalPanelExteriorCoteJunction(h.xM, w) &&
|
!isHorizontalPanelExteriorCoteJunction(h.xM, w) &&
|
||||||
horizontalSeparatorFaceHoleFromBottomThick(etPiece);
|
horizontalSeparatorFaceHoleFromBottomThick(etPiece);
|
||||||
@@ -675,9 +741,11 @@ function buildPanelHoleDiscardUniforms(holes, rawSize, piece) {
|
|||||||
h.face === 'face' &&
|
h.face === 'face' &&
|
||||||
h.type === 'shelf_mobile_face';
|
h.type === 'shelf_mobile_face';
|
||||||
let fromBottom = sepFromBottom || shelfFromBottom;
|
let fromBottom = sepFromBottom || shelfFromBottom;
|
||||||
// Habillage finition sur montant : lignes haut/bas 50 mm = entrée face extérieure (FACE=back), opposée à la face caisse.
|
// FACE=back désigne toujours la grande face opposée : sous-face pour les panneaux horizontaux,
|
||||||
// Séparateurs : renfort tiroir vers module supérieur (colonne droite) = FACE=back — grande face opposée à celle du module inférieur.
|
// face extérieure/opposée pour les montants et séparateurs.
|
||||||
if ((etPiece === 'cotes' || etPiece === 'panneauxInterieurs') && h.face === 'back') fromBottom = true;
|
if (h.face === 'back' && dessusDir == null) fromBottom = true;
|
||||||
|
// Habillage dessus : face intérieure (contre le caisson) = y=0 dans la géométrie onglet (y=t = face extérieure pleine largeur).
|
||||||
|
if (etPiece === 'habillageFinitionDessus' && h.face === 'face') fromBottom = true;
|
||||||
const y = fromBottom ? depth / 2 + 0.0002 : t - depth / 2 + 0.0002;
|
const y = fromBottom ? depth / 2 + 0.0002 : t - depth / 2 + 0.0002;
|
||||||
cx = h.xM;
|
cx = h.xM;
|
||||||
cy = fromBottom ? depth / 2 + 0.0002 : t - depth / 2 + 0.0002;
|
cy = fromBottom ? depth / 2 + 0.0002 : t - depth / 2 + 0.0002;
|
||||||
@@ -710,7 +778,7 @@ function patchMaterialPanelDiscard(material, grooveParams, uMin, uMax, holeDisca
|
|||||||
const grooveActive = grooveParams ? 1 : 0;
|
const grooveActive = grooveParams ? 1 : 0;
|
||||||
const hc = holeDiscard.count;
|
const hc = holeDiscard.count;
|
||||||
const mc = holeDiscard.miterCount || 0;
|
const mc = holeDiscard.miterCount || 0;
|
||||||
material.customProgramCacheKey = () => `apex-panel-cut-v12-g${grooveActive}-h${hc}-m${mc}`;
|
material.customProgramCacheKey = () => `apex-panel-cut-v14-g${grooveActive}-h${hc}-m${mc}`;
|
||||||
material.onBeforeCompile = (shader) => {
|
material.onBeforeCompile = (shader) => {
|
||||||
shader.uniforms.uGrooveActive = { value: grooveActive };
|
shader.uniforms.uGrooveActive = { value: grooveActive };
|
||||||
shader.uniforms.uGrooveMin = { value: uMin };
|
shader.uniforms.uGrooveMin = { value: uMin };
|
||||||
@@ -899,8 +967,10 @@ function GroovedPanelMesh({ rawSize, piece, materialColor, holes }) {
|
|||||||
roughness: 0.72,
|
roughness: 0.72,
|
||||||
metalness: 0.05,
|
metalness: 0.05,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.72,
|
opacity: 0.82,
|
||||||
depthWrite: false,
|
// Occlude underside hole cylinders when viewing the opposite face; openings
|
||||||
|
// still come from the discard shader on the real entry face.
|
||||||
|
depthWrite: true,
|
||||||
side: DoubleSide
|
side: DoubleSide
|
||||||
});
|
});
|
||||||
if (!grooveParams && holeDiscard.count === 0) return m;
|
if (!grooveParams && holeDiscard.count === 0) return m;
|
||||||
@@ -1060,6 +1130,40 @@ function GroovedPanelMesh({ rawSize, piece, materialColor, holes }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FRONT_BACK_LABEL_TYPES = new Set([
|
||||||
|
'cotes',
|
||||||
|
'cotesDoublage',
|
||||||
|
'fileur',
|
||||||
|
'fileurCotes',
|
||||||
|
'panneauxInterieurs',
|
||||||
|
'plintheGauche',
|
||||||
|
'plintheDroite',
|
||||||
|
'habillageFinitionGauche',
|
||||||
|
'habillageFinitionDroite'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function PieceSideLabels({ rawSize, piece }) {
|
||||||
|
if (!rawSize) return null;
|
||||||
|
|
||||||
|
const labelY = rawSize.thickM + Math.max(0.012, rawSize.thickM * 0.75);
|
||||||
|
const labelZ = rawSize.heightM / 2;
|
||||||
|
const sideOffset = Math.max(0.07, rawSize.widthM * 0.08);
|
||||||
|
const labels = FRONT_BACK_LABEL_TYPES.has(piece?.elementType)
|
||||||
|
? ['Devant', 'Derrière']
|
||||||
|
: ['Gauche', 'Droite'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Html position={[-sideOffset, labelY, labelZ]} center zIndexRange={[2, 1]}>
|
||||||
|
<span className={styles.sideLabel}>{labels[0]}</span>
|
||||||
|
</Html>
|
||||||
|
<Html position={[rawSize.widthM + sideOffset, labelY, labelZ]} center zIndexRange={[2, 1]}>
|
||||||
|
<span className={styles.sideLabel}>{labels[1]}</span>
|
||||||
|
</Html>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, alignId, alignAxis, fallbackDistance }) {
|
function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, alignId, alignAxis, fallbackDistance }) {
|
||||||
/**
|
/**
|
||||||
* The panel mesh sits in +X/+Z (center at w/2, t/2, h/2). GridHelper is a square in XZ centered on its
|
* The panel mesh sits in +X/+Z (center at w/2, t/2, h/2). GridHelper is a square in XZ centered on its
|
||||||
@@ -1088,16 +1192,25 @@ function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, align
|
|||||||
{rawSize && (
|
{rawSize && (
|
||||||
<GroovedPanelMesh rawSize={rawSize} piece={piece} materialColor={materialColor} holes={holes} />
|
<GroovedPanelMesh rawSize={rawSize} piece={piece} materialColor={materialColor} holes={holes} />
|
||||||
)}
|
)}
|
||||||
{rawSize && holes.map((h, i) => {
|
<PieceSideLabels rawSize={rawSize} piece={piece} />
|
||||||
|
{rawSize && [...holes]
|
||||||
|
// Draw Inbus after its Ixconnect so the Ø6 core stays visible when coaxial.
|
||||||
|
.map((h, i) => ({ h, i }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const rank = (t) => (t === 'ixconnect_inbus' ? 1 : 0);
|
||||||
|
return rank(a.h.type) - rank(b.h.type) || a.i - b.i;
|
||||||
|
})
|
||||||
|
.map(({ h, i }) => {
|
||||||
const radius = Math.max(0.0001, Number(h.dM) / 2);
|
const radius = Math.max(0.0001, Number(h.dM) / 2);
|
||||||
const etPiece = piece?.elementType || '';
|
const etPiece = piece?.elementType || '';
|
||||||
const wPanel = rawSize.widthM;
|
const wPanel = rawSize.widthM;
|
||||||
|
const isInbus = h.type === 'ixconnect_inbus';
|
||||||
const color =
|
const color =
|
||||||
h.type === 'ixconnect'
|
h.type === 'ixconnect' || h.type === 'ixconnect_finish'
|
||||||
? '#7c3aed'
|
? '#7c3aed'
|
||||||
: String(h.type || '').startsWith('ixconnect_drawer')
|
: String(h.type || '').startsWith('ixconnect_drawer')
|
||||||
? '#ea580c'
|
? '#ea580c'
|
||||||
: h.type === 'ixconnect_inbus'
|
: isInbus
|
||||||
? '#a855f7'
|
? '#a855f7'
|
||||||
: h.type === 'ferrure'
|
: h.type === 'ferrure'
|
||||||
? '#0f766e'
|
? '#0f766e'
|
||||||
@@ -1106,16 +1219,20 @@ function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, align
|
|||||||
: h.type === 'tourillon'
|
: h.type === 'tourillon'
|
||||||
? '#64748b'
|
? '#64748b'
|
||||||
: '#ea580c';
|
: '#ea580c';
|
||||||
/* Dessus/fond : sur face (séparateurs), trio T–IX–T tout gris. Sur chant (montants), comme renfort : violet = Ixconnect seul au milieu, tourillons gris. */
|
/* Dessus/fond : jonctions caisse → trio gris ; SC habillage (finish) reste violet. */
|
||||||
let holeColor = color;
|
let holeColor = color;
|
||||||
if ((etPiece === 'dessus' || etPiece === 'fond') && h.face === 'face') {
|
if ((etPiece === 'dessus' || etPiece === 'fond') && h.type === 'ixconnect' && h.face !== 'end') {
|
||||||
if (!isHorizontalPanelExteriorCoteJunction(h.xM, wPanel) && h.type === 'ixconnect') {
|
const isFinishSc =
|
||||||
holeColor = '#64748b';
|
etPiece === 'dessus' &&
|
||||||
}
|
h.face === 'face' &&
|
||||||
|
Math.abs((Number(h.zM) || 0) * 1000 - 12.5) < 0.35;
|
||||||
|
if (!isFinishSc) holeColor = '#64748b';
|
||||||
}
|
}
|
||||||
const lip = Math.min(0.00012, Math.max(0.00003, radius * 0.06));
|
const lip = Math.min(0.00012, Math.max(0.00003, radius * 0.06));
|
||||||
const innerR = Math.max(0.00005, radius - lip);
|
const innerR = Math.max(0.00005, radius - lip);
|
||||||
const outerR = radius + lip;
|
const outerR = radius + lip;
|
||||||
|
const faceHoleRenderOrder = isInbus ? 16 : 12;
|
||||||
|
const ringRenderOrder = isInbus ? 18 : 11;
|
||||||
|
|
||||||
if (h.face === 'end') {
|
if (h.face === 'end') {
|
||||||
const w = rawSize.widthM;
|
const w = rawSize.widthM;
|
||||||
@@ -1289,9 +1406,12 @@ function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, align
|
|||||||
if (h.type === 'tourillon') depth = Math.min(depth, 0.013);
|
if (h.type === 'tourillon') depth = Math.min(depth, 0.013);
|
||||||
const forceThroughSystem32 = h.type === 'system32' && etPiece !== 'cotes';
|
const forceThroughSystem32 = h.type === 'system32' && etPiece !== 'cotes';
|
||||||
const throughFace = rawDepth >= rawSize.thickM - 1e-5 || forceThroughSystem32;
|
const throughFace = rawDepth >= rawSize.thickM - 1e-5 || forceThroughSystem32;
|
||||||
const sepFromBottom = ixconnectInbusFaceFromBottomThick(etPiece, h)
|
const dessusDir = etPiece === 'dessus' ? dessusFaceHoleFromBottom(h) : null;
|
||||||
|
const sepFromBottom = dessusDir != null
|
||||||
|
? dessusDir
|
||||||
|
: ixconnectInbusFaceFromBottomThick(etPiece, h)
|
||||||
? horizontalSeparatorFaceHoleFromBottomThick(etPiece)
|
? horizontalSeparatorFaceHoleFromBottomThick(etPiece)
|
||||||
: (etPiece === 'dessus' || etPiece === 'fond' || etPiece === 'bandeauHaut') &&
|
: etPiece === 'bandeauHaut' &&
|
||||||
h.face === 'face' &&
|
h.face === 'face' &&
|
||||||
!isHorizontalPanelExteriorCoteJunction(h.xM, wPanel) &&
|
!isHorizontalPanelExteriorCoteJunction(h.xM, wPanel) &&
|
||||||
horizontalSeparatorFaceHoleFromBottomThick(etPiece);
|
horizontalSeparatorFaceHoleFromBottomThick(etPiece);
|
||||||
@@ -1300,54 +1420,68 @@ function CncScene({ rawSize, materialColor, holes, piece, orbitStorageKey, align
|
|||||||
h.face === 'face' &&
|
h.face === 'face' &&
|
||||||
h.type === 'shelf_mobile_face';
|
h.type === 'shelf_mobile_face';
|
||||||
let fromBottom = sepFromBottom || shelfFromBottom;
|
let fromBottom = sepFromBottom || shelfFromBottom;
|
||||||
if ((etPiece === 'cotes' || etPiece === 'panneauxInterieurs') && h.face === 'back') fromBottom = true;
|
// FACE=back = entrée par la face opposée (sous-face du fond pour les pieds AXILO ;
|
||||||
|
// sous-face du dessus pour jonctions caisse côtés/séparateurs).
|
||||||
|
if (h.face === 'back' && dessusDir == null) fromBottom = true;
|
||||||
|
// Habillage dessus : Ixconnect sur la face intérieure (y=0), pas la face extérieure visible.
|
||||||
|
if (etPiece === 'habillageFinitionDessus' && h.face === 'face') fromBottom = true;
|
||||||
const y = throughFace
|
const y = throughFace
|
||||||
? rawSize.thickM / 2
|
? rawSize.thickM / 2
|
||||||
: fromBottom
|
: fromBottom
|
||||||
? depth / 2 + 0.0002
|
? depth / 2 + 0.0002
|
||||||
: rawSize.thickM - depth / 2 + 0.0002;
|
: rawSize.thickM - depth / 2 + 0.0002;
|
||||||
const ringY = fromBottom ? -0.00008 : rawSize.thickM + 0.00008;
|
// Keep entry rings on the real entry face (depth-tested). depthTest:false was
|
||||||
|
// painting underside openings onto the visible top face through the transparent panel.
|
||||||
|
const ringY = fromBottom ? -0.00035 : rawSize.thickM + 0.00035;
|
||||||
const ringRot = fromBottom ? [Math.PI / 2, 0, 0] : [-Math.PI / 2, 0, 0];
|
const ringRot = fromBottom ? [Math.PI / 2, 0, 0] : [-Math.PI / 2, 0, 0];
|
||||||
const ringOppY = fromBottom ? rawSize.thickM + 0.00008 : -0.00008;
|
const ringOppY = fromBottom ? rawSize.thickM + 0.00035 : -0.00035;
|
||||||
const ringOppRot = fromBottom ? [-Math.PI / 2, 0, 0] : [Math.PI / 2, 0, 0];
|
const ringOppRot = fromBottom ? [-Math.PI / 2, 0, 0] : [Math.PI / 2, 0, 0];
|
||||||
return (
|
return (
|
||||||
<group key={`hole-${i}`}>
|
<group key={`hole-${i}`}>
|
||||||
<mesh position={[h.xM, y, h.yM]} renderOrder={12}>
|
<mesh position={[h.xM, y, h.yM]} renderOrder={faceHoleRenderOrder}>
|
||||||
<cylinderGeometry args={[radius, radius, depth, 32, 1, true]} />
|
<cylinderGeometry args={[radius, radius, depth, 32, 1, true]} />
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color={holeColor}
|
color={holeColor}
|
||||||
roughness={0.45}
|
roughness={0.45}
|
||||||
metalness={0.05}
|
metalness={isInbus ? 0.15 : 0.05}
|
||||||
|
emissive={isInbus ? holeColor : '#000000'}
|
||||||
|
emissiveIntensity={isInbus ? 0.25 : 0}
|
||||||
side={DoubleSide}
|
side={DoubleSide}
|
||||||
depthTest
|
depthTest
|
||||||
depthWrite
|
depthWrite
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh position={[h.xM, ringY, h.yM]} rotation={ringRot} renderOrder={11}>
|
<mesh position={[h.xM, ringY, h.yM]} rotation={ringRot} renderOrder={ringRenderOrder}>
|
||||||
<ringGeometry args={[innerR, outerR, 48]} />
|
<ringGeometry args={[innerR, outerR, 48]} />
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color={holeColor}
|
color={holeColor}
|
||||||
roughness={0.35}
|
roughness={0.35}
|
||||||
metalness={0.1}
|
metalness={0.1}
|
||||||
emissive={holeColor}
|
emissive={holeColor}
|
||||||
emissiveIntensity={0.2}
|
emissiveIntensity={isInbus ? 0.45 : 0.2}
|
||||||
depthTest={false}
|
depthTest
|
||||||
depthWrite={false}
|
depthWrite={false}
|
||||||
side={DoubleSide}
|
side={DoubleSide}
|
||||||
|
polygonOffset
|
||||||
|
polygonOffsetFactor={-2}
|
||||||
|
polygonOffsetUnits={-2}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
{throughFace && (
|
{throughFace && (
|
||||||
<mesh position={[h.xM, ringOppY, h.yM]} rotation={ringOppRot} renderOrder={11}>
|
<mesh position={[h.xM, ringOppY, h.yM]} rotation={ringOppRot} renderOrder={ringRenderOrder}>
|
||||||
<ringGeometry args={[innerR, outerR, 48]} />
|
<ringGeometry args={[innerR, outerR, 48]} />
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color={holeColor}
|
color={holeColor}
|
||||||
roughness={0.35}
|
roughness={0.35}
|
||||||
metalness={0.1}
|
metalness={0.1}
|
||||||
emissive={holeColor}
|
emissive={holeColor}
|
||||||
emissiveIntensity={0.2}
|
emissiveIntensity={isInbus ? 0.45 : 0.2}
|
||||||
depthTest={false}
|
depthTest
|
||||||
depthWrite={false}
|
depthWrite={false}
|
||||||
side={DoubleSide}
|
side={DoubleSide}
|
||||||
|
polygonOffset
|
||||||
|
polygonOffsetFactor={-2}
|
||||||
|
polygonOffsetUnits={-2}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
)}
|
)}
|
||||||
@@ -1366,10 +1500,20 @@ export default function Cnc3DViewer({
|
|||||||
materialColor = '#c4a574',
|
materialColor = '#c4a574',
|
||||||
orbitStorageKey = 'default'
|
orbitStorageKey = 'default'
|
||||||
}) {
|
}) {
|
||||||
const rawSize = useMemo(() => parseRawSize(gcode), [gcode]);
|
const rawSize = useMemo(() => panelSceneSize(piece, gcode), [piece, gcode]);
|
||||||
const holes = useMemo(() => {
|
const holes = useMemo(() => {
|
||||||
const fromG = parseHoles(gcode);
|
const mirrorDepthAxis = ['dessus', 'fond'].includes(piece?.elementType || '');
|
||||||
|
const fromG = parseHoles(gcode).map((hole) => (
|
||||||
|
mirrorDepthAxis && rawSize
|
||||||
|
? { ...hole, yM: rawSize.heightM - hole.yM }
|
||||||
|
: hole
|
||||||
|
));
|
||||||
const fromPlan = planHoleSpecsToSceneHoles(piece, rawSize);
|
const fromPlan = planHoleSpecsToSceneHoles(piece, rawSize);
|
||||||
|
const planIsComplete = ['dessus', 'fond', 'cotes', 'panneauxInterieurs']
|
||||||
|
.includes(piece?.elementType || '');
|
||||||
|
// These panels are fully represented by shared machining specs. Combining
|
||||||
|
// them with raw G-code renders a second, offset copy of the same holes.
|
||||||
|
if (planIsComplete && fromPlan.length) return fromPlan;
|
||||||
return mergeHoleListsByPosition(fromG, fromPlan);
|
return mergeHoleListsByPosition(fromG, fromPlan);
|
||||||
}, [gcode, piece, rawSize]);
|
}, [gcode, piece, rawSize]);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,24 @@
|
|||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sideLabel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 1px solid rgba(71, 85, 105, 0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #334155;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.16);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.axisBar {
|
.axisBar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 8px;
|
left: 8px;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function getPieceDepthMm(piece, cuttingListConfigDepthMm) {
|
|||||||
|
|
||||||
// For readability in the cutting list, "Profondeur" is the dimension along wardrobe depth.
|
// For readability in the cutting list, "Profondeur" is the dimension along wardrobe depth.
|
||||||
// Depending on the panel type, this maps to either width or height.
|
// Depending on the panel type, this maps to either width or height.
|
||||||
const depthIsWidth = new Set(['cotes', 'cotesDoublage', 'fileur', 'habillageFinitionGauche', 'habillageFinitionDroite']);
|
const depthIsWidth = new Set(['cotes', 'cotesDoublage', 'fileur', 'habillageFinitionGauche', 'habillageFinitionDroite', 'plintheGauche', 'plintheDroite']);
|
||||||
const depthIsHeight = new Set(['dessus', 'fond', 'habillageFinitionDessus']);
|
const depthIsHeight = new Set(['dessus', 'fond', 'habillageFinitionDessus']);
|
||||||
|
|
||||||
if (depthIsWidth.has(t)) return width ?? null;
|
if (depthIsWidth.has(t)) return width ?? null;
|
||||||
@@ -186,10 +186,14 @@ export default function CuttingListTable({ cuttingList, onExportMprPiece, cncPro
|
|||||||
|
|
||||||
function buildManufacturingRows(piece) {
|
function buildManufacturingRows(piece) {
|
||||||
const m = piece?.machining || {};
|
const m = piece?.machining || {};
|
||||||
|
const surcote = piece.surcoteMm ? ` · surcote sur-mesure ${piece.surcoteMm} mm L+l` : '';
|
||||||
|
const bandingDir = piece.edgeBanding?.direction?.label
|
||||||
|
? ` · sens de collage: ${piece.edgeBanding.direction.label}`
|
||||||
|
: '';
|
||||||
const rows = [
|
const rows = [
|
||||||
['Dimensions finies', `${piece.widthMm} x ${piece.heightMm} x ${piece.thicknessMm} mm`],
|
['Dimensions finies', `${piece.widthMm} x ${piece.heightMm} x ${piece.thicknessMm} mm`],
|
||||||
['Dimensions brutes CNC', `${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm`],
|
['Dimensions brutes CNC', `${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm${surcote}`],
|
||||||
['Chants', `${piece.edgeBanding?.sides || 0} côté(s) · épaisseur ${piece.edgeBanding?.thickness || 0} mm`]
|
['Chants', `${piece.edgeBanding?.sides || 0} côté(s) · épaisseur ${piece.edgeBanding?.thickness || 0} mm${bandingDir}`]
|
||||||
];
|
];
|
||||||
|
|
||||||
if (m.system32) {
|
if (m.system32) {
|
||||||
@@ -417,8 +421,12 @@ export default function CuttingListTable({ cuttingList, onExportMprPiece, cncPro
|
|||||||
<td className={styles.surfaceCell}>{piece.surfaceM2.toFixed(3)}</td>
|
<td className={styles.surfaceCell}>{piece.surfaceM2.toFixed(3)}</td>
|
||||||
<td className={styles.edgeCell}>
|
<td className={styles.edgeCell}>
|
||||||
{piece.edgeBanding.type !== 'none' && (
|
{piece.edgeBanding.type !== 'none' && (
|
||||||
<span className={`${styles.edgeBadge} ${styles[piece.edgeBanding.type]}`}>
|
<span
|
||||||
|
className={`${styles.edgeBadge} ${styles[piece.edgeBanding.type]}`}
|
||||||
|
title={piece.edgeBanding.direction?.label ? `Sens de collage: ${piece.edgeBanding.direction.label}` : undefined}
|
||||||
|
>
|
||||||
{piece.edgeBanding.sides} côtés
|
{piece.edgeBanding.sides} côtés
|
||||||
|
{piece.edgeBanding.direction?.label ? ` · ${piece.edgeBanding.direction.label}` : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import styles from './Layout.module.css';
|
import styles from './Layout.module.css';
|
||||||
|
|
||||||
|
const navClass = ({ isActive }) => (isActive ? styles.navActive : undefined);
|
||||||
|
|
||||||
export default function Layout({ children }) {
|
export default function Layout({ children }) {
|
||||||
const { isAdmin, logout } = useAuth();
|
const { isAdmin, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const headerRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const header = headerRef.current;
|
||||||
|
if (!header) return undefined;
|
||||||
|
const root = document.documentElement;
|
||||||
|
const update = () => root.style.setProperty('--header-height', `${header.offsetHeight}px`);
|
||||||
|
update();
|
||||||
|
const observer = new ResizeObserver(update);
|
||||||
|
observer.observe(header);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
@@ -13,16 +28,16 @@ export default function Layout({ children }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.app}>
|
<div className={styles.app}>
|
||||||
<header className={styles.header}>
|
<header ref={headerRef} className={styles.header}>
|
||||||
<Link to="/" className={styles.logo}>SIGNATURE</Link>
|
<Link to="/" className={styles.logo}>SIGNATURE</Link>
|
||||||
<nav>
|
<nav>
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<>
|
<>
|
||||||
<Link to="/">Accueil</Link>
|
<NavLink to="/" end className={navClass}>Accueil</NavLink>
|
||||||
<Link to="/configurateur">Configurateur</Link>
|
<NavLink to="/configurateur" className={navClass}>Configurateur</NavLink>
|
||||||
<Link to="/materiaux">Matériaux</Link>
|
<NavLink to="/materiaux" className={navClass}>Matériaux</NavLink>
|
||||||
<Link to="/admin">Admin</Link>
|
<NavLink to="/admin" className={navClass}>Admin</NavLink>
|
||||||
<Link to="/parametres">Paramètres</Link>
|
<NavLink to="/parametres" className={navClass}>Paramètres</NavLink>
|
||||||
<button type="button" className={styles.logoutBtn} onClick={handleLogout}>Déconnexion</button>
|
<button type="button" className={styles.logoutBtn} onClick={handleLogout}>Déconnexion</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -33,6 +48,15 @@ export default function Layout({ children }) {
|
|||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
<footer className={styles.footer}>
|
||||||
|
<span className={styles.footerBrand}>© {new Date().getFullYear()} SIGNATURE</span>
|
||||||
|
<nav className={styles.footerNav}>
|
||||||
|
<Link to="/mentions-legales">Mentions légales</Link>
|
||||||
|
<Link to="/confidentialite">Politique de confidentialité</Link>
|
||||||
|
<Link to="/conditions-generales">Conditions générales</Link>
|
||||||
|
<Link to="/reglements">Règlements</Link>
|
||||||
|
</nav>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,15 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
background: var(--bg-card);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||||
|
position: -webkit-sticky;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 70;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
@@ -40,6 +47,23 @@
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header nav a.navActive {
|
||||||
|
position: relative;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header nav a.navActive::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: -0.375rem;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.header nav .logoutBtn {
|
.header nav .logoutBtn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -61,3 +85,36 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 1.5rem;
|
padding: 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem 1.5rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerBrand {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerNav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerNav a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerNav a:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import Icon from './ui/Icon';
|
|||||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
const PLANS_TAB_KEY = 'apex.plans.tab';
|
const PLANS_TAB_KEY = 'apex.plans.tab';
|
||||||
const VALID_PLAN_TABS = ['assembly', 'side', 'top', 'cutting', 'hardware'];
|
const VALID_PLAN_TABS = ['assembly', 'side', 'top', 'cutting', 'calepinage', 'hardware'];
|
||||||
|
|
||||||
function readSavedPlanTab() {
|
function readSavedPlanTab() {
|
||||||
try {
|
try {
|
||||||
@@ -34,6 +34,8 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId
|
|||||||
const [activeTab, setActiveTab] = useState(readSavedPlanTab);
|
const [activeTab, setActiveTab] = useState(readSavedPlanTab);
|
||||||
const [plans, setPlans] = useState(null);
|
const [plans, setPlans] = useState(null);
|
||||||
const [cuttingList, setCuttingList] = useState(null);
|
const [cuttingList, setCuttingList] = useState(null);
|
||||||
|
const [cuttingPlan, setCuttingPlan] = useState(null);
|
||||||
|
const [cuttingPlanError, setCuttingPlanError] = useState(null);
|
||||||
const [hardwareList, setHardwareList] = useState(null);
|
const [hardwareList, setHardwareList] = useState(null);
|
||||||
const [hardwareError, setHardwareError] = useState(null);
|
const [hardwareError, setHardwareError] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -128,6 +130,26 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId
|
|||||||
setHardwareError('Erreur lors de la génération de la liste de quincaillerie');
|
setHardwareError('Erreur lors de la génération de la liste de quincaillerie');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch cutting plan (calepinage)
|
||||||
|
try {
|
||||||
|
const planRes = await fetch(`${API_BASE}/api/plans/cutting-plan`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ config, modules, colors })
|
||||||
|
});
|
||||||
|
const planData = await planRes.json();
|
||||||
|
if (planRes.ok && planData.success) {
|
||||||
|
setCuttingPlan(planData.cuttingPlan);
|
||||||
|
setCuttingPlanError(null);
|
||||||
|
} else {
|
||||||
|
setCuttingPlan(null);
|
||||||
|
setCuttingPlanError(planData.error || 'Plan de coupe indisponible.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setCuttingPlan(null);
|
||||||
|
setCuttingPlanError('Plan de coupe indisponible.');
|
||||||
|
}
|
||||||
|
|
||||||
const cncRes = await fetch(`${API_BASE}/api/plans/export-cnc`, {
|
const cncRes = await fetch(`${API_BASE}/api/plans/export-cnc`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -246,6 +268,21 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
setExportStatus('Archive Homag MPR (.zip) téléchargée.');
|
setExportStatus('Archive Homag MPR (.zip) téléchargée.');
|
||||||
|
} else if (format === 'excel') {
|
||||||
|
const res = await fetch(`${API_BASE}/api/plans/export-excel`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ config, modules, colors })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'liste-debitage.xlsx';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setExportStatus('Liste de débitage exportée en Excel (.xlsx).');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Export error:', err);
|
console.error('Export error:', err);
|
||||||
@@ -303,6 +340,7 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId
|
|||||||
{ id: 'side', label: 'Vue de côté' },
|
{ id: 'side', label: 'Vue de côté' },
|
||||||
{ id: 'top', label: 'Vue de dessus' },
|
{ id: 'top', label: 'Vue de dessus' },
|
||||||
{ id: 'cutting', label: 'Liste de débitage' },
|
{ id: 'cutting', label: 'Liste de débitage' },
|
||||||
|
{ id: 'calepinage', label: 'Calepinage' },
|
||||||
{ id: 'hardware', label: 'Quincaillerie' }
|
{ id: 'hardware', label: 'Quincaillerie' }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -384,6 +422,49 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'calepinage' && (
|
||||||
|
cuttingPlanError ? (
|
||||||
|
<div className={styles.empty}>{cuttingPlanError}</div>
|
||||||
|
) : cuttingPlan ? (
|
||||||
|
<div className={styles.calepinage}>
|
||||||
|
<div className={styles.calepinageSummary}>
|
||||||
|
<div className={styles.calepinageStat}>
|
||||||
|
<span>Panneaux nécessaires</span>
|
||||||
|
<strong>{cuttingPlan.totalBoards}</strong>
|
||||||
|
</div>
|
||||||
|
<div className={styles.calepinageStat}>
|
||||||
|
<span>Format panneau</span>
|
||||||
|
<strong>{cuttingPlan.stock?.widthMm} × {cuttingPlan.stock?.heightMm} mm</strong>
|
||||||
|
</div>
|
||||||
|
<div className={styles.calepinageStat}>
|
||||||
|
<span>Surface utilisée</span>
|
||||||
|
<strong>{cuttingPlan.totalUsedAreaM2} m²</strong>
|
||||||
|
</div>
|
||||||
|
<div className={styles.calepinageStat}>
|
||||||
|
<span>Chute totale</span>
|
||||||
|
<strong>{cuttingPlan.totalWasteAreaM2} m² ({cuttingPlan.globalWastePercent}%)</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.calepinageBoards}>
|
||||||
|
{cuttingPlan.boards.map((board, i) => (
|
||||||
|
<div key={i} className={styles.calepinageBoard}>
|
||||||
|
<div className={styles.calepinageBoardHead}>
|
||||||
|
<span>Panneau {i + 1} — {board.finishLabel} · {board.thicknessMm} mm</span>
|
||||||
|
<span>Chute {board.wastePercent}%</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={styles.calepinageSvg}
|
||||||
|
dangerouslySetInnerHTML={{ __html: board.svg }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.empty}>Plan de coupe en cours de génération…</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'hardware' && (
|
{activeTab === 'hardware' && (
|
||||||
<HardwareListTable
|
<HardwareListTable
|
||||||
hardwareList={hardwareList}
|
hardwareList={hardwareList}
|
||||||
@@ -417,6 +498,15 @@ export default function Plans2DView({ config, modules = [], colors = {}, orderId
|
|||||||
Liste texte
|
Liste texte
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{(activeTab === 'cutting' || activeTab === 'calepinage' || activeTab === 'hardware') && (
|
||||||
|
<button
|
||||||
|
className={styles.exportBtn}
|
||||||
|
onClick={() => handleExport('excel')}
|
||||||
|
>
|
||||||
|
<Icon name="download" size={15} />
|
||||||
|
Export Excel (.xlsx)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className={styles.exportBtn}
|
className={styles.exportBtn}
|
||||||
onClick={() => handleExport('pdf')}
|
onClick={() => handleExport('pdf')}
|
||||||
|
|||||||
@@ -144,6 +144,72 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calepinage {
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepinageSummary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepinageStat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--surface-2, #f3f4f6);
|
||||||
|
border: 1px solid var(--border, #e5e7eb);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepinageStat span {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepinageStat strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepinageBoards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepinageBoard {
|
||||||
|
border: 1px solid var(--border, #e5e7eb);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepinageBoardHead {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text, #111827);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepinageSvg {
|
||||||
|
width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepinageSvg svg {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.planContainer {
|
.planContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -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
|
// Default colors for different element types
|
||||||
const DEFAULT_COLORS = {
|
const DEFAULT_COLORS = {
|
||||||
plintheBas: '#8b7355',
|
plintheBas: '#8b7355',
|
||||||
|
plintheGauche: '#8b7355',
|
||||||
|
plintheDroite: '#8b7355',
|
||||||
fileur: '#8b7355',
|
fileur: '#8b7355',
|
||||||
bandeauHaut: '#8b7355',
|
bandeauHaut: '#8b7355',
|
||||||
champs: '#6b5344',
|
champs: '#6b5344',
|
||||||
@@ -32,6 +34,8 @@ const DEFAULT_COLORS = {
|
|||||||
// Element labels for display
|
// Element labels for display
|
||||||
const ELEMENT_LABELS = {
|
const ELEMENT_LABELS = {
|
||||||
plintheBas: 'Plinthe bas',
|
plintheBas: 'Plinthe bas',
|
||||||
|
plintheGauche: 'Plinthe retour gauche',
|
||||||
|
plintheDroite: 'Plinthe retour droit',
|
||||||
fileur: 'Fileur',
|
fileur: 'Fileur',
|
||||||
bandeauHaut: 'Bandeau haut',
|
bandeauHaut: 'Bandeau haut',
|
||||||
champs: 'Champs',
|
champs: 'Champs',
|
||||||
@@ -63,12 +67,27 @@ function getElementColor(elementType, colors) {
|
|||||||
return DEFAULT_COLORS[elementType] || '#cccccc';
|
return DEFAULT_COLORS[elementType] || '#cccccc';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-origin swatch images (e.g. supplier CDNs) taint the <canvas> we use to
|
||||||
|
* build WebGL textures, so the texture upload and dominant-color sampling both
|
||||||
|
* fail and the element falls back to a flat default color. Route absolute URLs
|
||||||
|
* through our same-origin proxy so the canvas stays clean. Relative URLs
|
||||||
|
* (locally cached materials under /materials/...) are already same-origin.
|
||||||
|
*/
|
||||||
|
function toCanvasSafeUrl(url) {
|
||||||
|
if (!url) return null;
|
||||||
|
if (/^https?:\/\//i.test(url)) {
|
||||||
|
return `/api/material-image?url=${encodeURIComponent(url)}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get image URL for element texture (if available)
|
* Get image URL for element texture (if available)
|
||||||
*/
|
*/
|
||||||
function getElementImageUrl(elementType, colors) {
|
function getElementImageUrl(elementType, colors) {
|
||||||
if (colors && colors[elementType] && colors[elementType].imageUrl) {
|
if (colors && colors[elementType] && colors[elementType].imageUrl) {
|
||||||
return colors[elementType].imageUrl;
|
return toCanvasSafeUrl(colors[elementType].imageUrl);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -197,8 +216,10 @@ function loadCroppedTexture(url) {
|
|||||||
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
|
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
|
||||||
|
|
||||||
const tex = new THREE.CanvasTexture(canvas);
|
const tex = new THREE.CanvasTexture(canvas);
|
||||||
tex.wrapS = THREE.RepeatWrapping;
|
// Clamp (don't repeat): a single swatch is stretched to fill each panel so
|
||||||
tex.wrapT = THREE.RepeatWrapping;
|
// the non-seamless thumbnail edges never tile into a visible grid of squares.
|
||||||
|
tex.wrapS = THREE.ClampToEdgeWrapping;
|
||||||
|
tex.wrapT = THREE.ClampToEdgeWrapping;
|
||||||
tex.colorSpace = THREE.SRGBColorSpace;
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
tex.minFilter = THREE.LinearMipmapLinearFilter;
|
tex.minFilter = THREE.LinearMipmapLinearFilter;
|
||||||
tex.magFilter = THREE.LinearFilter;
|
tex.magFilter = THREE.LinearFilter;
|
||||||
@@ -281,37 +302,20 @@ function useCroppedTexture(imageUrl, geometryArgs, geometryType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure texture repeat based on geometry dimensions.
|
* Stretch a single swatch across the whole face instead of tiling it.
|
||||||
* The cropped swatch represents approximately 0.3m x 0.3m of real material.
|
*
|
||||||
|
* The supplier thumbnails are not seamless, so any repeat > 1 produces a visible
|
||||||
|
* grid of squares at every tile boundary. Filling each panel with exactly one
|
||||||
|
* swatch (repeat = 1, clamped) removes that grid entirely. The `geometryArgs` /
|
||||||
|
* `geometryType` arguments are kept for API compatibility (callers pass them and
|
||||||
|
* the repeat is re-applied when geometry changes).
|
||||||
*/
|
*/
|
||||||
function configureTextureRepeat(tex, geometryArgs, geometryType) {
|
function configureTextureRepeat(tex /* , geometryArgs, geometryType */) {
|
||||||
if (!tex || !geometryArgs) return;
|
if (!tex) return;
|
||||||
|
tex.wrapS = THREE.ClampToEdgeWrapping;
|
||||||
const TEXTURE_PATCH_SIZE = 0.3; // meters
|
tex.wrapT = THREE.ClampToEdgeWrapping;
|
||||||
let widthM, heightM;
|
tex.repeat.set(1, 1);
|
||||||
|
tex.offset.set(0, 0);
|
||||||
switch (geometryType) {
|
|
||||||
case 'box':
|
|
||||||
widthM = geometryArgs[0] || 1;
|
|
||||||
heightM = geometryArgs[1] || 1;
|
|
||||||
break;
|
|
||||||
case 'plane':
|
|
||||||
widthM = geometryArgs[0] || 1;
|
|
||||||
heightM = geometryArgs[1] || 1;
|
|
||||||
break;
|
|
||||||
case 'cylinder':
|
|
||||||
widthM = 0.3;
|
|
||||||
heightM = 0.3;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
widthM = 1;
|
|
||||||
heightM = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
tex.repeat.set(
|
|
||||||
Math.max(1, widthM / TEXTURE_PATCH_SIZE),
|
|
||||||
Math.max(1, heightM / TEXTURE_PATCH_SIZE)
|
|
||||||
);
|
|
||||||
tex.needsUpdate = true;
|
tex.needsUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,225 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { deleteLayoutPreset, fetchLayoutPresets, saveLayoutPreset } from '../../api/client';
|
||||||
|
import {
|
||||||
|
minShelvesForColumn, moduleBlockHeightsMm, moduleHangingZoneMm, moduleStackInfo, pickLayoutPreset, resolveShelfPositionsMm
|
||||||
|
} from '../../constants/wardrobe';
|
||||||
|
import { proposeLayout } from '../../constants/layoutProposal';
|
||||||
|
import { drawerStackHeightMm } from '@shared/manufacturing.js';
|
||||||
|
import Icon from '../ui/Icon';
|
||||||
|
import styles from './LayoutPresets.module.css';
|
||||||
|
|
||||||
|
function errorMessage(error) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(error.message)?.error || error.message;
|
||||||
|
} catch {
|
||||||
|
return error.message || 'Une erreur est survenue.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Élévation de face schématique: caisson, blocks, rayons, tiroirs et tringles. */
|
||||||
|
function LayoutThumbnail({ layout, drawerHeightsMm }) {
|
||||||
|
const cfg = { plinthHeightMm: 100, ...layout };
|
||||||
|
const W = Number(cfg.widthMm) || 1;
|
||||||
|
const H = Number(cfg.heightMm) || 1;
|
||||||
|
const plinth = Number(cfg.plinthHeightMm) || 0;
|
||||||
|
const bodyTop = 0;
|
||||||
|
const bodyBottom = H - plinth;
|
||||||
|
const yFromCarcass = (mm) => bodyBottom - mm;
|
||||||
|
const modules = cfg.modules || [];
|
||||||
|
let x = 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg className={styles.thumb} viewBox={`-30 -30 ${W + 60} ${H + 60}`} preserveAspectRatio="xMidYMid meet" aria-hidden="true">
|
||||||
|
<rect x={0} y={bodyBottom} width={W} height={plinth} className={styles.thumbPlinth} />
|
||||||
|
<rect x={0} y={bodyTop} width={W} height={bodyBottom} className={styles.thumbBody} />
|
||||||
|
{modules.map((mod, i) => {
|
||||||
|
const w = Number(mod.widthMm) || 0;
|
||||||
|
const x0 = x;
|
||||||
|
x += w;
|
||||||
|
const heights = moduleBlockHeightsMm(mod, cfg);
|
||||||
|
const open = Array.isArray(mod.blockDoors) && mod.blockDoors.every((d) => d === false);
|
||||||
|
const shelfCount = Math.max(minShelvesForColumn(cfg.heightMm, mod, cfg), Number(mod.shelves) || 0);
|
||||||
|
const shelves = resolveShelfPositionsMm({
|
||||||
|
shelfCount,
|
||||||
|
heightMm: cfg.heightMm,
|
||||||
|
plinthHeightMm: plinth,
|
||||||
|
shelfPositionsMm: mod.shelfPositionsMm,
|
||||||
|
stack: moduleStackInfo(mod, cfg)
|
||||||
|
});
|
||||||
|
const drawers = Math.max(0, Number(mod.drawers) || 0);
|
||||||
|
const drawerStack = drawers > 0 ? drawerStackHeightMm(drawers, drawerHeightsMm) : 0;
|
||||||
|
const drawerH = drawers > 0 ? drawerStack / drawers : 0;
|
||||||
|
const boundaries = heights.slice(0, -1).reduce((acc, h) => [...acc, (acc[acc.length - 1] || 0) + h], []);
|
||||||
|
return (
|
||||||
|
<g key={mod.id || i}>
|
||||||
|
{open && <rect x={x0} y={bodyTop} width={w} height={bodyBottom} className={styles.thumbOpen} />}
|
||||||
|
{i > 0 && <line x1={x0} y1={bodyTop} x2={x0} y2={bodyBottom} className={styles.thumbPanel} />}
|
||||||
|
{boundaries.map((b) => (
|
||||||
|
<line key={`b${b}`} x1={x0} y1={yFromCarcass(b)} x2={x0 + w} y2={yFromCarcass(b)} className={styles.thumbPanel} />
|
||||||
|
))}
|
||||||
|
{shelves.map((p) => (
|
||||||
|
<line key={`s${p}`} x1={x0 + 18} y1={yFromCarcass(p)} x2={x0 + w - 18} y2={yFromCarcass(p)} className={styles.thumbShelf} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: drawers }, (_, d) => (
|
||||||
|
<g key={`d${d}`}>
|
||||||
|
<rect x={x0 + 25} y={yFromCarcass((d + 1) * drawerH)} width={w - 50} height={drawerH - 12} className={styles.thumbDrawer} />
|
||||||
|
<line x1={x0 + w / 2 - 50} y1={yFromCarcass((d + 0.5) * drawerH)} x2={x0 + w / 2 + 50} y2={yFromCarcass((d + 0.5) * drawerH)} className={styles.thumbHandle} />
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{mod.hasRod && (() => {
|
||||||
|
const rodY = yFromCarcass(moduleHangingZoneMm(mod, cfg).rodMm);
|
||||||
|
return <line x1={x0 + 30} y1={rodY} x2={x0 + w - 30} y2={rodY} className={styles.thumbRod} />;
|
||||||
|
})()}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LayoutPresets({ wc }) {
|
||||||
|
const { config } = wc;
|
||||||
|
const [presets, setPresets] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [naming, setNaming] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLayoutPresets()
|
||||||
|
.then(setPresets)
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const autoProposal = useMemo(() => proposeLayout(config), [config]);
|
||||||
|
const isNiche = config.installationType === 'niche';
|
||||||
|
const follow = wc.layoutFollow;
|
||||||
|
const autoActive = !!follow && (follow.mode !== 'preset' || follow.fellBack);
|
||||||
|
const followedPreset = follow?.mode === 'preset' ? presets.find((p) => p.id === follow.presetId) : null;
|
||||||
|
const hint = !follow
|
||||||
|
? 'Colonnes personnalisées: choisissez une disposition pour suivre à nouveau les dimensions.'
|
||||||
|
: follow.mode === 'preset' && follow.fellBack
|
||||||
|
? `« ${followedPreset?.name || 'Disposition'} » ne convient pas à ces cotes: proposition auto en attendant.`
|
||||||
|
: follow.mode === 'preset'
|
||||||
|
? `« ${followedPreset?.name || 'Disposition'} » s\u2019adapte à vos dimensions (aperçu en transparence).`
|
||||||
|
: isNiche
|
||||||
|
? 'La proposition auto s\u2019adapte aux cotes de la niche (aperçu en transparence).'
|
||||||
|
: 'La proposition auto suit vos dimensions (aperçu en transparence).';
|
||||||
|
|
||||||
|
const handleSave = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const preset = await saveLayoutPreset(trimmed, pickLayoutPreset(config));
|
||||||
|
setPresets((current) => [...current, preset]);
|
||||||
|
setName('');
|
||||||
|
setNaming(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (preset) => {
|
||||||
|
if (!window.confirm(`Retirer la disposition « ${preset.name} » ?`)) return;
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await deleteLayoutPreset(preset.id);
|
||||||
|
setPresets((current) => current.filter((p) => p.id !== preset.id));
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.presets} aria-labelledby="layout-presets-title">
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h3 id="layout-presets-title" className={styles.title}>Dispositions types</h3>
|
||||||
|
<span className={styles.hint}>{hint}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|
||||||
|
<div className={styles.strip}>
|
||||||
|
<div className={`${styles.card} ${autoActive ? styles.cardActive : ''}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cardMain}
|
||||||
|
onClick={wc.enableAutoLayout}
|
||||||
|
title="Disposition calculée selon les cotes; elle suit les changements de dimensions tant que les colonnes ne sont pas modifiées."
|
||||||
|
>
|
||||||
|
<LayoutThumbnail layout={autoProposal} drawerHeightsMm={config.hardware?.drawerHeightsMm} />
|
||||||
|
<span className={styles.cardName}>
|
||||||
|
Proposition auto <span className={styles.autoBadge}>{autoActive ? 'Active' : 'Auto'}</span>
|
||||||
|
</span>
|
||||||
|
<span className={styles.cardMeta}>
|
||||||
|
Selon {config.widthMm} × {config.heightMm} mm · {autoProposal.modules.length} col.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{loading && <p className={styles.empty}>Chargement…</p>}
|
||||||
|
{presets.filter((p) => p.config).map((preset) => {
|
||||||
|
const cols = (preset.config.modules || []).length;
|
||||||
|
const active = follow?.mode === 'preset' && follow.presetId === preset.id && !follow.fellBack;
|
||||||
|
return (
|
||||||
|
<div key={preset.id} className={`${styles.card} ${active ? styles.cardActive : ''}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cardMain}
|
||||||
|
onClick={() => wc.applyLayoutPreset(preset.config, preset.id)}
|
||||||
|
title={preset.description || preset.name}
|
||||||
|
>
|
||||||
|
<LayoutThumbnail layout={preset.config} drawerHeightsMm={config.hardware?.drawerHeightsMm} />
|
||||||
|
<span className={styles.cardName}>{preset.name}</span>
|
||||||
|
<span className={styles.cardMeta}>
|
||||||
|
{preset.config.widthMm} × {preset.config.heightMm} mm · {cols} col.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cardDelete}
|
||||||
|
onClick={() => handleDelete(preset)}
|
||||||
|
aria-label={`Retirer ${preset.name}`}
|
||||||
|
title="Retirer cette disposition"
|
||||||
|
>
|
||||||
|
<Icon name="x" size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{naming ? (
|
||||||
|
<form className={`${styles.card} ${styles.saveCard}`} onSubmit={handleSave}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={100}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Nom de la disposition"
|
||||||
|
className={styles.saveInput}
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Escape') setNaming(false); }}
|
||||||
|
/>
|
||||||
|
<div className={styles.saveActions}>
|
||||||
|
<button type="submit" className={styles.saveBtn} disabled={saving || !name.trim()}>
|
||||||
|
{saving ? 'Enregistrement…' : 'Enregistrer'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={styles.cancelBtn} onClick={() => setNaming(false)}>Annuler</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button type="button" className={`${styles.card} ${styles.addCard}`} onClick={() => setNaming(true)}>
|
||||||
|
<Icon name="save" size={18} />
|
||||||
|
<span>Enregistrer la disposition actuelle</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
.presets {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.55rem 0.6rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 0.2rem;
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 132px;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--bg);
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--action) 50%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardActive {
|
||||||
|
border-color: var(--action);
|
||||||
|
background: color-mix(in srgb, var(--action) 8%, var(--bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardMain {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.2rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.45rem 0.45rem 0.4rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardName {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autoBadge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.2rem;
|
||||||
|
padding: 0.05rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--action);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardMeta {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardDelete {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .cardDelete,
|
||||||
|
.cardDelete:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardDelete:hover {
|
||||||
|
color: #b91c1c;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 78px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbBody {
|
||||||
|
fill: color-mix(in srgb, var(--bg-card) 80%, #c4a574);
|
||||||
|
stroke: var(--text-muted);
|
||||||
|
stroke-width: 1.4;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbPlinth {
|
||||||
|
fill: var(--text-muted);
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbOpen {
|
||||||
|
fill: var(--bg);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbPanel {
|
||||||
|
stroke: var(--text-muted);
|
||||||
|
stroke-width: 1.4;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbShelf {
|
||||||
|
stroke: var(--text-muted);
|
||||||
|
stroke-width: 1;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbDrawer {
|
||||||
|
fill: var(--bg-card);
|
||||||
|
stroke: var(--text-muted);
|
||||||
|
stroke-width: 0.9;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbHandle {
|
||||||
|
stroke: var(--text-muted);
|
||||||
|
stroke-width: 1.2;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbRod {
|
||||||
|
stroke: var(--action);
|
||||||
|
stroke-width: 1.6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-height: 128px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-style: dashed;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addCard:hover {
|
||||||
|
color: var(--action);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-basis: 170px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveInput {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.3rem 0.4rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBtn,
|
||||||
|
.cancelBtn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.3rem 0.4rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBtn {
|
||||||
|
background: var(--action);
|
||||||
|
border-color: var(--action);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBtn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn {
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import WardrobePreview from '../WardrobePreview';
|
import WardrobePreview from '../WardrobePreview';
|
||||||
import DesignTip from '../DesignTip';
|
import DesignTip from '../DesignTip';
|
||||||
import { resolveShelfPositionsMm, minShelvesForStructuralHeight } from '../../constants/wardrobe';
|
import LayoutPresets from './LayoutPresets';
|
||||||
|
import { resolveShelfPositionsMm, minShelvesForStructuralHeight, moduleStackInfo, moduleHangingZoneMm, ROD_CLEAR_HEIGHT_MM, STRUCTURAL_WINDOW_HALF_MM } from '../../constants/wardrobe';
|
||||||
import styles from './PreviewArea.module.css';
|
import styles from './PreviewArea.module.css';
|
||||||
|
|
||||||
export default function PreviewArea({ wc, ui }) {
|
export default function PreviewArea({ wc, ui }) {
|
||||||
const colorPickEnabled = ui.activeStep === 4 || ui.activeStep === 6;
|
const colorPickEnabled = ui.activeStep === 4 || ui.activeStep === 6;
|
||||||
const infoByStep = {
|
const infoByStep = {
|
||||||
1: 'Survolez les zones colorees du cadre puis glissez pour regler L/H/P.',
|
1: 'Glissez les poignees L/H/P : elles suivent le curseur. Echap annule, F recadre la vue.',
|
||||||
2: 'Tirez les separateurs pour redimensionner et glissez les rayonnages par la tranche avant.',
|
2: 'Tirez les separateurs (aimantes sur largeurs egales, Maj = au mm, Alt = sans aimant, Echap = annuler) et glissez les rayonnages par la tranche avant.',
|
||||||
3: 'Activez ou masquez les portes pour verifier l ouverture.',
|
3: 'Activez ou masquez les portes pour verifier l ouverture.',
|
||||||
4: 'Choisissez un element puis appliquez sa couleur.',
|
4: 'Choisissez un element puis appliquez sa couleur.',
|
||||||
5: 'Verifiez les volumes interieurs pour valider la quincaillerie.',
|
5: 'Verifiez les volumes interieurs pour valider la quincaillerie.',
|
||||||
@@ -24,6 +25,9 @@ export default function PreviewArea({ wc, ui }) {
|
|||||||
const out = [];
|
const out = [];
|
||||||
for (let i = 0; i < mods.length; i++) {
|
for (let i = 0; i < mods.length; i++) {
|
||||||
const mod = mods[i];
|
const mod = mods[i];
|
||||||
|
// Colonne empilée: la jonction bas/haut joue le rôle de rayon structurel.
|
||||||
|
const stack = moduleStackInfo(mod, wc.config);
|
||||||
|
if (stack) continue;
|
||||||
const shelfCount = Math.max(0, Number(mod?.shelves) || 0);
|
const shelfCount = Math.max(0, Number(mod?.shelves) || 0);
|
||||||
const positions = resolveShelfPositionsMm({
|
const positions = resolveShelfPositionsMm({
|
||||||
shelfCount,
|
shelfCount,
|
||||||
@@ -31,14 +35,19 @@ export default function PreviewArea({ wc, ui }) {
|
|||||||
plinthHeightMm: plinthMm,
|
plinthHeightMm: plinthMm,
|
||||||
shelfPositionsMm: mod?.shelfPositionsMm
|
shelfPositionsMm: mod?.shelfPositionsMm
|
||||||
});
|
});
|
||||||
const hasStructural = positions.some((p) => p >= midMm - 200 && p <= midMm + 200);
|
const hasStructural = positions.some((p) => p >= midMm - STRUCTURAL_WINDOW_HALF_MM && p <= midMm + STRUCTURAL_WINDOW_HALF_MM);
|
||||||
if (!hasStructural) out.push(`Colonne ${i + 1}: placer un rayon dans la zone structurelle (±20 cm).`);
|
if (!hasStructural) out.push(`Colonne ${i + 1}: placer un rayon dans la zone structurelle (±20 cm).`);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
})();
|
})();
|
||||||
|
const hangingWarnings = ui.activeStep !== 2 ? [] : (wc.config.modules || []).flatMap((mod, i) => {
|
||||||
|
const zone = moduleHangingZoneMm(mod, wc.config);
|
||||||
|
if (!zone || zone.clearMm >= ROD_CLEAR_HEIGHT_MM.short) return [];
|
||||||
|
return [`Colonne ${i + 1}: penderie de ${zone.clearMm} mm sous tringle (min. ${ROD_CLEAR_HEIGHT_MM.short} mm vestes, ${ROD_CLEAR_HEIGHT_MM.long} mm manteaux). Empilez la colonne ou retirez un rayon.`];
|
||||||
|
});
|
||||||
const handleSetDimension = useCallback((key, valueMm) => {
|
const handleSetDimension = useCallback((key, valueMm) => {
|
||||||
wc.setDimension(key, valueMm);
|
wc.setDimensionWithoutUndo(key, valueMm);
|
||||||
}, [wc.setDimension]);
|
}, [wc.setDimensionWithoutUndo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e) => {
|
const onKeyDown = (e) => {
|
||||||
@@ -115,10 +124,13 @@ export default function PreviewArea({ wc, ui }) {
|
|||||||
colors={wc.config.colors}
|
colors={wc.config.colors}
|
||||||
modules={wc.config.modules}
|
modules={wc.config.modules}
|
||||||
onElementClick={colorPickEnabled ? (elementType, label) => ui.openColorPicker(elementType, label) : undefined}
|
onElementClick={colorPickEnabled ? (elementType, label) => ui.openColorPicker(elementType, label) : undefined}
|
||||||
onModuleClick={ui.handleModuleClick}
|
onModuleClick={ui.activeStep === 2 ? ui.handleModuleClick : undefined}
|
||||||
onUpdateModule={wc.handleColumnUpdate}
|
onUpdateModule={wc.handleColumnUpdateWithoutUndo}
|
||||||
onResizeModules={wc.handleResizeModules}
|
onResizeModules={wc.handleResizeModules}
|
||||||
selectedModuleIndex={ui.expandedColumn}
|
selectedModuleIndex={ui.activeStep === 2 ? ui.expandedColumn : null}
|
||||||
|
selectedBlockIndex={ui.expandedBlock}
|
||||||
|
selectedHorizontalSpanId={ui.activeStep === 2 ? ui.selectedHorizontalSpanId : null}
|
||||||
|
onHorizontalSpanClick={ui.activeStep === 2 ? ui.handleHorizontalSpanSelect : undefined}
|
||||||
onSetDimension={handleSetDimension}
|
onSetDimension={handleSetDimension}
|
||||||
onBeginUndoableGesture={wc.pushUndoSnapshot}
|
onBeginUndoableGesture={wc.pushUndoSnapshot}
|
||||||
canUndo3d={wc.canUndo}
|
canUndo3d={wc.canUndo}
|
||||||
@@ -130,10 +142,11 @@ export default function PreviewArea({ wc, ui }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.infoZone}>
|
<div className={styles.infoZone}>
|
||||||
<div className={styles.infoLine}>{infoByStep[ui.activeStep] || 'Manipulez la vue pour controler la configuration.'}</div>
|
<div className={styles.infoLine}>{infoByStep[ui.activeStep] || 'Manipulez la vue pour controler la configuration.'}</div>
|
||||||
{structuralWarnings.map((msg) => (
|
{[...structuralWarnings, ...hangingWarnings].map((msg) => (
|
||||||
<div key={msg} className={styles.warnLine}>{msg}</div>
|
<div key={msg} className={styles.warnLine}>{msg}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{ui.activeStep === 1 && <LayoutPresets wc={wc} />}
|
||||||
<DesignTip config={wc.config} />
|
<DesignTip config={wc.config} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,47 @@
|
|||||||
import { useState, useRef, useCallback } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
COLUMN_TYPES,
|
COLUMN_TYPES,
|
||||||
maxShelfGapMm,
|
maxShelfGapMm,
|
||||||
SHELF_GAP_MM,
|
SHELF_GAP_MM,
|
||||||
minShelvesForStructuralHeight,
|
minShelvesForStructuralHeight,
|
||||||
|
minShelvesForColumn,
|
||||||
|
moduleStackInfo,
|
||||||
|
moduleBlockHeightsMm,
|
||||||
|
moduleBlockBacks,
|
||||||
|
moduleBlockDoors,
|
||||||
|
moduleBottomBlockMinMm,
|
||||||
|
minBlocksForBody,
|
||||||
|
maxBlocksForBody,
|
||||||
resolveShelfPositionsMm,
|
resolveShelfPositionsMm,
|
||||||
system32ShelfPositionsMm,
|
system32ShelfPositionsMm,
|
||||||
SYSTEM32_PITCH_MM
|
SYSTEM32_PITCH_MM,
|
||||||
|
STRUCTURAL_WINDOW_HALF_MM,
|
||||||
|
STACK_BLOCK_MM
|
||||||
} from '../../constants/wardrobe';
|
} from '../../constants/wardrobe';
|
||||||
|
import {
|
||||||
|
getModuleVerticalBoundariesMm,
|
||||||
|
HORIZONTAL_SPAN_DOOR_MODES,
|
||||||
|
resolveHorizontalSpan,
|
||||||
|
synchronizeModuleSpanBoundaries,
|
||||||
|
validateHorizontalSpans
|
||||||
|
} from '@shared/manufacturing.js';
|
||||||
import s from './steps.module.css';
|
import s from './steps.module.css';
|
||||||
|
|
||||||
|
const HORIZONTAL_SPAN_DOOR_OPTIONS = [
|
||||||
|
{ value: HORIZONTAL_SPAN_DOOR_MODES.none, label: 'Sans porte' },
|
||||||
|
{ value: HORIZONTAL_SPAN_DOOR_MODES.perColumn, label: 'Deux portes, charnières extérieures' },
|
||||||
|
{ value: HORIZONTAL_SPAN_DOOR_MODES.single, label: 'Une porte pleine largeur' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function horizontalSpanDoorLabel(doorMode) {
|
||||||
|
return HORIZONTAL_SPAN_DOOR_OPTIONS.find((option) => option.value === doorMode)?.label
|
||||||
|
|| HORIZONTAL_SPAN_DOOR_OPTIONS[0].label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function horizontalSpanBackLabel(hasBack) {
|
||||||
|
return hasBack ? 'Avec dos' : 'Sans dos';
|
||||||
|
}
|
||||||
|
|
||||||
/** After moving array item from -> to, map a selected index to the new position. */
|
/** After moving array item from -> to, map a selected index to the new position. */
|
||||||
function mapExpandedIndexAfterReorder(expanded, from, to) {
|
function mapExpandedIndexAfterReorder(expanded, from, to) {
|
||||||
if (expanded === null || expanded === undefined) return expanded;
|
if (expanded === null || expanded === undefined) return expanded;
|
||||||
@@ -26,11 +58,19 @@ export default function StepColumns({ wc, ui }) {
|
|||||||
const {
|
const {
|
||||||
config, numColumns,
|
config, numColumns,
|
||||||
handleNumColumnsChange, handleAIOptimize, handleColumnTypeChange,
|
handleNumColumnsChange, handleAIOptimize, handleColumnTypeChange,
|
||||||
handleColumnLock, handleColumnUpdate,
|
handleColumnLock, handleColumnUpdate, setAllBacks, setAllBlockCounts,
|
||||||
aiOptimizing, aiOptimizeMsg, setAiOptimizeMsg, totalModuleWidth,
|
aiOptimizing, aiOptimizeMsg, setAiOptimizeMsg, totalModuleWidth,
|
||||||
reorderModules, pushUndoSnapshot,
|
reorderModules, pushUndoSnapshot,
|
||||||
|
createHorizontalSpan, updateHorizontalSpan, deleteHorizontalSpan,
|
||||||
|
addHorizontalSpanUpperBlock, resizeHorizontalSpanUpperBlockHeight,
|
||||||
|
getHorizontalSpanUpperBlock,
|
||||||
} = wc;
|
} = wc;
|
||||||
const { expandedColumn, setExpandedColumn } = ui;
|
const {
|
||||||
|
expandedColumn, setExpandedColumn, expandedBlock, setExpandedBlock,
|
||||||
|
selectedHorizontalSpanId, setSelectedHorizontalSpanId,
|
||||||
|
horizontalSpanDraft, setHorizontalSpanDraft,
|
||||||
|
horizontalSpanMessage, setHorizontalSpanMessage,
|
||||||
|
} = ui;
|
||||||
|
|
||||||
const [draggingIndex, setDraggingIndex] = useState(null);
|
const [draggingIndex, setDraggingIndex] = useState(null);
|
||||||
const [dragOverIndex, setDragOverIndex] = useState(null);
|
const [dragOverIndex, setDragOverIndex] = useState(null);
|
||||||
@@ -81,60 +121,210 @@ export default function StepColumns({ wc, ui }) {
|
|||||||
suppressCardClickRef.current = false;
|
suppressCardClickRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setSelectedHorizontalSpanId(null);
|
||||||
|
setHorizontalSpanDraft(null);
|
||||||
|
setHorizontalSpanMessage('');
|
||||||
|
setExpandedBlock(0);
|
||||||
setExpandedColumn(isActive ? null : i);
|
setExpandedColumn(isActive ? null : i);
|
||||||
}, [setExpandedColumn]);
|
}, [
|
||||||
|
setExpandedBlock,
|
||||||
|
setExpandedColumn,
|
||||||
|
setHorizontalSpanDraft,
|
||||||
|
setHorizontalSpanMessage,
|
||||||
|
setSelectedHorizontalSpanId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// When a block is selected (3D click or card), bring its options into view.
|
||||||
|
const detailRef = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (expandedColumn === null) return;
|
||||||
|
detailRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}, [expandedColumn]);
|
||||||
|
|
||||||
const minShelvesStructural = minShelvesForStructuralHeight(config.heightMm);
|
const minShelvesStructural = minShelvesForStructuralHeight(config.heightMm);
|
||||||
const hasTallCabinet = minShelvesStructural > 0;
|
const hasTallCabinet = minShelvesStructural > 0;
|
||||||
const hw = config.hardware || {};
|
const hw = config.hardware || {};
|
||||||
const drawerDepth = config.effectiveDrawerDepthMm || Math.max(400, Math.min(650, config.depthMm - 10));
|
const drawerDepthBase = Math.max(
|
||||||
|
350,
|
||||||
|
Math.round(Math.max(400, Math.min(650, config.depthMm - 10)) / 50) * 50
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Empilage PAR COLONNE — modèle N blocks: mod.blockHeightsMm / mod.blockBacks. */
|
||||||
|
const modHeights = (m) => moduleBlockHeightsMm(m, config);
|
||||||
|
const modBacks = (m) => moduleBlockBacks(m, config);
|
||||||
|
const backStates = config.modules.map((m) => modBacks(m).every(Boolean));
|
||||||
|
const noBackStates = config.modules.map((m) => modBacks(m).every((b) => !b));
|
||||||
|
const allWithBack = backStates.length > 0 && backStates.every(Boolean);
|
||||||
|
const noneWithBack = noBackStates.every(Boolean);
|
||||||
|
const mixedBack = !allWithBack && !noneWithBack;
|
||||||
|
|
||||||
|
/** Nombre de colonnes non limité: seule la largeur borne (min. 300 mm par colonne). */
|
||||||
|
const maxColumns = Math.max(1, Math.floor((config.widthMm || 2400) / 300));
|
||||||
|
|
||||||
|
/** Empilage vertical: bornes (caisse hors plinthe). Hauteur > panneau max → blocks imposés. */
|
||||||
|
const bodyHmm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 0);
|
||||||
|
const stackMinMm = STACK_BLOCK_MM.min;
|
||||||
|
const minBlocks = minBlocksForBody(bodyHmm);
|
||||||
|
const maxBlocks = maxBlocksForBody(bodyHmm);
|
||||||
|
const blockCounts = config.modules.map((m) => modHeights(m).length);
|
||||||
|
const anyStacked = blockCounts.some((n) => n >= 2);
|
||||||
|
const allSameCount = blockCounts.length > 0 && blockCounts.every((n) => n === blockCounts[0]);
|
||||||
|
const mixedStacked = anyStacked && !(allSameCount && blockCounts[0] >= 2);
|
||||||
|
const horizontalSpans = Array.isArray(config.horizontalSpans) ? config.horizontalSpans : [];
|
||||||
|
const resolvedSpans = horizontalSpans
|
||||||
|
.map((span) => resolveHorizontalSpan(span, config))
|
||||||
|
.filter(Boolean);
|
||||||
|
const selectedSpan = horizontalSpans.find(
|
||||||
|
(span) => String(span.id) === String(selectedHorizontalSpanId)
|
||||||
|
) || null;
|
||||||
|
const selectedResolvedSpan = selectedSpan ? resolveHorizontalSpan(selectedSpan, config) : null;
|
||||||
|
const selectedUpperBlock = selectedSpan
|
||||||
|
? getHorizontalSpanUpperBlock(selectedSpan.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const beginHorizontalSpan = (moduleIndex, blockIndex) => {
|
||||||
|
if (config.modules.length < 2) {
|
||||||
|
setHorizontalSpanMessage('Ajoutez au moins deux colonnes pour créer un block horizontal.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedHorizontalSpanId(null);
|
||||||
|
setHorizontalSpanMessage('');
|
||||||
|
setHorizontalSpanDraft({ startModuleIndex: moduleIndex, startBlockIndex: blockIndex, endModuleIndex: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelHorizontalSpan = () => {
|
||||||
|
setHorizontalSpanDraft(null);
|
||||||
|
setHorizontalSpanMessage('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmHorizontalSpan = () => {
|
||||||
|
const startIndex = Number(horizontalSpanDraft?.startModuleIndex);
|
||||||
|
const blockIndex = Number(horizontalSpanDraft?.startBlockIndex);
|
||||||
|
const endIndex = Number(horizontalSpanDraft?.endModuleIndex);
|
||||||
|
const startModule = config.modules[startIndex];
|
||||||
|
if (!startModule || !Number.isInteger(endIndex) || endIndex === startIndex || !config.modules[endIndex]) {
|
||||||
|
setHorizontalSpanMessage('Choisissez une colonne de fin adjacente (au moins deux colonnes au total).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundaries = getModuleVerticalBoundariesMm(startModule, config);
|
||||||
|
const bottomMm = boundaries[blockIndex];
|
||||||
|
const topMm = boundaries[blockIndex + 1];
|
||||||
|
if (!Number.isFinite(bottomMm) || !Number.isFinite(topMm)) {
|
||||||
|
setHorizontalSpanMessage('Le block de départ sélectionné n’existe plus.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstIndex = Math.min(startIndex, endIndex);
|
||||||
|
const lastIndex = Math.max(startIndex, endIndex);
|
||||||
|
const includedModules = config.modules.slice(firstIndex, lastIndex + 1);
|
||||||
|
const moduleIds = includedModules.map((module) => module.id);
|
||||||
|
if (bottomMm === 0) {
|
||||||
|
const drawerIndex = includedModules.findIndex((module) => (Number(module.drawers) || 0) > 0);
|
||||||
|
if (drawerIndex >= 0) {
|
||||||
|
setHorizontalSpanMessage(
|
||||||
|
`Impossible : retirez d’abord les tiroirs de la colonne ${firstIndex + drawerIndex + 1}, car ils occupent le block bas.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview the synchronized model, then turn off every door band covered
|
||||||
|
// by the horizontal opening before validating the candidate.
|
||||||
|
const synchronized = synchronizeModuleSpanBoundaries(config, moduleIds, bottomMm, topMm);
|
||||||
|
const moduleIdSet = new Set(moduleIds.map(String));
|
||||||
|
const modulesWithOpenBands = synchronized.modules.map((module) => {
|
||||||
|
if (!moduleIdSet.has(String(module.id))) return module;
|
||||||
|
const edges = getModuleVerticalBoundariesMm(module, synchronized);
|
||||||
|
const doors = moduleBlockDoors(module, synchronized, edges.length - 1);
|
||||||
|
return {
|
||||||
|
...module,
|
||||||
|
blockDoors: doors.map((hasDoor, index) => (
|
||||||
|
edges[index] >= bottomMm - 0.01 && edges[index + 1] <= topMm + 0.01 ? false : hasDoor
|
||||||
|
))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const draftSpan = {
|
||||||
|
id: '__horizontal-span-preview__',
|
||||||
|
moduleIds,
|
||||||
|
bottomMm,
|
||||||
|
topMm,
|
||||||
|
hasBack: Boolean(moduleBlockBacks(startModule, config)[blockIndex]),
|
||||||
|
doorMode: HORIZONTAL_SPAN_DOOR_MODES.none,
|
||||||
|
type: 'open'
|
||||||
|
};
|
||||||
|
const candidate = {
|
||||||
|
...synchronized,
|
||||||
|
modules: modulesWithOpenBands,
|
||||||
|
horizontalSpans: [...horizontalSpans, draftSpan]
|
||||||
|
};
|
||||||
|
const issue = validateHorizontalSpans(candidate).find((item) => item.spanId === draftSpan.id);
|
||||||
|
if (issue) {
|
||||||
|
setHorizontalSpanMessage(issue.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spanId = createHorizontalSpan({ ...draftSpan, id: undefined });
|
||||||
|
setSelectedHorizontalSpanId(spanId);
|
||||||
|
setHorizontalSpanDraft(null);
|
||||||
|
setHorizontalSpanMessage('Block horizontal créé sans porte. Configurez ses portes dans les paramètres du block horizontal.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectHorizontalSpan = (spanId) => {
|
||||||
|
ui.handleHorizontalSpanSelect?.(spanId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeHorizontalSpan = (spanId) => {
|
||||||
|
if (!deleteHorizontalSpan(spanId)) return;
|
||||||
|
setSelectedHorizontalSpanId(null);
|
||||||
|
setHorizontalSpanMessage('Block horizontal supprimé. Les découpes de colonnes sont conservées.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const addUpperBlock = () => {
|
||||||
|
const result = addHorizontalSpanUpperBlock(selectedSpan?.id);
|
||||||
|
if (!result?.ok) {
|
||||||
|
setHorizontalSpanMessage(result?.message || 'Impossible d’ajouter le block supérieur.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHorizontalSpanMessage('Block supérieur de 300 mm ajouté et synchronisé sur toutes les colonnes couvertes.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeUpperBlock = (heightMm) => {
|
||||||
|
const result = resizeHorizontalSpanUpperBlockHeight(selectedSpan?.id, heightMm);
|
||||||
|
if (!result?.ok) {
|
||||||
|
setHorizontalSpanMessage(result?.message || 'Impossible de redimensionner le block supérieur.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHorizontalSpanMessage('');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.stepSection}>
|
<div className={s.stepSection}>
|
||||||
<h2 className={s.stepTitle}>Configuration intérieure</h2>
|
<h2 className={s.stepTitle}>Configuration intérieure</h2>
|
||||||
<p className={s.stepDesc}>
|
<p className={s.stepDesc}>
|
||||||
Choisissez le nombre de colonnes et personnalisez chacune. Cliquez sur un module dans la vue 3D pour le sélectionner.
|
Cliquez sur un block dans la vue 3D (façade, intérieur ou dos) pour ouvrir ses options ici.
|
||||||
{canReorderColumns && (
|
{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>
|
</p>
|
||||||
|
|
||||||
<div className={s.hardwareGroup}>
|
{/* Column count — non limité, borné par la largeur (min 300 mm par colonne) */}
|
||||||
<label className={s.hardwareLabel}>Choix colonne: séparations ou block</label>
|
|
||||||
<div className={s.colCountBtns}>
|
|
||||||
{[
|
|
||||||
{ key: 'separations', label: 'Séparations' },
|
|
||||||
{ key: 'block', label: 'Block (x caisses)' }
|
|
||||||
].map((m) => (
|
|
||||||
<button
|
|
||||||
key={m.key}
|
|
||||||
type="button"
|
|
||||||
className={`${s.colCountBtn} ${config.columnMode === m.key ? s.colCountActive : ''}`}
|
|
||||||
onClick={() => wc.set('columnMode', m.key)}
|
|
||||||
>
|
|
||||||
{m.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{config.columnMode === 'block' && (
|
|
||||||
<p className={s.hint}>Assemblage block: vis de jonction + perçages 3 milieux et +100/-100 mm (30 mm face/dos).</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Column count */}
|
|
||||||
<div className={s.colCountRow}>
|
<div className={s.colCountRow}>
|
||||||
<span className={s.colCountLabel}>Nombre de colonnes</span>
|
<span className={s.colCountLabel}>Nombre de colonnes</span>
|
||||||
<div className={s.colCountBtns}>
|
<div className={s.counter}>
|
||||||
{[1, 2, 3, 4, 5, 6].map(n => (
|
|
||||||
<button
|
<button
|
||||||
key={n}
|
|
||||||
type="button"
|
type="button"
|
||||||
className={`${s.colCountBtn} ${numColumns === n ? s.colCountActive : ''}`}
|
className={s.counterBtn}
|
||||||
onClick={() => handleNumColumnsChange(n)}
|
onClick={() => handleNumColumnsChange(Math.max(1, numColumns - 1))}
|
||||||
>
|
disabled={numColumns <= 1}
|
||||||
{n}
|
>−</button>
|
||||||
</button>
|
<span className={s.counterValue}>{numColumns}</span>
|
||||||
))}
|
<button
|
||||||
|
type="button"
|
||||||
|
className={s.counterBtn}
|
||||||
|
onClick={() => handleNumColumnsChange(Math.min(maxColumns, numColumns + 1))}
|
||||||
|
disabled={numColumns >= maxColumns}
|
||||||
|
>+</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -145,6 +335,9 @@ export default function StepColumns({ wc, ui }) {
|
|||||||
{aiOptimizing ? '...' : '\u2728 Optimiser'}
|
{aiOptimizing ? '...' : '\u2728 Optimiser'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{numColumns >= maxColumns && (
|
||||||
|
<p className={s.hint}>Maximum {maxColumns} colonnes pour {config.widthMm} mm de largeur (min. 300 mm par colonne).</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{aiOptimizeMsg && (
|
{aiOptimizeMsg && (
|
||||||
<div className={s.aiMsg}>
|
<div className={s.aiMsg}>
|
||||||
@@ -187,27 +380,288 @@ export default function StepColumns({ wc, ui }) {
|
|||||||
<span className={s.colCardIcon}>{typeDef.icon}</span>
|
<span className={s.colCardIcon}>{typeDef.icon}</span>
|
||||||
<span className={s.colCardType}>{typeDef.label}</span>
|
<span className={s.colCardType}>{typeDef.label}</span>
|
||||||
<span className={s.colCardWidth}>{mod.widthMm} mm</span>
|
<span className={s.colCardWidth}>{mod.widthMm} mm</span>
|
||||||
|
<span className={s.colCardWidth}>
|
||||||
|
{(() => {
|
||||||
|
const backs = modBacks(mod);
|
||||||
|
const n = backs.length;
|
||||||
|
const withBack = backs.filter(Boolean).length;
|
||||||
|
const dosTxt = withBack === n ? 'avec dos' : withBack === 0 ? 'sans dos' : `dos ${withBack}/${n}`;
|
||||||
|
return n >= 2 ? `${n} blocks \u00b7 ${dosTxt}` : dosTxt;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
{resolvedSpans
|
||||||
|
.filter((resolved) => resolved.moduleIndexes.includes(i))
|
||||||
|
.map((resolved) => (
|
||||||
|
<span
|
||||||
|
key={resolved.span.id}
|
||||||
|
className={`${s.spanMembership} ${String(selectedHorizontalSpanId) === String(resolved.span.id) ? s.spanMembershipActive : ''}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
selectHorizontalSpan(resolved.span.id);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
selectHorizontalSpan(resolved.span.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Horizontal {resolved.bottomMm}–{resolved.topMm} · {horizontalSpanBackLabel(resolved.span.hasBack)} · {horizontalSpanDoorLabel(resolved.span.doorMode)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{horizontalSpans.length > 0 && (
|
||||||
|
<div className={s.spanPanel}>
|
||||||
|
<div className={s.spanPanelTitle}>Blocks horizontaux</div>
|
||||||
|
<div className={s.spanList}>
|
||||||
|
{resolvedSpans.map((resolved, index) => (
|
||||||
|
<button
|
||||||
|
key={resolved.span.id}
|
||||||
|
type="button"
|
||||||
|
className={`${s.spanListItem} ${String(selectedHorizontalSpanId) === String(resolved.span.id) ? s.spanListItemActive : ''}`}
|
||||||
|
onClick={() => selectHorizontalSpan(resolved.span.id)}
|
||||||
|
>
|
||||||
|
Horizontal {index + 1} · colonnes {resolved.moduleIndexes[0] + 1} à {resolved.moduleIndexes[resolved.moduleIndexes.length - 1] + 1}
|
||||||
|
<small>
|
||||||
|
{resolved.bottomMm}–{resolved.topMm} mm · {horizontalSpanBackLabel(resolved.span.hasBack)} · {horizontalSpanDoorLabel(resolved.span.doorMode)}
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selectedSpan && selectedResolvedSpan && (
|
||||||
|
<div className={s.spanEditor}>
|
||||||
|
<span>
|
||||||
|
Colonnes {selectedResolvedSpan.moduleIndexes[0] + 1} à {selectedResolvedSpan.moduleIndexes[selectedResolvedSpan.moduleIndexes.length - 1] + 1}
|
||||||
|
</span>
|
||||||
|
<fieldset className={s.spanDoorChoices}>
|
||||||
|
<legend>Dos du block horizontal</legend>
|
||||||
|
{[false, true].map((hasBack) => (
|
||||||
|
<button
|
||||||
|
key={String(hasBack)}
|
||||||
|
type="button"
|
||||||
|
className={`${s.spanDoorChoice} ${selectedSpan.hasBack === hasBack ? s.spanDoorChoiceActive : ''}`}
|
||||||
|
aria-pressed={selectedSpan.hasBack === hasBack}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedSpan.hasBack !== hasBack) {
|
||||||
|
updateHorizontalSpan(selectedSpan.id, { hasBack });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{horizontalSpanBackLabel(hasBack)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
<fieldset className={s.spanDoorChoices}>
|
||||||
|
<legend>Portes du block horizontal</legend>
|
||||||
|
{(() => {
|
||||||
|
const activeDoorMode = selectedSpan.doorMode ?? HORIZONTAL_SPAN_DOOR_MODES.none;
|
||||||
|
const hasDoor = activeDoorMode !== HORIZONTAL_SPAN_DOOR_MODES.none;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${s.spanDoorChoice} ${!hasDoor ? s.spanDoorChoiceActive : ''}`}
|
||||||
|
aria-pressed={!hasDoor}
|
||||||
|
onClick={() => {
|
||||||
|
if (hasDoor) {
|
||||||
|
updateHorizontalSpan(selectedSpan.id, { doorMode: HORIZONTAL_SPAN_DOOR_MODES.none });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sans porte
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${s.spanDoorChoice} ${hasDoor ? s.spanDoorChoiceActive : ''}`}
|
||||||
|
aria-pressed={hasDoor}
|
||||||
|
onClick={() => {
|
||||||
|
if (!hasDoor) {
|
||||||
|
updateHorizontalSpan(selectedSpan.id, { doorMode: HORIZONTAL_SPAN_DOOR_MODES.perColumn });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Avec porte
|
||||||
|
</button>
|
||||||
|
{hasDoor && (
|
||||||
|
<>
|
||||||
|
<small>Configuration des portes</small>
|
||||||
|
{HORIZONTAL_SPAN_DOOR_OPTIONS.slice(1).map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`${s.spanDoorChoice} ${activeDoorMode === option.value ? s.spanDoorChoiceActive : ''}`}
|
||||||
|
aria-pressed={activeDoorMode === option.value}
|
||||||
|
onClick={() => {
|
||||||
|
if (activeDoorMode !== option.value) {
|
||||||
|
updateHorizontalSpan(selectedSpan.id, { doorMode: option.value });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<small>
|
||||||
|
Ces portes appartiennent au block horizontal. Les portes individuelles des blocks couverts restent désactivées.
|
||||||
|
</small>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</fieldset>
|
||||||
|
{selectedUpperBlock?.ok && selectedUpperBlock.exists ? (
|
||||||
|
<div className={s.detailRow}>
|
||||||
|
<span className={s.detailLabel}>Block supérieur synchronisé</span>
|
||||||
|
<div className={s.dimSliderRow} style={{ flex: 1 }}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className={s.gapRange}
|
||||||
|
min={selectedUpperBlock.minHeightMm}
|
||||||
|
max={selectedUpperBlock.maxHeightMm}
|
||||||
|
step={selectedUpperBlock.stepMm}
|
||||||
|
value={selectedUpperBlock.heightMm}
|
||||||
|
onChange={(event) => resizeUpperBlock(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
<span className={s.counterValue}>{selectedUpperBlock.heightMm} mm</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={s.optimizeBtn}
|
||||||
|
onClick={addUpperBlock}
|
||||||
|
disabled={!selectedUpperBlock?.ok || !selectedUpperBlock.canAdd}
|
||||||
|
title={!selectedUpperBlock?.canAdd
|
||||||
|
? 'Le block horizontal doit mesurer au moins 600 mm.'
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
Ajouter un block au-dessus (300 mm)
|
||||||
|
</button>
|
||||||
|
{!selectedUpperBlock?.ok && (
|
||||||
|
<small className={s.warnText}>{selectedUpperBlock?.message}</small>
|
||||||
|
)}
|
||||||
|
{selectedUpperBlock?.ok && !selectedUpperBlock.canAdd && (
|
||||||
|
<small className={s.hint}>
|
||||||
|
Hauteur insuffisante : conservez au moins 300 mm pour le block horizontal et 300 mm pour le nouveau block.
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<small className={s.hint}>
|
||||||
|
Cette limite est commune à toutes les colonnes couvertes. Sa hauteur se règle ici par pas de 50 mm.
|
||||||
|
</small>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={s.spanDeleteBtn}
|
||||||
|
onClick={() => removeHorizontalSpan(selectedSpan.id)}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{horizontalSpanMessage && (
|
||||||
|
<div className={horizontalSpanMessage.startsWith('Block ') ? s.spanSuccess : s.warnText}>
|
||||||
|
{horizontalSpanMessage}
|
||||||
|
<button type="button" className={s.aiDismiss} onClick={() => setHorizontalSpanMessage('')}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Detail tray for expanded column */}
|
{/* Detail tray for expanded column */}
|
||||||
{expandedColumn !== null && config.modules[expandedColumn] && (() => {
|
{expandedColumn !== null && config.modules[expandedColumn] && (() => {
|
||||||
const i = expandedColumn;
|
const i = expandedColumn;
|
||||||
const mod = config.modules[i];
|
const mod = config.modules[i];
|
||||||
const typeDef = COLUMN_TYPES.find(t => t.key === mod.type) || COLUMN_TYPES[0];
|
const typeDef = COLUMN_TYPES.find(t => t.key === mod.type) || COLUMN_TYPES[0];
|
||||||
|
const colHeights = modHeights(mod);
|
||||||
|
const colBacks = modBacks(mod);
|
||||||
|
const colDoors = moduleBlockDoors(mod, config, colHeights.length);
|
||||||
|
const colCount = colHeights.length;
|
||||||
|
const drawerType = ['hidden', 'hidden_with_handle'].includes(mod.drawerType)
|
||||||
|
? 'hidden'
|
||||||
|
: mod.drawerType === 'visible'
|
||||||
|
? 'visible'
|
||||||
|
: ['hidden', 'hidden_with_handle'].includes(hw.drawerType)
|
||||||
|
? 'hidden'
|
||||||
|
: 'visible';
|
||||||
|
const drawerDepth = Math.max(350, drawerDepthBase - (drawerType === 'hidden' ? 40 : 0));
|
||||||
|
const bottomBlockMinMm = moduleBottomBlockMinMm(mod, config);
|
||||||
|
const colMaxBlocks = Math.max(
|
||||||
|
minBlocks,
|
||||||
|
Math.min(maxBlocks, 1 + Math.floor((bodyHmm - bottomBlockMinMm) / stackMinMm))
|
||||||
|
);
|
||||||
|
const modStackedHere = colCount >= 2;
|
||||||
|
const activeBlockIndex = Math.min(Math.max(0, expandedBlock || 0), colCount - 1);
|
||||||
|
const columnResolvedSpans = resolvedSpans.filter((resolved) =>
|
||||||
|
resolved.moduleIndexes.includes(i)
|
||||||
|
);
|
||||||
|
const activeResolvedSpan = columnResolvedSpans.find((resolved) =>
|
||||||
|
resolved.coveredBlocks.some(
|
||||||
|
(covered) =>
|
||||||
|
covered.moduleIndex === i &&
|
||||||
|
covered.blockIndexes.includes(activeBlockIndex)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const columnHasBottomSpan = columnResolvedSpans.some(
|
||||||
|
(resolved) => resolved.bottomMm <= 0.5
|
||||||
|
);
|
||||||
|
const modStackInfo = moduleStackInfo(mod, config);
|
||||||
|
const blockLabel = (k) =>
|
||||||
|
colCount <= 1
|
||||||
|
? ''
|
||||||
|
: k === 0
|
||||||
|
? `Block 1 (bas)`
|
||||||
|
: k === colCount - 1
|
||||||
|
? `Block ${colCount} (haut)`
|
||||||
|
: `Block ${k + 1}`;
|
||||||
|
const setBlockCount = (n) => {
|
||||||
|
const count = Math.min(colMaxBlocks, Math.max(minBlocks, n));
|
||||||
|
const even = bodyHmm / count;
|
||||||
|
setExpandedBlock((current) => Math.min(current, count - 1));
|
||||||
|
handleColumnUpdate(i, {
|
||||||
|
blockHeightsMm: count <= 1 ? [bodyHmm] : Array.from({ length: count }, () => even)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const setBlockHeight = (k, valueMm) => {
|
||||||
|
const next = colHeights.slice();
|
||||||
|
next[k] = Number(valueMm);
|
||||||
|
handleColumnUpdate(i, { blockHeightsMm: next });
|
||||||
|
};
|
||||||
|
const setBlockBack = (k, value) => {
|
||||||
|
const next = colBacks.slice();
|
||||||
|
next[k] = value;
|
||||||
|
handleColumnUpdate(i, {
|
||||||
|
blockBacks: next,
|
||||||
|
hasBack: next[0],
|
||||||
|
hasBackTop: next[next.length - 1]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const setBlockDoor = (k, value) => {
|
||||||
|
const next = colDoors.slice();
|
||||||
|
next[k] = value;
|
||||||
|
handleColumnUpdate(i, { blockDoors: next });
|
||||||
|
};
|
||||||
const shelfN = mod.shelves || 0;
|
const shelfN = mod.shelves || 0;
|
||||||
const shelfPositions = resolveShelfPositionsMm({
|
const shelfPositions = resolveShelfPositionsMm({
|
||||||
shelfCount: shelfN,
|
shelfCount: shelfN,
|
||||||
heightMm: config.heightMm,
|
heightMm: config.heightMm,
|
||||||
plinthHeightMm: config.plinthHeightMm ?? 100,
|
plinthHeightMm: config.plinthHeightMm ?? 100,
|
||||||
shelfPositionsMm: mod.shelfPositionsMm
|
shelfPositionsMm: mod.shelfPositionsMm,
|
||||||
|
stack: modStackInfo
|
||||||
});
|
});
|
||||||
const minShelves = minShelvesStructural;
|
// Colonne empilée: la jonction est structurelle → pas de rayon minimum imposé.
|
||||||
const bodyHmm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 0);
|
const minShelves = minShelvesForColumn(config.heightMm, mod, config);
|
||||||
const bodyMidMm = bodyHmm / 2;
|
const bodyMidMm = bodyHmm / 2;
|
||||||
const shelfSlots = system32ShelfPositionsMm(config.heightMm, config.plinthHeightMm ?? 100);
|
const shelfSlots = system32ShelfPositionsMm(config.heightMm, config.plinthHeightMm ?? 100, modStackInfo);
|
||||||
const structuralIdx =
|
const structuralIdx =
|
||||||
minShelves > 0 && shelfPositions.length > 0
|
minShelves > 0 && shelfPositions.length > 0
|
||||||
? shelfPositions.reduce((bestIdx, posMm, idx, arr) => {
|
? shelfPositions.reduce((bestIdx, posMm, idx, arr) => {
|
||||||
@@ -218,7 +672,7 @@ export default function StepColumns({ wc, ui }) {
|
|||||||
: null;
|
: null;
|
||||||
const hasShelfInStructuralWindow =
|
const hasShelfInStructuralWindow =
|
||||||
minShelves <= 0 ||
|
minShelves <= 0 ||
|
||||||
shelfPositions.some((p) => p >= bodyMidMm - 200 && p <= bodyMidMm + 200);
|
shelfPositions.some((p) => p >= bodyMidMm - STRUCTURAL_WINDOW_HALF_MM && p <= bodyMidMm + STRUCTURAL_WINDOW_HALF_MM);
|
||||||
const gapMax = Math.min(
|
const gapMax = Math.min(
|
||||||
SHELF_GAP_MM.max,
|
SHELF_GAP_MM.max,
|
||||||
maxShelfGapMm(config.heightMm, config.plinthHeightMm ?? 100, shelfN)
|
maxShelfGapMm(config.heightMm, config.plinthHeightMm ?? 100, shelfN)
|
||||||
@@ -232,9 +686,13 @@ export default function StepColumns({ wc, ui }) {
|
|||||||
handleColumnUpdate(i, { shelfPositionsMm: next });
|
handleColumnUpdate(i, { shelfPositionsMm: next });
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className={s.colDetail}>
|
<div className={s.colDetail} ref={detailRef}>
|
||||||
<div className={s.colDetailHeader}>
|
<div className={s.colDetailHeader}>
|
||||||
<span className={s.colDetailTitle}>Colonne {i + 1} — {typeDef.icon} {typeDef.label}</span>
|
<span className={s.colDetailTitle}>
|
||||||
|
Colonne {i + 1} — {typeDef.icon} {typeDef.label}
|
||||||
|
{modStackedHere && ` · ${blockLabel(activeBlockIndex)}`}
|
||||||
|
</span>
|
||||||
|
<div className={s.colDetailHeaderBtns}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${s.lockBtn} ${mod.locked ? s.isLocked : ''}`}
|
className={`${s.lockBtn} ${mod.locked ? s.isLocked : ''}`}
|
||||||
@@ -242,6 +700,15 @@ export default function StepColumns({ wc, ui }) {
|
|||||||
>
|
>
|
||||||
{mod.locked ? '\u{1F512} Verrouill\u00e9e' : '\u{1F513} Verrouiller'}
|
{mod.locked ? '\u{1F512} Verrouill\u00e9e' : '\u{1F513} Verrouiller'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={s.lockBtn}
|
||||||
|
onClick={() => setExpandedColumn(null)}
|
||||||
|
title="Fermer les options de ce block"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Type tiles */}
|
{/* Type tiles */}
|
||||||
@@ -277,6 +744,11 @@ export default function StepColumns({ wc, ui }) {
|
|||||||
>+</button>
|
>+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{columnResolvedSpans.length > 0 && (
|
||||||
|
<p className={s.hint} style={{ marginTop: 0 }}>
|
||||||
|
La colonne reste modifiable. Les limites couvertes communes se règlent dans l’éditeur du block horizontal.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{minShelves >= 1 && (mod.shelves || 0) <= minShelves && (
|
{minShelves >= 1 && (mod.shelves || 0) <= minShelves && (
|
||||||
<p className={s.hint}>
|
<p className={s.hint}>
|
||||||
Hauteur > 2 m : au moins 1 étagère par colonne (rigidité, assemblage RAZENTZAB). Le compteur ne peut pas descendre en dessous.
|
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"
|
type="button"
|
||||||
className={s.counterBtn}
|
className={s.counterBtn}
|
||||||
onClick={() => handleColumnUpdate(i, { drawers: Math.min(6, (mod.drawers || 0) + 1) })}
|
onClick={() => handleColumnUpdate(i, { drawers: Math.min(6, (mod.drawers || 0) + 1) })}
|
||||||
disabled={(mod.drawers || 0) >= 6}
|
disabled={(mod.drawers || 0) >= 6 || columnHasBottomSpan}
|
||||||
|
title={columnHasBottomSpan ? 'Un block horizontal occupe le block bas de cette colonne.' : undefined}
|
||||||
>+</button>
|
>+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -396,8 +869,8 @@ export default function StepColumns({ wc, ui }) {
|
|||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
type="button"
|
type="button"
|
||||||
className={`${s.colCountBtn} ${hw.drawerType === item.key ? s.colCountActive : ''}`}
|
className={`${s.colCountBtn} ${drawerType === item.key ? s.colCountActive : ''}`}
|
||||||
onClick={() => wc.setHardware('drawerType', item.key)}
|
onClick={() => handleColumnUpdate(i, { drawerType: item.key })}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -405,7 +878,7 @@ export default function StepColumns({ wc, ui }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className={s.hint} style={{ marginTop: 0 }}>
|
<p className={s.hint} style={{ marginTop: 0 }}>
|
||||||
Manufacture depuis le bas. Profondeur utile: {drawerDepth} mm (réglage global).
|
Manufacture depuis le bas. Profondeur utile: {drawerDepth} mm pour ce block.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -421,14 +894,287 @@ export default function StepColumns({ wc, ui }) {
|
|||||||
{mod.hasRod ? 'Oui' : 'Non'}
|
{mod.hasRod ? 'Oui' : 'Non'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Empilage vertical (par colonne): nombre de blocks en hauteur */}
|
||||||
|
<div className={s.detailRow}>
|
||||||
|
<span className={s.detailLabel}>Blocks en hauteur</span>
|
||||||
|
<div className={s.counter}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={s.counterBtn}
|
||||||
|
onClick={() => setBlockCount(colCount - 1)}
|
||||||
|
disabled={colCount <= minBlocks || columnResolvedSpans.length > 0}
|
||||||
|
title={minBlocks >= 2 && colCount <= minBlocks ? `Hauteur > ${STACK_BLOCK_MM.max} mm : min. ${minBlocks} blocks` : undefined}
|
||||||
|
>−</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>
|
</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}>
|
<p className={s.summaryLine}>
|
||||||
{config.modules.length} colonne{config.modules.length > 1 ? 's' : ''} — Largeur totale : {totalModuleWidth} mm
|
{config.modules.length} colonne{config.modules.length > 1 ? 's' : ''} — Largeur totale : {totalModuleWidth} mm
|
||||||
</p>
|
</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) && (
|
{config.modules.some((m) => (m.drawers || 0) > 0 && (m.widthMm || 0) > 1000) && (
|
||||||
<p className={s.warnText}>Une colonne avec tiroirs dépasse 1000 mm. Limite max appliquée.</p>
|
<p className={s.warnText}>Une colonne avec tiroirs dépasse 1000 mm. Limite max appliquée.</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import s from './steps.module.css';
|
import s from './steps.module.css';
|
||||||
|
import {
|
||||||
|
AXILO_TARGET_HEIGHTS_MM,
|
||||||
|
DIMENSION_RANGES,
|
||||||
|
outerColumnStacked,
|
||||||
|
moduleBlockBacks
|
||||||
|
} from '../../constants/wardrobe.js';
|
||||||
|
|
||||||
const DIMS = [
|
const DIMS = [
|
||||||
{ key: 'widthMm', label: 'Largeur totale', min: 400, max: 5000, step: 50, unit: 'mm' },
|
{ key: 'widthMm', label: 'Largeur totale', ...DIMENSION_RANGES.widthMm, unit: 'mm' },
|
||||||
{ key: 'heightMm', label: 'Hauteur', min: 1800, max: 2800, step: 50, unit: 'mm' },
|
{ key: 'heightMm', label: 'Hauteur', ...DIMENSION_RANGES.heightMm, unit: 'mm' },
|
||||||
{ key: 'depthMm', label: 'Profondeur', min: 400, max: 800, step: 10, unit: 'mm' },
|
{ key: 'depthMm', label: 'Profondeur', ...DIMENSION_RANGES.depthMm, unit: 'mm' },
|
||||||
{ key: 'plinthHeightMm', label: 'Plinthe', min: 60, max: 160, step: 10, unit: 'mm' },
|
{ key: 'plinthHeightMm', label: 'Plinthe', ...DIMENSION_RANGES.plinthHeightMm, unit: 'mm' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function StepDimensions({ wc }) {
|
export default function StepDimensions({ wc }) {
|
||||||
@@ -15,13 +21,26 @@ export default function StepDimensions({ wc }) {
|
|||||||
const sideRightEnabled = config.sideFinishRightEnabled ?? sideDefaultsEnabled;
|
const sideRightEnabled = config.sideFinishRightEnabled ?? sideDefaultsEnabled;
|
||||||
const topFinishEnabled = config.topFinishEnabled !== false;
|
const topFinishEnabled = config.topFinishEnabled !== false;
|
||||||
|
|
||||||
|
// Habillages obligatoires:
|
||||||
|
// - colonne d'extrémité empilée → habillage de ce côté seulement
|
||||||
|
// - une zone du dessus (niveau haut si colonne empilée, sinon zone unique) sans dos → dessus de doublage
|
||||||
|
const moduleCount = (config.modules || []).length;
|
||||||
|
const sideLeftDoublageMandatory = outerColumnStacked(config.modules, config, 'left');
|
||||||
|
const sideRightDoublageMandatory = outerColumnStacked(config.modules, config, 'right');
|
||||||
|
const topDoublageMandatory =
|
||||||
|
moduleCount > 0 &&
|
||||||
|
(config.modules || []).some((m) => {
|
||||||
|
const backs = moduleBlockBacks(m, config);
|
||||||
|
return !backs[backs.length - 1];
|
||||||
|
});
|
||||||
|
|
||||||
const minNicheWidth = Math.min(niche.widthTopMm || config.widthMm, niche.widthMidMm || config.widthMm, niche.widthBottomMm || config.widthMm);
|
const minNicheWidth = Math.min(niche.widthTopMm || config.widthMm, niche.widthMidMm || config.widthMm, niche.widthBottomMm || config.widthMm);
|
||||||
const minNicheDepth = Math.min(niche.depthTopMm || config.depthMm, niche.depthMidMm || config.depthMm, niche.depthBottomMm || config.depthMm);
|
const minNicheDepth = Math.min(niche.depthTopMm || config.depthMm, niche.depthMidMm || config.depthMm, niche.depthBottomMm || config.depthMm);
|
||||||
const minNicheHeight = Math.min(niche.heightLeftMm || config.heightMm, niche.heightRightMm || config.heightMm);
|
const minNicheHeight = Math.min(niche.heightLeftMm || config.heightMm, niche.heightRightMm || config.heightMm);
|
||||||
const gap = Number(config.sanitaryGapMm || 30);
|
const gap = Number(config.sanitaryGapMm || 30);
|
||||||
const usableWidth = Math.max(400, minNicheWidth - gap * 2);
|
const usableWidth = Math.max(DIMENSION_RANGES.widthMm.min, minNicheWidth - gap * 2);
|
||||||
const usableDepth = Math.max(400, minNicheDepth - gap * 2);
|
const usableDepth = Math.max(DIMENSION_RANGES.depthMm.min, minNicheDepth - gap * 2);
|
||||||
const usableHeight = Math.max(1800, minNicheHeight - gap * 2);
|
const usableHeight = Math.max(DIMENSION_RANGES.heightMm.min, minNicheHeight - gap);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.stepSection}>
|
<div className={s.stepSection}>
|
||||||
@@ -84,7 +103,23 @@ export default function StepDimensions({ wc }) {
|
|||||||
|
|
||||||
{/* Dimension sliders */}
|
{/* Dimension sliders */}
|
||||||
<div className={s.dimGrid}>
|
<div className={s.dimGrid}>
|
||||||
{DIMS.map(({ key, label, min, max, step, unit }) => (
|
{DIMS.map(({ key, label, min, max, step, unit }) => key === 'plinthHeightMm' ? (
|
||||||
|
<div key={key} className={`${s.dimField} ${s.dimFieldFull}`}>
|
||||||
|
<span className={s.dimLabel}>Hauteur cible pieds AXILO 78</span>
|
||||||
|
<div className={s.dimSliderRow}>
|
||||||
|
<select
|
||||||
|
className={s.dimValue}
|
||||||
|
value={config[key]}
|
||||||
|
onChange={(e) => set(key, Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{AXILO_TARGET_HEIGHTS_MM.map((heightMm) => (
|
||||||
|
<option key={heightMm} value={heightMm}>{heightMm} mm</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className={s.dimUnit}>Plinthe débitée à {Math.max(0, Number(config[key]) - 5)} mm</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div key={key} className={s.dimField}>
|
<div key={key} className={s.dimField}>
|
||||||
<span className={s.dimLabel}>{label}</span>
|
<span className={s.dimLabel}>{label}</span>
|
||||||
<div className={s.dimSliderRow}>
|
<div className={s.dimSliderRow}>
|
||||||
@@ -118,29 +153,47 @@ export default function StepDimensions({ wc }) {
|
|||||||
<div className={s.finishSelector}>
|
<div className={s.finishSelector}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${s.finishOption} ${sideLeftEnabled ? s.finishOptionActive : ''}`}
|
className={`${s.finishOption} ${sideLeftDoublageMandatory ? s.finishOptionMandatory : sideLeftEnabled ? s.finishOptionActive : ''}`}
|
||||||
|
disabled={sideLeftDoublageMandatory}
|
||||||
|
title={sideLeftDoublageMandatory ? 'Obligatoire: colonne gauche empilée (joints visibles)' : undefined}
|
||||||
onClick={() => set('sideFinishLeftEnabled', !sideLeftEnabled)}
|
onClick={() => set('sideFinishLeftEnabled', !sideLeftEnabled)}
|
||||||
>
|
>
|
||||||
<span className={s.finishOptionLabel}>Côté gauche</span>
|
<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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${s.finishOption} ${sideRightEnabled ? s.finishOptionActive : ''}`}
|
className={`${s.finishOption} ${sideRightDoublageMandatory ? s.finishOptionMandatory : sideRightEnabled ? s.finishOptionActive : ''}`}
|
||||||
|
disabled={sideRightDoublageMandatory}
|
||||||
|
title={sideRightDoublageMandatory ? 'Obligatoire: colonne droite empilée (joints visibles)' : undefined}
|
||||||
onClick={() => set('sideFinishRightEnabled', !sideRightEnabled)}
|
onClick={() => set('sideFinishRightEnabled', !sideRightEnabled)}
|
||||||
>
|
>
|
||||||
<span className={s.finishOptionLabel}>Côté droit</span>
|
<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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${s.finishOption} ${topFinishEnabled ? s.finishOptionActive : ''}`}
|
className={`${s.finishOption} ${topDoublageMandatory ? s.finishOptionMandatory : topFinishEnabled ? s.finishOptionActive : ''}`}
|
||||||
|
disabled={topDoublageMandatory}
|
||||||
|
title={topDoublageMandatory ? 'Obligatoire: une zone du dessus est sans dos' : undefined}
|
||||||
onClick={() => set('topFinishEnabled', !topFinishEnabled)}
|
onClick={() => set('topFinishEnabled', !topFinishEnabled)}
|
||||||
>
|
>
|
||||||
<span className={s.finishOptionLabel}>Dessus</span>
|
<span className={s.finishOptionLabel}>Dessus</span>
|
||||||
<span className={s.finishOptionState}>{topFinishEnabled ? 'Actif' : 'Retiré'}</span>
|
<span className={s.finishOptionState}>{topFinishEnabled ? (topDoublageMandatory ? 'Obligatoire' : 'Actif') : 'Retiré'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{(sideLeftDoublageMandatory || sideRightDoublageMandatory || topDoublageMandatory) && (
|
||||||
|
<p className={s.finishMandatoryNotice} role="status">
|
||||||
|
{sideLeftDoublageMandatory && sideRightDoublageMandatory
|
||||||
|
? 'Colonnes d\'extrémité empilées : habillages gauche et droit obligatoires. '
|
||||||
|
: sideLeftDoublageMandatory
|
||||||
|
? 'Colonne gauche empilée : habillage gauche obligatoire. '
|
||||||
|
: sideRightDoublageMandatory
|
||||||
|
? 'Colonne droite empilée : habillage droit obligatoire. '
|
||||||
|
: ''}
|
||||||
|
{topDoublageMandatory && 'Une zone du dessus (niveau haut) sans dos : dessus de doublage obligatoire.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import { COLUMN_TYPES } from '../../constants/wardrobe';
|
import { COLUMN_TYPES, moduleBlockHeightsMm, moduleBlockBacks, moduleBlockDoors } from '../../constants/wardrobe';
|
||||||
|
import { HORIZONTAL_SPAN_DOOR_MODES, resolveHorizontalSpan } from '@shared/manufacturing.js';
|
||||||
import { submitOrder } from '../../api/client';
|
import { submitOrder } from '../../api/client';
|
||||||
import Icon from '../ui/Icon';
|
import Icon from '../ui/Icon';
|
||||||
import s from './steps.module.css';
|
import s from './steps.module.css';
|
||||||
|
|
||||||
|
const HORIZONTAL_SPAN_DOOR_LABELS = {
|
||||||
|
[HORIZONTAL_SPAN_DOOR_MODES.none]: 'sans porte',
|
||||||
|
[HORIZONTAL_SPAN_DOOR_MODES.perColumn]: 'deux portes, charnières extérieures',
|
||||||
|
[HORIZONTAL_SPAN_DOOR_MODES.single]: 'une porte pleine largeur'
|
||||||
|
};
|
||||||
|
|
||||||
|
function horizontalSpanDoorLabel(doorMode) {
|
||||||
|
return HORIZONTAL_SPAN_DOOR_LABELS[doorMode] || HORIZONTAL_SPAN_DOOR_LABELS[HORIZONTAL_SPAN_DOOR_MODES.none];
|
||||||
|
}
|
||||||
|
|
||||||
export default function StepFinalize({ wc, ui }) {
|
export default function StepFinalize({ wc, ui }) {
|
||||||
const { config, priceResult, serviceOptions, selectedFinish, totalModuleWidth } = wc;
|
const { config, priceResult, serviceOptions, selectedFinish, totalModuleWidth } = wc;
|
||||||
const { orderForm, setOrderForm, orderSent, setOrderSent, setActiveStep } = ui;
|
const { orderForm, setOrderForm, orderSent, setOrderSent, setActiveStep } = ui;
|
||||||
@@ -22,7 +33,32 @@ export default function StepFinalize({ wc, ui }) {
|
|||||||
|
|
||||||
// Build summary lines
|
// Build summary lines
|
||||||
const installLabel = config.installationType === 'free' ? 'Libre' : 'Niche';
|
const installLabel = config.installationType === 'free' ? 'Libre' : 'Niche';
|
||||||
const doorLabel = config.doorType === 'hinged' ? 'Battantes' : `Coulissantes (${config.slidingPanelsCount} vantaux) [legacy]`;
|
// Portes par block: blocks sans porte = niches ouvertes.
|
||||||
|
const doorTotals = config.modules.reduce(
|
||||||
|
(acc, m) => {
|
||||||
|
const doors = moduleBlockDoors(m, config);
|
||||||
|
acc.total += doors.length;
|
||||||
|
acc.withDoor += doors.filter(Boolean).length;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ total: 0, withDoor: 0 }
|
||||||
|
);
|
||||||
|
const resolvedHorizontalSpans = (config.horizontalSpans || [])
|
||||||
|
.map((span) => resolveHorizontalSpan(span, config))
|
||||||
|
.filter(Boolean);
|
||||||
|
const regularDoorLabel =
|
||||||
|
config.doorType !== 'hinged'
|
||||||
|
? `Coulissantes (${config.slidingPanelsCount} vantaux) [legacy]`
|
||||||
|
: doorTotals.withDoor === doorTotals.total
|
||||||
|
? `Battantes (${doorTotals.withDoor})`
|
||||||
|
: doorTotals.withDoor === 0
|
||||||
|
? 'Sans portes (niches ouvertes)'
|
||||||
|
: `Battantes — ${doorTotals.withDoor}/${doorTotals.total} blocks avec porte`;
|
||||||
|
const doorLabel = resolvedHorizontalSpans.length > 0
|
||||||
|
? `${regularDoorLabel} · Blocks horizontaux : ${resolvedHorizontalSpans
|
||||||
|
.map((resolved) => horizontalSpanDoorLabel(resolved.span.doorMode))
|
||||||
|
.join(', ')}`
|
||||||
|
: regularDoorLabel;
|
||||||
const roomLabel = {
|
const roomLabel = {
|
||||||
other: 'Autre',
|
other: 'Autre',
|
||||||
bathroom: 'Salle de bain',
|
bathroom: 'Salle de bain',
|
||||||
@@ -34,6 +70,34 @@ export default function StepFinalize({ wc, ui }) {
|
|||||||
return `${t.icon} ${t.label} (${m.widthMm}mm)`;
|
return `${t.icon} ${t.label} (${m.widthMm}mm)`;
|
||||||
}).join(', ');
|
}).join(', ');
|
||||||
|
|
||||||
|
// Empilage PAR COLONNE — N blocks: zones = somme des blocks de chaque colonne.
|
||||||
|
const modHeightsList = config.modules.map((m) => moduleBlockHeightsMm(m, config));
|
||||||
|
const modBacksList = config.modules.map((m, i) => moduleBlockBacks(m, config, modHeightsList[i].length));
|
||||||
|
const stackedCount = modHeightsList.filter((hs) => hs.length >= 2).length;
|
||||||
|
const zoneCount = modHeightsList.reduce((sum, hs) => sum + hs.length, 0);
|
||||||
|
const backCount = modBacksList.reduce((sum, backs) => sum + backs.filter(Boolean).length, 0);
|
||||||
|
const backSummary =
|
||||||
|
backCount === 0
|
||||||
|
? 'Sans dos (côtés et rayonnage en block, reste en séparations)'
|
||||||
|
: backCount === zoneCount
|
||||||
|
? 'Avec dos partout (caissons block)'
|
||||||
|
: `Mixte — ${backCount}/${zoneCount} zone${backCount > 1 ? 's' : ''} avec dos (caissons block) ; sans dos: block si côté ou rayonnage, sinon séparations`;
|
||||||
|
const stackSignatures = [...new Set(modHeightsList.filter((hs) => hs.length >= 2).map((hs) => hs.join(' + ')))];
|
||||||
|
const stackSummary =
|
||||||
|
stackedCount === 0
|
||||||
|
? '1 (pleine hauteur)'
|
||||||
|
: stackedCount === config.modules.length && stackSignatures.length === 1
|
||||||
|
? `${modHeightsList[0].length} empilés — ${stackSignatures[0]} mm (toutes les colonnes)`
|
||||||
|
: stackedCount === config.modules.length
|
||||||
|
? 'Empilage sur toutes les colonnes (blocks et hauteurs par colonne)'
|
||||||
|
: `Mixte — ${stackedCount}/${config.modules.length} colonne${stackedCount > 1 ? 's' : ''} empilée${stackedCount > 1 ? 's' : ''}, reste pleine hauteur`;
|
||||||
|
const horizontalSpanSummaries = resolvedHorizontalSpans
|
||||||
|
.map((resolved) => (
|
||||||
|
`colonnes ${resolved.moduleIndexes[0] + 1}–${resolved.moduleIndexes[resolved.moduleIndexes.length - 1] + 1}, ` +
|
||||||
|
`${resolved.bottomMm}–${resolved.topMm} mm, ${resolved.span.hasBack ? 'avec dos' : 'sans dos'}, ` +
|
||||||
|
horizontalSpanDoorLabel(resolved.span.doorMode)
|
||||||
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.stepSection}>
|
<div className={s.stepSection}>
|
||||||
<h2 className={s.stepTitle}>Récapitulatif & Commande</h2>
|
<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>
|
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(2)}>modifier</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{horizontalSpanSummaries.length > 0 && (
|
||||||
|
<div className={s.summaryRow}>
|
||||||
|
<span className={s.summaryLabel}>Blocks horizontaux ({horizontalSpanSummaries.length})</span>
|
||||||
|
<span className={s.summaryValue}>
|
||||||
|
{horizontalSpanSummaries.join(' · ')}
|
||||||
|
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(2)}>modifier</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={s.summaryRow}>
|
<div className={s.summaryRow}>
|
||||||
<span className={s.summaryLabel}>Portes</span>
|
<span className={s.summaryLabel}>Portes</span>
|
||||||
<span className={s.summaryValue}>
|
<span className={s.summaryValue}>
|
||||||
@@ -113,17 +186,27 @@ export default function StepFinalize({ wc, ui }) {
|
|||||||
<div className={s.panelInfo}>
|
<div className={s.panelInfo}>
|
||||||
<div className={s.panelInfoTitle}>Fabrication APEX 4.0</div>
|
<div className={s.panelInfoTitle}>Fabrication APEX 4.0</div>
|
||||||
<div className={s.panelRow}>
|
<div className={s.panelRow}>
|
||||||
<span>Structure / Renforts / Dos :</span>
|
<span>Structure / Renforts{backCount > 0 ? ' / Dos' : ''} :</span>
|
||||||
<strong>19 mm / 16 mm / 8 mm</strong>
|
<strong>19 mm / 16 mm{backCount > 0 ? ' / 8 mm' : ''}</strong>
|
||||||
|
</div>
|
||||||
|
<div className={s.panelRow}>
|
||||||
|
<span>Dos (par zone) :</span>
|
||||||
|
<strong>{backSummary}</strong>
|
||||||
|
</div>
|
||||||
|
<div className={s.panelRow}>
|
||||||
|
<span>Blocks en hauteur (par colonne) :</span>
|
||||||
|
<strong>{stackSummary}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className={s.panelRow}>
|
<div className={s.panelRow}>
|
||||||
<span>System 32 :</span>
|
<span>System 32 :</span>
|
||||||
<strong>Ø5 mm, prof. 12 mm, pas 32 mm</strong>
|
<strong>Ø5 mm, prof. 12 mm, pas 32 mm</strong>
|
||||||
</div>
|
</div>
|
||||||
|
{backCount > 0 && (
|
||||||
<div className={s.panelRow}>
|
<div className={s.panelRow}>
|
||||||
<span>Rainure dos :</span>
|
<span>Rainure dos :</span>
|
||||||
<strong>9×10.5 mm, à 16 mm du bord</strong>
|
<strong>9×10.5 mm, à 16 mm du bord{backCount < zoneCount ? ' (zones avec dos uniquement)' : ''}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className={s.panelRow}>
|
<div className={s.panelRow}>
|
||||||
<span>Connecteurs structure :</span>
|
<span>Connecteurs structure :</span>
|
||||||
<strong>SC 8/60 (extrémités T@20 → IX@50 → T@80 · milieu IX + T ±30 mm)</strong>
|
<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>
|
||||||
<div className={s.panelRow}>
|
<div className={s.panelRow}>
|
||||||
<span>Plinthe :</span>
|
<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>
|
</div>
|
||||||
{priceResult?.details?.reinforcementCount > 0 && (
|
{priceResult?.details?.reinforcementCount > 0 && (
|
||||||
<div className={s.panelRow}>
|
<div className={s.panelRow}>
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ import s from './steps.module.css';
|
|||||||
export default function StepHardware({ wc }) {
|
export default function StepHardware({ wc }) {
|
||||||
const { config, handleVariants, hingeVariants, blumHardware, setHardware } = wc;
|
const { config, handleVariants, hingeVariants, blumHardware, setHardware } = wc;
|
||||||
|
|
||||||
const hasDrawers = config.modules.some(m => m.drawers > 0);
|
const drawerModules = config.modules.filter(m => m.drawers > 0);
|
||||||
const drawerType = config.hardware?.drawerType || 'visible';
|
const hasDrawers = drawerModules.length > 0;
|
||||||
const standardHeights = [60, 90, 128, 177, 240];
|
const allDrawersVisible = hasDrawers && drawerModules.every(
|
||||||
|
m => (m.drawerType || config.hardware?.drawerType || 'visible') === 'visible'
|
||||||
|
);
|
||||||
|
// Blum Legrabox : hauteurs de côté N/M/K/C/F (66/90/128/177/240 mm).
|
||||||
|
const standardHeights = [66, 90, 128, 177, 240];
|
||||||
|
const legraboxSystemByHeight = { 66: 'N', 90: 'M', 128: 'K', 177: 'C', 240: 'F' };
|
||||||
const currentHeights = config.hardware?.drawerHeightsMm || [90, 128, 177, 240];
|
const currentHeights = config.hardware?.drawerHeightsMm || [90, 128, 177, 240];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -24,7 +29,7 @@ export default function StepHardware({ wc }) {
|
|||||||
className={`${s.hardwareBtn} ${config.hardware.handleVariantId === h.id ? s.hardwareBtnSelected : ''}`}
|
className={`${s.hardwareBtn} ${config.hardware.handleVariantId === h.id ? s.hardwareBtnSelected : ''}`}
|
||||||
onClick={() => setHardware('handleVariantId', h.id)}
|
onClick={() => setHardware('handleVariantId', h.id)}
|
||||||
title={`${h.name} - ${h.price_chf} CHF`}
|
title={`${h.name} - ${h.price_chf} CHF`}
|
||||||
disabled={drawerType === 'visible' && hasDrawers}
|
disabled={allDrawersVisible}
|
||||||
>
|
>
|
||||||
{h.image_url ? (
|
{h.image_url ? (
|
||||||
<img src={h.image_url} alt={h.name} />
|
<img src={h.image_url} alt={h.name} />
|
||||||
@@ -36,7 +41,7 @@ export default function StepHardware({ wc }) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{drawerType === 'visible' && hasDrawers && (
|
{allDrawersVisible && (
|
||||||
<p className={s.hint}>Tiroir visible avec coulisse Blum Motion: poignée non requise.</p>
|
<p className={s.hint}>Tiroir visible avec coulisse Blum Motion: poignée non requise.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -96,12 +101,12 @@ export default function StepHardware({ wc }) {
|
|||||||
setHardware('drawerHeightsMm', next.slice(0, 4));
|
setHardware('drawerHeightsMm', next.slice(0, 4));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mm} mm
|
{legraboxSystemByHeight[mm]} — {mm} mm
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p className={s.hint}>4 hauteurs standard max (hypothèse codée en dur pour cette version).</p>
|
<p className={s.hint}>Hauteurs de côté Blum Legrabox (N/M/K/C/F) — 4 hauteurs max par colonne.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { STEPS } from '../../constants/wardrobe';
|
import { STEPS } from '../../constants/wardrobe';
|
||||||
|
import Icon from '../ui/Icon';
|
||||||
import styles from './StepperBar.module.css';
|
import styles from './StepperBar.module.css';
|
||||||
|
|
||||||
export default function StepperBar({ activeStep, onStepChange, totalChf, touched }) {
|
export default function StepperBar({
|
||||||
|
activeStep,
|
||||||
|
onStepChange,
|
||||||
|
onOpenSavedDesigns,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
totalChf,
|
||||||
|
touched
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.stepperBar}>
|
<div className={styles.stepperBar}>
|
||||||
{STEPS.map(({ id, label }) => (
|
{STEPS.map(({ id, label }) => (
|
||||||
@@ -20,6 +31,33 @@ export default function StepperBar({ activeStep, onStepChange, totalChf, touched
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<span className={styles.spacer} />
|
<span className={styles.spacer} />
|
||||||
|
<div className={styles.historyActions} aria-label="Historique des modifications">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.historyBtn}
|
||||||
|
onClick={onUndo}
|
||||||
|
disabled={!canUndo}
|
||||||
|
title="Annuler la dernière action (Ctrl+Z)"
|
||||||
|
aria-label="Annuler la dernière action"
|
||||||
|
>
|
||||||
|
<Icon name="undo" size={16} />
|
||||||
|
<span>Annuler</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.historyBtn}
|
||||||
|
onClick={onRedo}
|
||||||
|
disabled={!canRedo}
|
||||||
|
title="Rétablir la dernière action (Ctrl+Y)"
|
||||||
|
aria-label="Rétablir la dernière action"
|
||||||
|
>
|
||||||
|
<Icon name="redo" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" className={styles.savedDesignsBtn} onClick={onOpenSavedDesigns}>
|
||||||
|
<Icon name="save" size={15} />
|
||||||
|
<span>Mes armoires</span>
|
||||||
|
</button>
|
||||||
<div className={styles.priceBadge}>
|
<div className={styles.priceBadge}>
|
||||||
<span className={styles.priceLabel}>CHF</span>
|
<span className={styles.priceLabel}>CHF</span>
|
||||||
{(totalChf || 0).toLocaleString('fr-CH', { minimumFractionDigits: 0 })}.–
|
{(totalChf || 0).toLocaleString('fr-CH', { minimumFractionDigits: 0 })}.–
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: var(--bg-card);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||||
position: -webkit-sticky;
|
position: -webkit-sticky;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: var(--header-height);
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
@@ -67,6 +70,52 @@
|
|||||||
|
|
||||||
.spacer { flex: 1; min-width: 0.5rem; }
|
.spacer { flex: 1; min-width: 0.5rem; }
|
||||||
|
|
||||||
|
.historyActions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyBtn + .historyBtn { border-left: 1px solid var(--border); }
|
||||||
|
.historyBtn:hover:not(:disabled) { background: var(--bg); color: var(--action); }
|
||||||
|
.historyBtn:disabled { color: var(--text-muted); cursor: not-allowed; opacity: 0.45; }
|
||||||
|
|
||||||
|
.savedDesignsBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.savedDesignsBtn:hover {
|
||||||
|
border-color: var(--action);
|
||||||
|
color: var(--action);
|
||||||
|
}
|
||||||
|
|
||||||
.priceBadge {
|
.priceBadge {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -92,5 +141,8 @@
|
|||||||
.stepperBar { padding: 0.4rem 0.5rem; gap: 0.15rem; }
|
.stepperBar { padding: 0.4rem 0.5rem; gap: 0.15rem; }
|
||||||
.step { padding: 0.4rem 0.6rem; font-size: 0.75rem; }
|
.step { padding: 0.4rem 0.6rem; font-size: 0.75rem; }
|
||||||
.stepLabel { display: none; }
|
.stepLabel { display: none; }
|
||||||
|
.historyBtn span { display: none; }
|
||||||
|
.savedDesignsBtn span { display: none; }
|
||||||
|
.savedDesignsBtn { padding: 0.4rem 0.5rem; }
|
||||||
.priceBadge { font-size: 0.8125rem; padding: 0.35rem 0.65rem; }
|
.priceBadge { font-size: 0.8125rem; padding: 0.35rem 0.65rem; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
.dimFieldFull {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
.dimLabel {
|
.dimLabel {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -298,6 +301,22 @@
|
|||||||
.colCardIcon { font-size: 1.2rem; display: block; }
|
.colCardIcon { font-size: 1.2rem; display: block; }
|
||||||
.colCardType { font-size: 0.6875rem; font-weight: 600; color: var(--text); margin-top: 0.15rem; }
|
.colCardType { font-size: 0.6875rem; font-weight: 600; color: var(--text); margin-top: 0.15rem; }
|
||||||
.colCardWidth { font-size: 0.625rem; color: var(--text-muted); }
|
.colCardWidth { font-size: 0.625rem; color: var(--text-muted); }
|
||||||
|
.spanMembership {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
padding: 0.18rem 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, #0ea5e9 12%, var(--bg));
|
||||||
|
color: #0369a1;
|
||||||
|
font-size: 0.56rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.spanMembershipActive {
|
||||||
|
background: #0ea5e9;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
.colLockIcon {
|
.colLockIcon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.2rem;
|
top: 0.2rem;
|
||||||
@@ -320,6 +339,61 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
.colDetailHeaderBtns {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder shown while no block is selected */
|
||||||
|
.detailPlaceholder {
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global settings accordion (dos par zone, empilage) */
|
||||||
|
.globalSettings {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
margin: 0.25rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
.globalSettingsSummary {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.globalSettingsSummary::-webkit-details-marker { display: none; }
|
||||||
|
.globalSettingsSummary::before {
|
||||||
|
content: '\25B8';
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 0.45rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.globalSettings[open] > .globalSettingsSummary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.globalSettings[open] > .globalSettingsSummary {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.globalSettings > .hardwareGroup {
|
||||||
|
padding: 0 0.85rem;
|
||||||
|
}
|
||||||
|
.globalSettings > .hardwareGroup:last-child {
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
.colDetailTitle {
|
.colDetailTitle {
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -337,6 +411,180 @@
|
|||||||
.lockBtn:hover { border-color: var(--text); }
|
.lockBtn:hover { border-color: var(--text); }
|
||||||
.isLocked { background: var(--bg); }
|
.isLocked { background: var(--bg); }
|
||||||
|
|
||||||
|
.blockPicker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0.35rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
.blockPickerBtn {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.blockPickerBtn:hover { border-color: var(--action); }
|
||||||
|
.blockPickerBtn small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.blockPickerBtnActive {
|
||||||
|
border-color: var(--action);
|
||||||
|
background: color-mix(in srgb, var(--action) 10%, var(--bg));
|
||||||
|
color: var(--action);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--action) 30%, transparent);
|
||||||
|
}
|
||||||
|
.blockSettingsTitle {
|
||||||
|
margin: 0.2rem 0 0.25rem;
|
||||||
|
padding-top: 0.65rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spanPanel {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid color-mix(in srgb, #0ea5e9 35%, var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, #0ea5e9 5%, var(--bg-card));
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.spanPanelTitle {
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.spanList {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.spanListItem {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem;
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.spanListItem small { color: var(--text-muted); }
|
||||||
|
.spanListItemActive {
|
||||||
|
border-color: #0ea5e9;
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, #0ea5e9 30%, transparent);
|
||||||
|
}
|
||||||
|
.spanEditor {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.spanDoorChoices {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.35rem;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.6rem;
|
||||||
|
border: 1px solid color-mix(in srgb, #0ea5e9 30%, var(--border));
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.spanDoorChoices legend {
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.spanDoorChoices small {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.spanDoorChoice {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.45rem 0.4rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.spanDoorChoice:hover {
|
||||||
|
border-color: #0ea5e9;
|
||||||
|
}
|
||||||
|
.spanDoorChoiceActive {
|
||||||
|
border-color: #0ea5e9;
|
||||||
|
background: color-mix(in srgb, #0ea5e9 12%, var(--bg-card));
|
||||||
|
color: #0369a1;
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, #0ea5e9 25%, transparent);
|
||||||
|
}
|
||||||
|
.spanDeleteBtn {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.spanSuccess {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border: 1px solid color-mix(in srgb, #22c55e 35%, var(--border));
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, #22c55e 10%, var(--bg-card));
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin: 0.4rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
.horizontalSpanCreator {
|
||||||
|
margin: 0.55rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
.horizontalSpanStartBtn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
border: 1px dashed #0ea5e9;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: color-mix(in srgb, #0ea5e9 6%, var(--bg-card));
|
||||||
|
color: #0369a1;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.spanEndChoices,
|
||||||
|
.spanCreatorActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.spanCreatorActions .optimizeBtn { margin-left: 0; }
|
||||||
|
|
||||||
/* Type selector tiles */
|
/* Type selector tiles */
|
||||||
.typeTiles {
|
.typeTiles {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -840,17 +1088,30 @@
|
|||||||
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.finishOption:hover {
|
.finishOption:hover:not(:disabled) {
|
||||||
border-color: var(--action);
|
border-color: var(--action);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finishOption:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.finishOptionActive {
|
.finishOptionActive {
|
||||||
border-color: var(--action);
|
border-color: var(--action);
|
||||||
background: color-mix(in srgb, var(--action) 10%, var(--bg-card));
|
background: color-mix(in srgb, var(--action) 10%, var(--bg-card));
|
||||||
box-shadow: 0 8px 18px color-mix(in srgb, var(--action) 20%, transparent);
|
box-shadow: 0 8px 18px color-mix(in srgb, var(--action) 20%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finishOptionMandatory,
|
||||||
|
.finishOptionMandatory:hover {
|
||||||
|
border-color: var(--warning);
|
||||||
|
background: color-mix(in srgb, var(--warning) 16%, var(--bg-card));
|
||||||
|
box-shadow: 0 8px 18px color-mix(in srgb, var(--warning) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.finishOptionLabel {
|
.finishOptionLabel {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -865,6 +1126,26 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finishOptionMandatory .finishOptionLabel,
|
||||||
|
.finishOptionMandatory .finishOptionState {
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finishOptionMandatory .finishOptionState {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finishMandatoryNotice {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #92400e;
|
||||||
|
background: color-mix(in srgb, var(--warning) 12%, var(--bg-card));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--warning) 35%, var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.ruleGrid3 {
|
.ruleGrid3 {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -916,6 +1197,7 @@
|
|||||||
.cardToggle { flex-direction: column; }
|
.cardToggle { flex-direction: column; }
|
||||||
.columnStrip { gap: 0.25rem; }
|
.columnStrip { gap: 0.25rem; }
|
||||||
.ruleGrid3 { grid-template-columns: 1fr; }
|
.ruleGrid3 { grid-template-columns: 1fr; }
|
||||||
|
.spanDoorChoices { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact mode for desktop split-panel (keeps everything visible) */
|
/* Compact mode for desktop split-panel (keeps everything visible) */
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ export default function Icon({ name, size = 16, className = '' }) {
|
|||||||
<path d="M3 21v-5h5" />
|
<path d="M3 21v-5h5" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case 'fit':
|
||||||
|
return (
|
||||||
|
<svg {...props}>
|
||||||
|
<path d="M3 8V5a2 2 0 0 1 2-2h3" />
|
||||||
|
<path d="M16 3h3a2 2 0 0 1 2 2v3" />
|
||||||
|
<path d="M21 16v3a2 2 0 0 1-2 2h-3" />
|
||||||
|
<path d="M8 21H5a2 2 0 0 1-2-2v-3" />
|
||||||
|
<rect x="8" y="8" width="8" height="8" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case 'eye':
|
case 'eye':
|
||||||
return (
|
return (
|
||||||
<svg {...props}>
|
<svg {...props}>
|
||||||
@@ -37,6 +47,23 @@ export default function Icon({ name, size = 16, className = '' }) {
|
|||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case 'door':
|
||||||
|
return (
|
||||||
|
<svg {...props}>
|
||||||
|
<path d="M5 21V3h14v18" />
|
||||||
|
<path d="M3 21h18" />
|
||||||
|
<circle cx="15" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'door-off':
|
||||||
|
return (
|
||||||
|
<svg {...props}>
|
||||||
|
<path d="M5 21V3h14v18" />
|
||||||
|
<path d="M3 21h18" />
|
||||||
|
<circle cx="15" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||||
|
<path d="M3 3l18 18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case 'download':
|
case 'download':
|
||||||
return (
|
return (
|
||||||
<svg {...props}>
|
<svg {...props}>
|
||||||
@@ -45,6 +72,34 @@ export default function Icon({ name, size = 16, className = '' }) {
|
|||||||
<path d="M12 15V3" />
|
<path d="M12 15V3" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case 'save':
|
||||||
|
return (
|
||||||
|
<svg {...props}>
|
||||||
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" />
|
||||||
|
<path d="M17 21v-8H7v8M7 3v5h8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'restore':
|
||||||
|
return (
|
||||||
|
<svg {...props}>
|
||||||
|
<path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
|
||||||
|
<path d="M3 3v5h5M12 7v5l3 2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'undo':
|
||||||
|
return (
|
||||||
|
<svg {...props}>
|
||||||
|
<path d="M9 7 4 12l5 5" />
|
||||||
|
<path d="M4 12h9a7 7 0 0 1 7 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'redo':
|
||||||
|
return (
|
||||||
|
<svg {...props}>
|
||||||
|
<path d="m15 7 5 5-5 5" />
|
||||||
|
<path d="M20 12h-9a7 7 0 0 0-7 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case 'trash':
|
case 'trash':
|
||||||
return (
|
return (
|
||||||
<svg {...props}>
|
<svg {...props}>
|
||||||
@@ -75,6 +130,63 @@ export default function Icon({ name, size = 16, className = '' }) {
|
|||||||
<path d="M5 3v3M3.5 4.5h3M19 16v5M16.5 18.5h5" />
|
<path d="M5 3v3M3.5 4.5h3M19 16v5M16.5 18.5h5" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case 'coins':
|
||||||
|
return (
|
||||||
|
<svg {...props}>
|
||||||
|
<circle cx="8" cy="8" r="6" />
|
||||||
|
<path d="M18.09 10.37A6 6 0 1 1 10.34 18" />
|
||||||
|
<path d="M7 6h1v4" />
|
||||||
|
<path d="m16.71 13.88.7.71-2.82 2.82" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'layers':
|
||||||
|
return (
|
||||||
|
<svg {...props}>
|
||||||
|
<path d="m12 2 10 5-10 5L2 7z" />
|
||||||
|
<path d="m2 17 10 5 10-5" />
|
||||||
|
<path d="m2 12 10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'sliding':
|
||||||
|
return (
|
||||||
|
<svg {...props}>
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="1" />
|
||||||
|
<path d="M12 4v16" />
|
||||||
|
<path d="m7 10-2 2 2 2" />
|
||||||
|
<path d="m17 10 2 2-2 2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'drawer':
|
||||||
|
return (
|
||||||
|
<svg {...props}>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<path d="M3 12h18" />
|
||||||
|
<path d="M10 7.5h4M10 16.5h4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'handle':
|
||||||
|
return (
|
||||||
|
<svg {...props}>
|
||||||
|
<path d="M6 15V10a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v5" />
|
||||||
|
<path d="M3 15h6M15 15h6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'groove':
|
||||||
|
return (
|
||||||
|
<svg {...props}>
|
||||||
|
<rect x="3" y="6" width="18" height="12" rx="1" />
|
||||||
|
<path d="M10 6v6h4V6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'clearance':
|
||||||
|
return (
|
||||||
|
<svg {...props}>
|
||||||
|
<path d="M4 4v16M20 4v16" />
|
||||||
|
<path d="M8 12h8" />
|
||||||
|
<path d="m10 9-3 3 3 3" />
|
||||||
|
<path d="m14 9 3 3-3 3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case 'x':
|
case 'x':
|
||||||
return (
|
return (
|
||||||
<svg {...props}>
|
<svg {...props}>
|
||||||
|
|||||||
@@ -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. */
|
/** Inner carcass height (mm): floor to ceiling inside side panels. */
|
||||||
export function innerCarcassHeightMm(heightMm, plinthHeightMm) {
|
export function innerCarcassHeightMm(heightMm, plinthHeightMm) {
|
||||||
@@ -15,18 +21,178 @@ export function maxShelfGapMm(heightMm, plinthHeightMm, shelfCount) {
|
|||||||
return Math.floor((H - n * t) / (n - 1));
|
return Math.floor((H - n * t) / (n - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for wardrobe envelope dimension ranges (mm).
|
||||||
|
* Width max = CNC length; depth max = CNC short side; beyond width max the system couples another armoire.
|
||||||
|
*/
|
||||||
|
export const DIMENSION_RANGES = {
|
||||||
|
widthMm: { min: 300, max: MAX_PANEL_DIMENSION_MM, step: 50 },
|
||||||
|
// > MAX_PANEL_DIMENSION_MM possible uniquement via l'empilage vertical (chaque block ≤ panneau max).
|
||||||
|
heightMm: { min: 300, max: 4000, step: 50 },
|
||||||
|
depthMm: { min: 300, max: MAX_PANEL_SHORT_SIDE_MM, step: 10 },
|
||||||
|
plinthHeightMm: { min: 60, max: 200, step: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AXILO_TARGET_HEIGHTS_MM = [...PLINTH.targetHeightsMm];
|
||||||
|
|
||||||
export const SHELF_GAP_MM = { min: 80, max: 520, step: 10 };
|
export const SHELF_GAP_MM = { min: 80, max: 520, step: 10 };
|
||||||
|
/** Empilage vertical: bornes de hauteur de chaque block (caisse, hors plinthe). max = panneau max. */
|
||||||
|
export const STACK_BLOCK_MM = { min: 300, max: MAX_PANEL_DIMENSION_MM, step: 50 };
|
||||||
export const SYSTEM32_PITCH_MM = 32;
|
export const SYSTEM32_PITCH_MM = 32;
|
||||||
export const SYSTEM32_FIRST_HOLE_MM = 51;
|
export const SYSTEM32_FIRST_HOLE_MM = 51;
|
||||||
|
|
||||||
/** Above this carcass height (mm), at least one shelf per column is required (rigidité / RAZENTZAB). */
|
/** Above this carcass height (mm), at least one shelf per column is required (rigidité / RAZENTZAB). */
|
||||||
export const STRUCTURAL_MIN_SHELF_HEIGHT_MM = 2000;
|
export const STRUCTURAL_MIN_SHELF_HEIGHT_MM = 2000;
|
||||||
|
|
||||||
|
/** Half-width (mm) of the structural shelf window around carcass mid-height. */
|
||||||
|
export const STRUCTURAL_WINDOW_HALF_MM = 200;
|
||||||
|
|
||||||
/** Minimum shelf count for a column at this height (0 or 1). */
|
/** Minimum shelf count for a column at this height (0 or 1). */
|
||||||
export function minShelvesForStructuralHeight(heightMm) {
|
export function minShelvesForStructuralHeight(heightMm) {
|
||||||
return (Number(heightMm) || 0) > STRUCTURAL_MIN_SHELF_HEIGHT_MM ? 1 : 0;
|
return (Number(heightMm) || 0) > STRUCTURAL_MIN_SHELF_HEIGHT_MM ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hauteur de caisse (hors plinthe). */
|
||||||
|
export function carcassBodyMm(config) {
|
||||||
|
return (Number(config?.heightMm) || 0) - (Number(config?.plinthHeightMm) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nombre minimum de blocks pour cette hauteur de caisse (panneau max par block). */
|
||||||
|
export function minBlocksForBody(bodyMm) {
|
||||||
|
return Math.max(1, Math.ceil((Number(bodyMm) || 0) / STACK_BLOCK_MM.max));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nombre maximum de blocks pour cette hauteur de caisse (min 300 mm par block). */
|
||||||
|
export function maxBlocksForBody(bodyMm) {
|
||||||
|
return Math.max(1, Math.floor((Number(bodyMm) || 0) / STACK_BLOCK_MM.min));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hauteur minimale du block bas pour contenir tous les tiroirs de la colonne. */
|
||||||
|
export function moduleBottomBlockMinMm(mod, config) {
|
||||||
|
const drawerCount = Math.max(0, Number(mod?.drawers) || 0);
|
||||||
|
if (drawerCount <= 0) return STACK_BLOCK_MM.min;
|
||||||
|
const drawerHeightsMm = config?.hardware?.drawerHeightsMm;
|
||||||
|
const drawerStackMm = drawerStackHeightMm(drawerCount, drawerHeightsMm);
|
||||||
|
return Math.max(
|
||||||
|
STACK_BLOCK_MM.min,
|
||||||
|
Math.ceil(drawerStackMm / STACK_BLOCK_MM.step) * STACK_BLOCK_MM.step
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empilage vertical PAR COLONNE — modèle N blocks.
|
||||||
|
* Canonique: `mod.blockHeightsMm` (hauteurs de caisse des blocks, bas → haut).
|
||||||
|
* Legacy 2 blocks: `mod.stacked` + `mod.stackBottomHeightMm` (défauts config.stackEnabled / config.stackBottomHeightMm).
|
||||||
|
* Retourne des hauteurs normalisées: chaque block dans [min, max], somme = hauteur de caisse.
|
||||||
|
*/
|
||||||
|
export function moduleBlockHeightsMm(mod, config) {
|
||||||
|
const bodyH = carcassBodyMm(config);
|
||||||
|
const min = STACK_BLOCK_MM.min;
|
||||||
|
const bottomMin = moduleBottomBlockMinMm(mod, config);
|
||||||
|
if (!(bodyH >= min * 2)) return [Math.max(0, bodyH)];
|
||||||
|
const maxBlocks = maxBlocksForBody(bodyH);
|
||||||
|
const minBlocks = minBlocksForBody(bodyH);
|
||||||
|
|
||||||
|
let raw = Array.isArray(mod?.blockHeightsMm)
|
||||||
|
? mod.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!raw || raw.length === 0) {
|
||||||
|
// Legacy: flags 2 blocks (stacked + stackBottomHeightMm).
|
||||||
|
const stacked = mod && typeof mod.stacked === 'boolean' ? mod.stacked : config?.stackEnabled === true;
|
||||||
|
if (stacked && maxBlocks >= 2) {
|
||||||
|
const b = Number(mod?.stackBottomHeightMm ?? config?.stackBottomHeightMm);
|
||||||
|
const bottom = Math.min(Math.max(min, bodyH - min), Math.max(min, Number.isFinite(b) ? b : 1000));
|
||||||
|
raw = [bottom, bodyH - bottom];
|
||||||
|
} else {
|
||||||
|
raw = [bodyH];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empilage obligatoire au-delà du panneau max: forcer le nombre minimum de blocks.
|
||||||
|
let count = Math.min(Math.max(raw.length, minBlocks), maxBlocks);
|
||||||
|
// Si les tiroirs ne tiennent pas dans le block bas, réduire le nombre de
|
||||||
|
// blocks facultatifs afin de leur réserver la hauteur nécessaire.
|
||||||
|
while (count > minBlocks && bodyH - min * (count - 1) < bottomMin) count -= 1;
|
||||||
|
if (count <= 1) return [bodyH];
|
||||||
|
if (raw.length !== count) {
|
||||||
|
// Nombre de blocks imposé/écrêté → répartition égale.
|
||||||
|
const even = bodyH / count;
|
||||||
|
raw = Array.from({ length: count }, () => even);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
let remaining = bodyH;
|
||||||
|
for (let i = 0; i < count - 1; i++) {
|
||||||
|
const blocksAfter = count - i - 1;
|
||||||
|
const maxH = Math.min(STACK_BLOCK_MM.max, remaining - min * blocksAfter);
|
||||||
|
const minH = i === 0 ? bottomMin : min;
|
||||||
|
const stepped = Math.round((Number(raw[i]) || minH) / STACK_BLOCK_MM.step) * STACK_BLOCK_MM.step;
|
||||||
|
const h = Math.min(maxH, Math.max(minH, stepped));
|
||||||
|
out.push(h);
|
||||||
|
remaining -= h;
|
||||||
|
}
|
||||||
|
out.push(remaining);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Porte par block (bas → haut). Défaut: porte sur chaque block (`bloc ou partie de bloc sans porte`). */
|
||||||
|
export function moduleBlockDoors(mod, config, blockCount = null) {
|
||||||
|
const n = blockCount ?? moduleBlockHeightsMm(mod, config).length;
|
||||||
|
const arr = Array.isArray(mod?.blockDoors) ? mod.blockDoors : null;
|
||||||
|
return Array.from({ length: n }, (_, i) => (arr && typeof arr[i] === 'boolean' ? arr[i] : true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dos par block (bas → haut). Legacy: hasBack (block 1 / défaut), hasBackTop (dernier block). */
|
||||||
|
export function moduleBlockBacks(mod, config, blockCount = null) {
|
||||||
|
const n = blockCount ?? moduleBlockHeightsMm(mod, config).length;
|
||||||
|
const arr = Array.isArray(mod?.blockBacks) ? mod.blockBacks : null;
|
||||||
|
const base = typeof mod?.hasBack === 'boolean' ? mod.hasBack : config?.hasBack === true;
|
||||||
|
return Array.from({ length: n }, (_, i) => {
|
||||||
|
if (arr && typeof arr[i] === 'boolean') return arr[i];
|
||||||
|
if (i === n - 1 && n >= 2 && typeof mod?.hasBackTop === 'boolean') return mod.hasBackTop;
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colonne empilée = au moins 2 blocks en hauteur. */
|
||||||
|
export function moduleStacked(mod, config) {
|
||||||
|
return moduleBlockHeightsMm(mod, config).length >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Habillage latéral obligatoire seulement si la colonne d'extrémité est empilée
|
||||||
|
* (joints visibles sur cette face). Un côté en un seul block reste optionnel.
|
||||||
|
*/
|
||||||
|
export function outerColumnStacked(modules, config, side) {
|
||||||
|
const mods = Array.isArray(modules) ? modules.filter(Boolean) : [];
|
||||||
|
if (mods.length === 0) return config?.stackEnabled === true;
|
||||||
|
const mod = side === 'right' ? mods[mods.length - 1] : mods[0];
|
||||||
|
return moduleStacked(mod, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infos empilage d'une colonne pour rayons / rendu: null (1 block) ou
|
||||||
|
* { heightsMm, boundariesMm } — boundariesMm = hauteurs cumulées des jonctions (depuis le bas de caisse).
|
||||||
|
*/
|
||||||
|
export function moduleStackInfo(mod, config) {
|
||||||
|
const heightsMm = moduleBlockHeightsMm(mod, config);
|
||||||
|
if (heightsMm.length < 2) return null;
|
||||||
|
const boundariesMm = [];
|
||||||
|
let acc = 0;
|
||||||
|
for (let i = 0; i < heightsMm.length - 1; i++) {
|
||||||
|
acc += heightsMm[i];
|
||||||
|
boundariesMm.push(acc);
|
||||||
|
}
|
||||||
|
return { heightsMm, boundariesMm };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colonne empilée → les jonctions (doubles panneaux) jouent le rôle de rayon structurel. */
|
||||||
|
export function minShelvesForColumn(heightMm, mod, config) {
|
||||||
|
if (mod && moduleStacked(mod, config)) return 0;
|
||||||
|
return minShelvesForStructuralHeight(heightMm);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shelf center Y (meters), wardrobe group space — matches WardrobePreview formulas.
|
* Shelf center Y (meters), wardrobe group space — matches WardrobePreview formulas.
|
||||||
* @param {number|null|undefined} shelfGapMm — clear opening between shelf faces; null = equal division (default).
|
* @param {number|null|undefined} shelfGapMm — clear opening between shelf faces; null = equal division (default).
|
||||||
@@ -72,25 +238,190 @@ export function shelfCenterYsMeters({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function system32ShelfPositionsMm(heightMm, plinthHeightMm) {
|
/**
|
||||||
|
* Rangées System 32 disponibles (mm depuis le bas de caisse).
|
||||||
|
* `stack` optionnel ({ boundariesMm }): colonne empilée → chaque block a ses propres rangées,
|
||||||
|
* aucune position ne traverse une jonction (double panneau horizontal).
|
||||||
|
*/
|
||||||
|
export function system32ShelfPositionsMm(heightMm, plinthHeightMm, stack = null) {
|
||||||
const bodyH = (Number(heightMm) || 0) - (Number(plinthHeightMm) || 0);
|
const bodyH = (Number(heightMm) || 0) - (Number(plinthHeightMm) || 0);
|
||||||
if (!(bodyH > 0)) return [];
|
if (!(bodyH > 0)) return [];
|
||||||
const minPos = SYSTEM32_FIRST_HOLE_MM;
|
const rangeSlots = (fromMm, toMm) => {
|
||||||
const maxPos = bodyH - SYSTEM32_FIRST_HOLE_MM;
|
const minPos = fromMm + SYSTEM32_FIRST_HOLE_MM;
|
||||||
|
const maxPos = toMm - SYSTEM32_FIRST_HOLE_MM;
|
||||||
if (maxPos < minPos) return [];
|
if (maxPos < minPos) return [];
|
||||||
const count = Math.floor((maxPos - minPos) / SYSTEM32_PITCH_MM) + 1;
|
const count = Math.floor((maxPos - minPos) / SYSTEM32_PITCH_MM) + 1;
|
||||||
return Array.from({ length: Math.max(0, count) }, (_, i) => minPos + i * SYSTEM32_PITCH_MM);
|
return Array.from({ length: Math.max(0, count) }, (_, i) => minPos + i * SYSTEM32_PITCH_MM);
|
||||||
|
};
|
||||||
|
const boundaries = (Array.isArray(stack?.boundariesMm) ? stack.boundariesMm : [])
|
||||||
|
.map(Number)
|
||||||
|
.filter((v) => Number.isFinite(v) && v > 0 && v < bodyH)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
if (boundaries.length > 0) {
|
||||||
|
const edges = [0, ...boundaries, bodyH];
|
||||||
|
const slots = [];
|
||||||
|
for (let i = 0; i + 1 < edges.length; i++) {
|
||||||
|
slots.push(...rangeSlots(edges[i], edges[i + 1]));
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
return rangeSlots(0, bodyH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shelfIndexValidRange(indexes, slots, shelfIdx, shelfCount) {
|
||||||
|
const low = Math.max(shelfIdx, shelfIdx > 0 ? indexes[shelfIdx - 1] + 1 : 0);
|
||||||
|
const high = Math.min(
|
||||||
|
slots.length - (shelfCount - shelfIdx),
|
||||||
|
shelfIdx < shelfCount - 1 ? indexes[shelfIdx + 1] - 1 : slots.length - 1
|
||||||
|
);
|
||||||
|
return [low, high];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyShelfIndexOrdering(indexes, slots, shelfCount) {
|
||||||
|
const next = [...indexes];
|
||||||
|
for (let i = 0; i < shelfCount; i++) {
|
||||||
|
const minIdx = i;
|
||||||
|
next[i] = Math.max(minIdx, Math.min(slots.length - (shelfCount - i), next[i]));
|
||||||
|
}
|
||||||
|
for (let i = shelfCount - 2; i >= 0; i--) {
|
||||||
|
next[i] = Math.min(next[i], next[i + 1] - 1);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Répartit les rayons dans les ouvertures de chaque block, puis les centre dans
|
||||||
|
* leur ouverture sur le pas System 32. Les jonctions sont ainsi traitées comme
|
||||||
|
* des séparations structurelles, et non comme des positions autour desquelles
|
||||||
|
* les rayons peuvent se regrouper.
|
||||||
|
*/
|
||||||
|
function distributedShelfIndexes(indexCount, slots, heightMm, plinthHeightMm, stack) {
|
||||||
|
const bodyH = (Number(heightMm) || 0) - (Number(plinthHeightMm) || 0);
|
||||||
|
const boundaries = (Array.isArray(stack?.boundariesMm) ? stack.boundariesMm : [])
|
||||||
|
.map(Number)
|
||||||
|
.filter((v) => Number.isFinite(v) && v > 0 && v < bodyH)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
if (boundaries.length === 0) {
|
||||||
|
return Array.from({ length: indexCount }, (_, i) => {
|
||||||
|
const t = ((i + 1) * (slots.length + 1)) / (indexCount + 1);
|
||||||
|
return Math.max(0, Math.min(slots.length - 1, Math.round(t) - 1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const edges = [0, ...boundaries, bodyH];
|
||||||
|
const segmentCounts = Array(edges.length - 1).fill(0);
|
||||||
|
|
||||||
|
// À chaque ajout, subdiviser l'ouverture actuellement la plus haute.
|
||||||
|
for (let shelfIdx = 0; shelfIdx < indexCount; shelfIdx++) {
|
||||||
|
let bestSegment = 0;
|
||||||
|
let largestOpening = -Infinity;
|
||||||
|
for (let segmentIdx = 0; segmentIdx < segmentCounts.length; segmentIdx++) {
|
||||||
|
const segmentHeight = edges[segmentIdx + 1] - edges[segmentIdx];
|
||||||
|
const openingHeight = segmentHeight / (segmentCounts[segmentIdx] + 1);
|
||||||
|
if (openingHeight > largestOpening) {
|
||||||
|
largestOpening = openingHeight;
|
||||||
|
bestSegment = segmentIdx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
segmentCounts[bestSegment] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = [];
|
||||||
|
for (let segmentIdx = 0; segmentIdx < segmentCounts.length; segmentIdx++) {
|
||||||
|
const count = segmentCounts[segmentIdx];
|
||||||
|
const fromMm = edges[segmentIdx];
|
||||||
|
const segmentHeight = edges[segmentIdx + 1] - fromMm;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
targets.push(fromMm + ((i + 1) * segmentHeight) / (count + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets.map((targetMm) => {
|
||||||
|
let bestIdx = 0;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
for (let i = 0; i < slots.length; i++) {
|
||||||
|
const distance = Math.abs(slots[i] - targetMm);
|
||||||
|
if (distance < bestDist) {
|
||||||
|
bestDist = distance;
|
||||||
|
bestIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestIdx;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snap the shelf with the smallest move so one lands in the structural window (System 32). */
|
||||||
|
function ensureStructuralShelfInIndexes(indexes, slots, heightMm, plinthHeightMm, shelfCount) {
|
||||||
|
if ((Number(heightMm) || 0) <= STRUCTURAL_MIN_SHELF_HEIGHT_MM || shelfCount <= 0) return indexes;
|
||||||
|
|
||||||
|
const bodyMidMm = ((Number(heightMm) || 0) - (Number(plinthHeightMm) || 0)) / 2;
|
||||||
|
const winMin = bodyMidMm - STRUCTURAL_WINDOW_HALF_MM;
|
||||||
|
const winMax = bodyMidMm + STRUCTURAL_WINDOW_HALF_MM;
|
||||||
|
|
||||||
|
if (indexes.some((idx) => slots[idx] >= winMin && slots[idx] <= winMax)) return indexes;
|
||||||
|
|
||||||
|
const structuralSlotIndices = [];
|
||||||
|
for (let si = 0; si < slots.length; si++) {
|
||||||
|
if (slots[si] >= winMin && slots[si] <= winMax) structuralSlotIndices.push(si);
|
||||||
|
}
|
||||||
|
if (structuralSlotIndices.length === 0) return indexes;
|
||||||
|
|
||||||
|
let bestMove = null;
|
||||||
|
let bestCost = Infinity;
|
||||||
|
for (let i = 0; i < shelfCount; i++) {
|
||||||
|
const [low, high] = shelfIndexValidRange(indexes, slots, i, shelfCount);
|
||||||
|
for (const si of structuralSlotIndices) {
|
||||||
|
if (si < low || si > high) continue;
|
||||||
|
const cost = Math.abs(slots[indexes[i]] - slots[si]);
|
||||||
|
if (cost < bestCost) {
|
||||||
|
bestCost = cost;
|
||||||
|
bestMove = { shelfIdx: i, slotIdx: si };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestMove) {
|
||||||
|
let closestShelf = 0;
|
||||||
|
let closestDist = Infinity;
|
||||||
|
for (let i = 0; i < shelfCount; i++) {
|
||||||
|
const d = Math.abs(slots[indexes[i]] - bodyMidMm);
|
||||||
|
if (d < closestDist) {
|
||||||
|
closestDist = d;
|
||||||
|
closestShelf = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [low, high] = shelfIndexValidRange(indexes, slots, closestShelf, shelfCount);
|
||||||
|
let bestSlot = null;
|
||||||
|
let bestSlotDist = Infinity;
|
||||||
|
for (const si of structuralSlotIndices) {
|
||||||
|
if (si < low || si > high) continue;
|
||||||
|
const d = Math.abs(slots[si] - bodyMidMm);
|
||||||
|
if (d < bestSlotDist) {
|
||||||
|
bestSlotDist = d;
|
||||||
|
bestSlot = si;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestSlot != null) bestMove = { shelfIdx: closestShelf, slotIdx: bestSlot };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestMove) return indexes;
|
||||||
|
|
||||||
|
const next = [...indexes];
|
||||||
|
next[bestMove.shelfIdx] = bestMove.slotIdx;
|
||||||
|
return applyShelfIndexOrdering(next, slots, shelfCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveShelfPositionsMm({
|
export function resolveShelfPositionsMm({
|
||||||
shelfCount,
|
shelfCount,
|
||||||
heightMm,
|
heightMm,
|
||||||
plinthHeightMm,
|
plinthHeightMm,
|
||||||
shelfPositionsMm
|
shelfPositionsMm,
|
||||||
|
stack = null
|
||||||
}) {
|
}) {
|
||||||
const n = Math.max(0, Number(shelfCount) || 0);
|
const n = Math.max(0, Number(shelfCount) || 0);
|
||||||
if (n <= 0) return [];
|
if (n <= 0) return [];
|
||||||
const slots = system32ShelfPositionsMm(heightMm, plinthHeightMm);
|
const slots = system32ShelfPositionsMm(heightMm, plinthHeightMm, stack);
|
||||||
if (slots.length <= 0) return [];
|
if (slots.length <= 0) return [];
|
||||||
if (n >= slots.length) return slots.slice(0, n);
|
if (n >= slots.length) return slots.slice(0, n);
|
||||||
|
|
||||||
@@ -113,10 +444,7 @@ export function resolveShelfPositionsMm({
|
|||||||
if (Array.isArray(shelfPositionsMm) && shelfPositionsMm.length === n) {
|
if (Array.isArray(shelfPositionsMm) && shelfPositionsMm.length === n) {
|
||||||
indexes = shelfPositionsMm.map(toNearestIndex);
|
indexes = shelfPositionsMm.map(toNearestIndex);
|
||||||
} else {
|
} else {
|
||||||
indexes = Array.from({ length: n }, (_, i) => {
|
indexes = distributedShelfIndexes(n, slots, heightMm, plinthHeightMm, stack);
|
||||||
const t = ((i + 1) * (slots.length + 1)) / (n + 1);
|
|
||||||
return Math.max(0, Math.min(slots.length - 1, Math.round(t) - 1));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
@@ -128,9 +456,59 @@ export function resolveShelfPositionsMm({
|
|||||||
indexes[i] = Math.min(indexes[i], indexes[i + 1] - 1);
|
indexes[i] = Math.min(indexes[i], indexes[i + 1] - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Colonne empilée: la jonction bas/haut est déjà structurelle → pas de rayon structurel imposé.
|
||||||
|
if (!stack) {
|
||||||
|
indexes = ensureStructuralShelfInIndexes(indexes, slots, heightMm, plinthHeightMm, n);
|
||||||
|
}
|
||||||
|
|
||||||
return indexes.map((idx) => slots[idx]);
|
return indexes.map((idx) => slots[idx]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Penderie — hauteurs libres sous tringle (règle 4.B): vestes courtes / manteaux longs. */
|
||||||
|
export const ROD_CLEAR_HEIGHT_MM = { short: 1100, long: 1600 };
|
||||||
|
/** Axe de tringle sous le dessus du compartiment de penderie. */
|
||||||
|
export const ROD_OFFSET_FROM_TOP_MM = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compartiment de penderie d'une colonne avec tringle: le block le plus bas offrant la hauteur
|
||||||
|
* vestes courtes (accessible), à défaut le plus haut; entre le dernier obstacle (fond, tiroirs, rayon)
|
||||||
|
* et le dessus du block. Hauteurs en mm depuis le bas de caisse; `clearMm` = hauteur libre sous la tringle.
|
||||||
|
*/
|
||||||
|
export function moduleHangingZoneMm(mod, config) {
|
||||||
|
if (!mod?.hasRod) return null;
|
||||||
|
const T = PANEL.structure;
|
||||||
|
const heights = moduleBlockHeightsMm(mod, config);
|
||||||
|
const rawClear = (h) => h - 2 * T - ROD_OFFSET_FROM_TOP_MM;
|
||||||
|
let blockIndex = heights.findIndex((h) => rawClear(h) >= ROD_CLEAR_HEIGHT_MM.short);
|
||||||
|
if (blockIndex < 0) {
|
||||||
|
blockIndex = 0;
|
||||||
|
heights.forEach((h, i) => { if (h > heights[blockIndex]) blockIndex = i; });
|
||||||
|
}
|
||||||
|
const blockBottomMm = heights.slice(0, blockIndex).reduce((sum, h) => sum + h, 0);
|
||||||
|
const topMm = blockBottomMm + heights[blockIndex] - T;
|
||||||
|
let floorMm = blockBottomMm + T;
|
||||||
|
|
||||||
|
const drawers = Math.max(0, Number(mod.drawers) || 0);
|
||||||
|
if (blockIndex === 0 && drawers > 0) {
|
||||||
|
floorMm += drawerStackHeightMm(drawers, config?.hardware?.drawerHeightsMm);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shelfCount = Math.max(minShelvesForColumn(config?.heightMm, mod, config), Number(mod.shelves) || 0);
|
||||||
|
const shelves = resolveShelfPositionsMm({
|
||||||
|
shelfCount,
|
||||||
|
heightMm: config?.heightMm,
|
||||||
|
plinthHeightMm: config?.plinthHeightMm ?? 100,
|
||||||
|
shelfPositionsMm: mod.shelfPositionsMm,
|
||||||
|
stack: moduleStackInfo(mod, config)
|
||||||
|
});
|
||||||
|
for (const shelfMm of shelves) {
|
||||||
|
if (shelfMm > floorMm && shelfMm < topMm) floorMm = shelfMm + T / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rodMm = topMm - ROD_OFFSET_FROM_TOP_MM;
|
||||||
|
return { blockIndex, floorMm, topMm, rodMm, clearMm: Math.max(0, Math.round(rodMm - floorMm)) };
|
||||||
|
}
|
||||||
|
|
||||||
// Column element types -- single source of truth
|
// Column element types -- single source of truth
|
||||||
export const COLUMN_TYPES = [
|
export const COLUMN_TYPES = [
|
||||||
{ key: 'penderie', label: 'Penderie', icon: '\u{1F454}', shelves: 1, drawers: 0, hasRod: true, defaultWidth: 600, minWidth: 400, maxWidth: 1200 },
|
{ key: 'penderie', label: 'Penderie', icon: '\u{1F454}', shelves: 1, drawers: 0, hasRod: true, defaultWidth: 600, minWidth: 400, maxWidth: 1200 },
|
||||||
@@ -193,7 +571,19 @@ export const DEFAULT_CONFIG = {
|
|||||||
},
|
},
|
||||||
sanitaryGapMm: 30,
|
sanitaryGapMm: 30,
|
||||||
modules: [],
|
modules: [],
|
||||||
columnMode: 'separations', // separations | block
|
// Ouvertures horizontales couvrant plusieurs colonnes contiguës.
|
||||||
|
horizontalSpans: [],
|
||||||
|
// Dos (panneau arrière) optionnel, réglable PAR ZONE (mod.hasBack = zone basse, mod.hasBackTop = zone haute si empilage).
|
||||||
|
// Construction: dos → caisson block ; sinon côté (montant extérieur) ou rayonnage → block ; sinon séparations.
|
||||||
|
// `hasBack` global = défaut pour les nouvelles colonnes / sessions legacy.
|
||||||
|
hasBack: false,
|
||||||
|
columnMode: 'separations', // separations | block — block si TOUTES les zones ont un dos
|
||||||
|
// Empilage vertical PAR COLONNE — N blocks: mod.blockHeightsMm (hauteurs bas → haut) + mod.blockBacks.
|
||||||
|
// Legacy 2 blocks: mod.stacked + mod.stackBottomHeightMm; les valeurs globales ci-dessous = DÉFAUTS.
|
||||||
|
// Règles: colonne d'extrémité empilée → habillage de ce côté obligatoire; dernier block sans dos → dessus de doublage;
|
||||||
|
// hauteur de caisse > panneau max (MAX_PANEL_DIMENSION_MM) → empilage obligatoire (nombre de blocks minimum imposé).
|
||||||
|
stackEnabled: false,
|
||||||
|
stackBottomHeightMm: 1000, // hauteur caisse du block bas (hors plinthe, legacy 2 blocks)
|
||||||
colors: { ...DEFAULT_COLORS },
|
colors: { ...DEFAULT_COLORS },
|
||||||
hardware: {
|
hardware: {
|
||||||
handleVariantId: null,
|
handleVariantId: null,
|
||||||
@@ -218,6 +608,19 @@ export const DEFAULT_CONFIG = {
|
|||||||
serviceOptionIds: []
|
serviceOptionIds: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Champs de config qui constituent une disposition type (structure, sans matériaux ni quincaillerie). */
|
||||||
|
export const LAYOUT_PRESET_KEYS = [
|
||||||
|
'widthMm', 'heightMm', 'depthMm', 'plinthHeightMm',
|
||||||
|
'hasBack', 'stackEnabled', 'stackBottomHeightMm',
|
||||||
|
'modules', 'horizontalSpans'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function pickLayoutPreset(config) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
LAYOUT_PRESET_KEYS.filter((key) => config?.[key] !== undefined).map((key) => [key, config[key]])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const STEPS = [
|
export const STEPS = [
|
||||||
{ id: 1, key: 'espace', label: 'Espace' },
|
{ id: 1, key: 'espace', label: 'Espace' },
|
||||||
{ id: 2, key: 'colonnes', label: 'Colonnes' },
|
{ id: 2, key: 'colonnes', label: 'Colonnes' },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
const SESSION_STEP_KEY = 'apex_wardrobe_step';
|
const SESSION_STEP_KEY = 'apex_wardrobe_step';
|
||||||
const PLANS_OPEN_KEY = 'apex.plans.open';
|
const PLANS_OPEN_KEY = 'apex.plans.open';
|
||||||
@@ -22,6 +22,13 @@ export default function useConfiguratorUI() {
|
|||||||
try { return Number(sessionStorage.getItem(SESSION_STEP_KEY)) || 1; } catch { return 1; }
|
try { return Number(sessionStorage.getItem(SESSION_STEP_KEY)) || 1; } catch { return 1; }
|
||||||
});
|
});
|
||||||
const [expandedColumn, setExpandedColumn] = useState(null);
|
const [expandedColumn, setExpandedColumn] = useState(null);
|
||||||
|
const [expandedBlock, setExpandedBlock] = useState(0);
|
||||||
|
const [selectedHorizontalSpanId, setSelectedHorizontalSpanId] = useState(null);
|
||||||
|
const [horizontalSpanDraft, setHorizontalSpanDraft] = useState(null);
|
||||||
|
const [horizontalSpanMessage, setHorizontalSpanMessage] = useState('');
|
||||||
|
|
||||||
|
const activeStepRef = useRef(activeStep);
|
||||||
|
activeStepRef.current = activeStep;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try { sessionStorage.setItem(SESSION_STEP_KEY, String(activeStep)); } catch {}
|
try { sessionStorage.setItem(SESSION_STEP_KEY, String(activeStep)); } catch {}
|
||||||
@@ -67,10 +74,30 @@ export default function useConfiguratorUI() {
|
|||||||
const [orderSent, setOrderSent] = useState(false);
|
const [orderSent, setOrderSent] = useState(false);
|
||||||
const [lastOrderId, setLastOrderId] = useState(null);
|
const [lastOrderId, setLastOrderId] = useState(null);
|
||||||
|
|
||||||
|
// Matériaux (4) and Finaliser (6) use the 3D click to color a piece.
|
||||||
|
const staysOnColorStep = () => activeStepRef.current === 4 || activeStepRef.current === 6;
|
||||||
|
|
||||||
// Module click from 3D view
|
// Module click from 3D view
|
||||||
const handleModuleClick = useCallback((moduleIndex) => {
|
const handleModuleClick = useCallback((moduleIndex, blockIndex = 0) => {
|
||||||
setExpandedColumn(prev => prev === moduleIndex ? null : moduleIndex);
|
if (staysOnColorStep()) return;
|
||||||
|
setSelectedHorizontalSpanId(null);
|
||||||
|
setHorizontalSpanDraft(null);
|
||||||
|
setHorizontalSpanMessage('');
|
||||||
|
setExpandedBlock(blockIndex);
|
||||||
|
setExpandedColumn(prev => (
|
||||||
|
prev === moduleIndex && expandedBlock === blockIndex ? null : moduleIndex
|
||||||
|
));
|
||||||
setActiveStep(2); // jump to columns step when clicking a module
|
setActiveStep(2); // jump to columns step when clicking a module
|
||||||
|
}, [expandedBlock]);
|
||||||
|
|
||||||
|
// Kept as a dedicated callback so the 3D preview can select spans later
|
||||||
|
// without changing the existing module-click contract.
|
||||||
|
const handleHorizontalSpanSelect = useCallback((spanId) => {
|
||||||
|
if (staysOnColorStep()) return;
|
||||||
|
setSelectedHorizontalSpanId(spanId ?? null);
|
||||||
|
setHorizontalSpanDraft(null);
|
||||||
|
setHorizontalSpanMessage('');
|
||||||
|
setActiveStep(2);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Element click from 3D view
|
// Element click from 3D view
|
||||||
@@ -101,6 +128,10 @@ export default function useConfiguratorUI() {
|
|||||||
return {
|
return {
|
||||||
activeStep, setActiveStep,
|
activeStep, setActiveStep,
|
||||||
expandedColumn, setExpandedColumn,
|
expandedColumn, setExpandedColumn,
|
||||||
|
expandedBlock, setExpandedBlock,
|
||||||
|
selectedHorizontalSpanId, setSelectedHorizontalSpanId,
|
||||||
|
horizontalSpanDraft, setHorizontalSpanDraft,
|
||||||
|
horizontalSpanMessage, setHorizontalSpanMessage,
|
||||||
showMaterialPicker, setShowMaterialPicker,
|
showMaterialPicker, setShowMaterialPicker,
|
||||||
showMaterialModal, setShowMaterialModal,
|
showMaterialModal, setShowMaterialModal,
|
||||||
showColorPicker, setShowColorPicker,
|
showColorPicker, setShowColorPicker,
|
||||||
@@ -118,6 +149,7 @@ export default function useConfiguratorUI() {
|
|||||||
orderSent, setOrderSent,
|
orderSent, setOrderSent,
|
||||||
lastOrderId, setLastOrderId,
|
lastOrderId, setLastOrderId,
|
||||||
handleModuleClick,
|
handleModuleClick,
|
||||||
|
handleHorizontalSpanSelect,
|
||||||
openColorPicker,
|
openColorPicker,
|
||||||
closeColorPicker,
|
closeColorPicker,
|
||||||
openMaterialDetail,
|
openMaterialDetail,
|
||||||
|
|||||||
@@ -2,14 +2,107 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|||||||
import { fetchFinishes, fetchServiceOptions, calculatePrice } from '../api/client';
|
import { fetchFinishes, fetchServiceOptions, calculatePrice } from '../api/client';
|
||||||
import {
|
import {
|
||||||
COLUMN_TYPES, DEFAULT_CYCLE, DEFAULT_COLORS, DEFAULT_CONFIG, maxShelfGapMm, SHELF_GAP_MM,
|
COLUMN_TYPES, DEFAULT_CYCLE, DEFAULT_COLORS, DEFAULT_CONFIG, maxShelfGapMm, SHELF_GAP_MM,
|
||||||
minShelvesForStructuralHeight, resolveShelfPositionsMm
|
minShelvesForStructuralHeight, minShelvesForColumn, moduleStackInfo, resolveShelfPositionsMm,
|
||||||
|
moduleBlockHeightsMm, moduleBlockBacks, moduleBlockDoors, carcassBodyMm, maxBlocksForBody,
|
||||||
|
outerColumnStacked, pickLayoutPreset, moduleHangingZoneMm, ROD_CLEAR_HEIGHT_MM,
|
||||||
|
DIMENSION_RANGES
|
||||||
} from '../constants/wardrobe';
|
} from '../constants/wardrobe';
|
||||||
|
import {
|
||||||
|
addUpperBlockAboveHorizontalSpan,
|
||||||
|
getModuleVerticalBoundariesMm,
|
||||||
|
getHorizontalSpanUpperBlockState,
|
||||||
|
normalizeAxiloTargetHeightMm,
|
||||||
|
normalizeHorizontalSpan,
|
||||||
|
reconcileHorizontalSpans,
|
||||||
|
resizeHorizontalSpanUpperBlock,
|
||||||
|
synchronizeModuleSpanBoundaries
|
||||||
|
} from '@shared/manufacturing.js';
|
||||||
|
import { proposeLayout, layoutModulesSignature } from '../constants/layoutProposal';
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||||
const SESSION_KEY = 'apex_wardrobe_config';
|
const SESSION_KEY = 'apex_wardrobe_config';
|
||||||
const SESSION_COLS_KEY = 'apex_wardrobe_numcols';
|
const SESSION_COLS_KEY = 'apex_wardrobe_numcols';
|
||||||
const MAX_CONFIG_UNDO = 50;
|
const MAX_CONFIG_UNDO = 50;
|
||||||
|
|
||||||
|
function createHorizontalSpanId() {
|
||||||
|
if (globalThis.crypto?.randomUUID) return `span-${globalThis.crypto.randomUUID()}`;
|
||||||
|
return `span-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameNumberArray(left, right) {
|
||||||
|
return (
|
||||||
|
Array.isArray(left) &&
|
||||||
|
Array.isArray(right) &&
|
||||||
|
left.length === right.length &&
|
||||||
|
left.every((value, index) => Number(value) === Number(right[index]))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Disposition « suivie »: tant que les colonnes correspondent à l'empreinte `autoLayoutSignature`
|
||||||
|
* (aucune personnalisation), elles sont recalculées à chaque changement de cotes selon `layoutFollow`:
|
||||||
|
* { mode: 'auto' } → proposition automatique; { mode: 'preset', presetId, modules } → disposition type
|
||||||
|
* remise à l'échelle, ou proposition automatique (fellBack) si elle ne respecte plus les règles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Pré-normalise blocks et rayons comme les effets de cohérence, pour une empreinte stable. */
|
||||||
|
function fitLayoutModules(c, sourceModules) {
|
||||||
|
const sourceWidthMm = sourceModules.reduce((sum, m) => sum + (Number(m.widthMm) || 0), 0) || c.widthMm;
|
||||||
|
const scale = c.widthMm / sourceWidthMm;
|
||||||
|
let allocatedMm = 0;
|
||||||
|
return sourceModules.map((module, index) => {
|
||||||
|
const isLast = index === sourceModules.length - 1;
|
||||||
|
const widthMm = isLast ? c.widthMm - allocatedMm : Math.round((Number(module.widthMm) || 0) * scale);
|
||||||
|
allocatedMm += widthMm;
|
||||||
|
const base = { ...module, widthMm, position: index, shelfPositionsMm: undefined };
|
||||||
|
const sized = { ...base, blockHeightsMm: moduleBlockHeightsMm(base, c) };
|
||||||
|
return { ...sized, shelves: Math.max(minShelvesForColumn(c.heightMm, sized, c), Number(module.shelves) || 0) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutFits(c, modules) {
|
||||||
|
return modules.every((m) => {
|
||||||
|
const typeDef = COLUMN_TYPES.find((t) => t.key === m.type);
|
||||||
|
if (typeDef && (m.widthMm < typeDef.minWidth || m.widthMm > typeDef.maxWidth)) return false;
|
||||||
|
if (!m.hasRod) return true;
|
||||||
|
const zone = moduleHangingZoneMm(m, c);
|
||||||
|
return c.depthMm >= 400 && zone.clearMm >= ROD_CLEAR_HEIGHT_MM.short;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function withLayout(c, rawModules, layoutFollow) {
|
||||||
|
const modules = rawModules.map((module) => {
|
||||||
|
const sized = { ...module, blockHeightsMm: moduleBlockHeightsMm(module, c) };
|
||||||
|
return { ...sized, shelves: Math.max(minShelvesForColumn(c.heightMm, sized, c), Number(module.shelves) || 0) };
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
modules,
|
||||||
|
stackEnabled: modules.some((m) => (m.blockHeightsMm || []).length >= 2),
|
||||||
|
horizontalSpans: [],
|
||||||
|
layoutFollow,
|
||||||
|
autoLayoutSignature: layoutModulesSignature(modules)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withAutoLayout(c) {
|
||||||
|
return withLayout(c, proposeLayout(c).modules, { mode: 'auto' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function withFollowedLayout(c) {
|
||||||
|
const follow = c.layoutFollow;
|
||||||
|
if (follow?.mode === 'preset' && Array.isArray(follow.modules)) {
|
||||||
|
const modules = fitLayoutModules(c, follow.modules);
|
||||||
|
if (layoutFits(c, modules)) return withLayout(c, modules, { ...follow, fellBack: false });
|
||||||
|
return withLayout(c, proposeLayout(c).modules, { ...follow, fellBack: true });
|
||||||
|
}
|
||||||
|
return withAutoLayout(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLayoutFollowing(c) {
|
||||||
|
return !!c.autoLayoutSignature && layoutModulesSignature(c.modules) === c.autoLayoutSignature;
|
||||||
|
}
|
||||||
|
|
||||||
function loadSavedConfig() {
|
function loadSavedConfig() {
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||||
@@ -33,6 +126,8 @@ export default function useWardrobeConfig() {
|
|||||||
|
|
||||||
const pushUndoSnapshot = useCallback(() => {
|
const pushUndoSnapshot = useCallback(() => {
|
||||||
const snap = JSON.parse(JSON.stringify(configRef.current));
|
const snap = JSON.parse(JSON.stringify(configRef.current));
|
||||||
|
const latest = undoPastRef.current[undoPastRef.current.length - 1];
|
||||||
|
if (latest && JSON.stringify(latest) === JSON.stringify(snap)) return;
|
||||||
undoPastRef.current.push(snap);
|
undoPastRef.current.push(snap);
|
||||||
if (undoPastRef.current.length > MAX_CONFIG_UNDO) undoPastRef.current.shift();
|
if (undoPastRef.current.length > MAX_CONFIG_UNDO) undoPastRef.current.shift();
|
||||||
undoFutureRef.current = [];
|
undoFutureRef.current = [];
|
||||||
@@ -43,6 +138,7 @@ export default function useWardrobeConfig() {
|
|||||||
if (undoPastRef.current.length === 0) return;
|
if (undoPastRef.current.length === 0) return;
|
||||||
const prev = undoPastRef.current.pop();
|
const prev = undoPastRef.current.pop();
|
||||||
undoFutureRef.current.push(JSON.parse(JSON.stringify(configRef.current)));
|
undoFutureRef.current.push(JSON.parse(JSON.stringify(configRef.current)));
|
||||||
|
configRef.current = prev;
|
||||||
setConfig(prev);
|
setConfig(prev);
|
||||||
setUndoRevision((n) => n + 1);
|
setUndoRevision((n) => n + 1);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -51,9 +147,79 @@ export default function useWardrobeConfig() {
|
|||||||
if (undoFutureRef.current.length === 0) return;
|
if (undoFutureRef.current.length === 0) return;
|
||||||
const next = undoFutureRef.current.pop();
|
const next = undoFutureRef.current.pop();
|
||||||
undoPastRef.current.push(JSON.parse(JSON.stringify(configRef.current)));
|
undoPastRef.current.push(JSON.parse(JSON.stringify(configRef.current)));
|
||||||
|
configRef.current = next;
|
||||||
setConfig(next);
|
setConfig(next);
|
||||||
setUndoRevision((n) => n + 1);
|
setUndoRevision((n) => n + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setConfigUndoable = useCallback((updater) => {
|
||||||
|
pushUndoSnapshot();
|
||||||
|
setConfig(updater);
|
||||||
|
}, [pushUndoSnapshot]);
|
||||||
|
|
||||||
|
/** Une armoire restaurée est figée (pas de suivi des cotes), sauf `buildFollowed` (disposition type). */
|
||||||
|
const restoreConfig = useCallback((savedConfig, buildFollowed = null) => {
|
||||||
|
if (!savedConfig || typeof savedConfig !== 'object' || Array.isArray(savedConfig)) return false;
|
||||||
|
|
||||||
|
const legacyBack = savedConfig.hasBack ?? (savedConfig.columnMode === 'block');
|
||||||
|
const restored = {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...savedConfig,
|
||||||
|
horizontalSpans: Array.isArray(savedConfig.horizontalSpans) ? savedConfig.horizontalSpans : [],
|
||||||
|
modules: (savedConfig.modules || []).map((module) => {
|
||||||
|
const hasBack = typeof module.hasBack === 'boolean' ? module.hasBack : legacyBack;
|
||||||
|
return {
|
||||||
|
...module,
|
||||||
|
hasBack,
|
||||||
|
hasBackTop: typeof module.hasBackTop === 'boolean' ? module.hasBackTop : hasBack
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
nicheMeasurements: {
|
||||||
|
...DEFAULT_CONFIG.nicheMeasurements,
|
||||||
|
...(savedConfig.nicheMeasurements || {})
|
||||||
|
},
|
||||||
|
hardware: {
|
||||||
|
...DEFAULT_CONFIG.hardware,
|
||||||
|
...(savedConfig.hardware || {})
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
...DEFAULT_CONFIG.colors,
|
||||||
|
...(savedConfig.colors || {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
restored.horizontalSpans = reconcileHorizontalSpans(restored.horizontalSpans, restored);
|
||||||
|
const next = buildFollowed
|
||||||
|
? buildFollowed(restored)
|
||||||
|
: { ...restored, layoutFollow: null, autoLayoutSignature: null };
|
||||||
|
|
||||||
|
pushUndoSnapshot();
|
||||||
|
configRef.current = next;
|
||||||
|
setConfig(next);
|
||||||
|
setNumColumns(Math.max(1, next.modules.length || 1));
|
||||||
|
return true;
|
||||||
|
}, [pushUndoSnapshot]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applique une disposition type (structure seule) en conservant matériaux, quincaillerie et type de pose.
|
||||||
|
* En niche, les cotes sont imposées par la niche: les largeurs de colonnes sont remises à l'échelle.
|
||||||
|
* La disposition suit ensuite les changements de cotes tant que les colonnes ne sont pas personnalisées.
|
||||||
|
*/
|
||||||
|
const applyLayoutPreset = useCallback((layout, presetId = null) => {
|
||||||
|
if (!layout || !Array.isArray(layout.modules) || layout.modules.length === 0) return false;
|
||||||
|
const current = configRef.current;
|
||||||
|
const structure = pickLayoutPreset(layout);
|
||||||
|
if (current.installationType === 'niche') {
|
||||||
|
delete structure.widthMm;
|
||||||
|
delete structure.heightMm;
|
||||||
|
delete structure.depthMm;
|
||||||
|
}
|
||||||
|
const follow = { mode: 'preset', presetId, modules: layout.modules, fellBack: false };
|
||||||
|
return restoreConfig(
|
||||||
|
{ ...current, ...structure },
|
||||||
|
(restored) => withLayout(restored, fitLayoutModules(restored, layout.modules), follow)
|
||||||
|
);
|
||||||
|
}, [restoreConfig]);
|
||||||
|
|
||||||
const [finishes, setFinishes] = useState([]);
|
const [finishes, setFinishes] = useState([]);
|
||||||
const [serviceOptions, setServiceOptions] = useState([]);
|
const [serviceOptions, setServiceOptions] = useState([]);
|
||||||
const [priceResult, setPriceResult] = useState(null);
|
const [priceResult, setPriceResult] = useState(null);
|
||||||
@@ -78,9 +244,23 @@ export default function useWardrobeConfig() {
|
|||||||
|
|
||||||
// Keep extended configuration schema compatible with older saved sessions.
|
// Keep extended configuration schema compatible with older saved sessions.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setConfig((c) => ({
|
setConfig((c) => {
|
||||||
|
// Legacy sessions: global hasBack derived from the chosen assembly mode,
|
||||||
|
// then seeded on each column (dos is now per colonne).
|
||||||
|
const legacyBack = c.hasBack ?? (c.columnMode === 'block');
|
||||||
|
const next = {
|
||||||
...DEFAULT_CONFIG,
|
...DEFAULT_CONFIG,
|
||||||
...c,
|
...c,
|
||||||
|
hasBack: legacyBack,
|
||||||
|
horizontalSpans: Array.isArray(c.horizontalSpans) ? c.horizontalSpans : [],
|
||||||
|
modules: (c.modules || []).map((m) => {
|
||||||
|
const hb = typeof m.hasBack === 'boolean' ? m.hasBack : legacyBack;
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
hasBack: hb,
|
||||||
|
hasBackTop: typeof m.hasBackTop === 'boolean' ? m.hasBackTop : hb
|
||||||
|
};
|
||||||
|
}),
|
||||||
nicheMeasurements: {
|
nicheMeasurements: {
|
||||||
...DEFAULT_CONFIG.nicheMeasurements,
|
...DEFAULT_CONFIG.nicheMeasurements,
|
||||||
...(c.nicheMeasurements || {})
|
...(c.nicheMeasurements || {})
|
||||||
@@ -89,7 +269,10 @@ export default function useWardrobeConfig() {
|
|||||||
...DEFAULT_CONFIG.hardware,
|
...DEFAULT_CONFIG.hardware,
|
||||||
...(c.hardware || {})
|
...(c.hardware || {})
|
||||||
}
|
}
|
||||||
}));
|
};
|
||||||
|
next.horizontalSpans = reconcileHorizontalSpans(next.horizontalSpans, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
// Run once at startup for legacy session payloads.
|
// Run once at startup for legacy session payloads.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -104,10 +287,11 @@ export default function useWardrobeConfig() {
|
|||||||
setConfig((c) => {
|
setConfig((c) => {
|
||||||
const h = c.heightMm;
|
const h = c.heightMm;
|
||||||
const pl = c.plinthHeightMm ?? 100;
|
const pl = c.plinthHeightMm ?? 100;
|
||||||
const minS = minShelvesForStructuralHeight(h);
|
|
||||||
const list = c.modules || [];
|
const list = c.modules || [];
|
||||||
let touched = false;
|
let touched = false;
|
||||||
const modules = list.map((mod) => {
|
const modules = list.map((mod) => {
|
||||||
|
// Colonne empilée: la jonction bas/haut est structurelle → pas de rayon minimum imposé.
|
||||||
|
const minS = minShelvesForColumn(h, mod, c);
|
||||||
const nRaw = mod.shelves || 0;
|
const nRaw = mod.shelves || 0;
|
||||||
const n = Math.max(minS, nRaw);
|
const n = Math.max(minS, nRaw);
|
||||||
let nextMod = mod;
|
let nextMod = mod;
|
||||||
@@ -119,7 +303,8 @@ export default function useWardrobeConfig() {
|
|||||||
shelfCount: n,
|
shelfCount: n,
|
||||||
heightMm: h,
|
heightMm: h,
|
||||||
plinthHeightMm: pl,
|
plinthHeightMm: pl,
|
||||||
shelfPositionsMm: nextMod.shelfPositionsMm
|
shelfPositionsMm: nextMod.shelfPositionsMm,
|
||||||
|
stack: moduleStackInfo(mod, c)
|
||||||
});
|
});
|
||||||
const nextShelfPositions = n > 0 ? resolvedShelfPositions : [];
|
const nextShelfPositions = n > 0 ? resolvedShelfPositions : [];
|
||||||
const prevShelfPositions = Array.isArray(nextMod.shelfPositionsMm) ? nextMod.shelfPositionsMm : [];
|
const prevShelfPositions = Array.isArray(nextMod.shelfPositionsMm) ? nextMod.shelfPositionsMm : [];
|
||||||
@@ -174,6 +359,94 @@ export default function useWardrobeConfig() {
|
|||||||
next = { ...next, doorType: 'hinged', slidingPanelsCount: 2 };
|
next = { ...next, doorType: 'hinged', slidingPanelsCount: 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Empilage vertical PAR COLONNE — modèle N blocks (mod.blockHeightsMm / mod.blockBacks).
|
||||||
|
// config.stackEnabled / config.stackBottomHeightMm = défauts legacy pour colonnes sans valeur.
|
||||||
|
// Hauteur > panneau max → nombre minimum de blocks imposé par colonne.
|
||||||
|
let backTouched = false;
|
||||||
|
const backModules = (Array.isArray(next.modules) ? next.modules : []).map((mod) => {
|
||||||
|
const heights = moduleBlockHeightsMm(mod, next);
|
||||||
|
const backs = moduleBlockBacks(mod, next, heights.length);
|
||||||
|
const doors = moduleBlockDoors(mod, next, heights.length);
|
||||||
|
const stacked = heights.length >= 2;
|
||||||
|
const prevHeights = Array.isArray(mod.blockHeightsMm) ? mod.blockHeightsMm : null;
|
||||||
|
const blockGeometryChanged = !sameNumberArray(prevHeights, heights);
|
||||||
|
const prevBacks = Array.isArray(mod.blockBacks) ? mod.blockBacks : null;
|
||||||
|
const prevDoors = Array.isArray(mod.blockDoors) ? mod.blockDoors : null;
|
||||||
|
const same =
|
||||||
|
prevHeights && prevHeights.length === heights.length &&
|
||||||
|
prevHeights.every((v, k) => v === heights[k]) &&
|
||||||
|
prevBacks && prevBacks.length === backs.length &&
|
||||||
|
prevBacks.every((v, k) => v === backs[k]) &&
|
||||||
|
prevDoors && prevDoors.length === doors.length &&
|
||||||
|
prevDoors.every((v, k) => v === doors[k]) &&
|
||||||
|
mod.hasBack === backs[0] &&
|
||||||
|
mod.hasBackTop === backs[backs.length - 1] &&
|
||||||
|
mod.stacked === stacked &&
|
||||||
|
mod.stackBottomHeightMm === heights[0];
|
||||||
|
if (same) return mod;
|
||||||
|
backTouched = true;
|
||||||
|
return {
|
||||||
|
...mod,
|
||||||
|
blockHeightsMm: heights,
|
||||||
|
blockBacks: backs,
|
||||||
|
blockDoors: doors,
|
||||||
|
// Miroirs legacy (anciens chemins de code / configs sauvegardées).
|
||||||
|
hasBack: backs[0],
|
||||||
|
hasBackTop: backs[backs.length - 1],
|
||||||
|
stacked,
|
||||||
|
stackBottomHeightMm: heights[0],
|
||||||
|
shelfPositionsMm: blockGeometryChanged
|
||||||
|
? resolveShelfPositionsMm({
|
||||||
|
shelfCount: mod.shelves ?? 0,
|
||||||
|
heightMm: next.heightMm,
|
||||||
|
plinthHeightMm: next.plinthHeightMm ?? 100,
|
||||||
|
shelfPositionsMm: undefined,
|
||||||
|
stack: heights.length >= 2
|
||||||
|
? moduleStackInfo({ ...mod, blockHeightsMm: heights }, next)
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
: mod.shelfPositionsMm
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const allBack =
|
||||||
|
backModules.length > 0 &&
|
||||||
|
backModules.every((m) => (m.blockBacks || []).every(Boolean));
|
||||||
|
const expectedColumnMode = allBack ? 'block' : 'separations';
|
||||||
|
let backModeChanged = backTouched || next.columnMode !== expectedColumnMode;
|
||||||
|
if (backModeChanged) {
|
||||||
|
next = { ...next, modules: backModules, columnMode: expectedColumnMode };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Habillages obligatoires (installation libre):
|
||||||
|
// - colonne d'extrémité empilée → habillage de ce côté seulement
|
||||||
|
// - une zone du dessus (dernier block) sans dos → dessus de doublage obligatoire
|
||||||
|
if (next.installationType === 'free' && backModules.length > 0) {
|
||||||
|
const sideLeftMandatory = outerColumnStacked(backModules, next, 'left');
|
||||||
|
const sideRightMandatory = outerColumnStacked(backModules, next, 'right');
|
||||||
|
const topDoublageMandatory = backModules.some((m) => {
|
||||||
|
const backs = m.blockBacks || [];
|
||||||
|
return backs.length === 0 ? !m.hasBack : !backs[backs.length - 1];
|
||||||
|
});
|
||||||
|
const sideDefaults = next.sideFinishesEnabled !== false;
|
||||||
|
const patch = {};
|
||||||
|
if (sideLeftMandatory) {
|
||||||
|
if (next.exteriorFinishesEnabled === false) patch.exteriorFinishesEnabled = true;
|
||||||
|
if ((next.sideFinishLeftEnabled ?? sideDefaults) === false) patch.sideFinishLeftEnabled = true;
|
||||||
|
}
|
||||||
|
if (sideRightMandatory) {
|
||||||
|
if (next.exteriorFinishesEnabled === false) patch.exteriorFinishesEnabled = true;
|
||||||
|
if ((next.sideFinishRightEnabled ?? sideDefaults) === false) patch.sideFinishRightEnabled = true;
|
||||||
|
}
|
||||||
|
if (topDoublageMandatory) {
|
||||||
|
if (next.exteriorFinishesEnabled === false) patch.exteriorFinishesEnabled = true;
|
||||||
|
if (next.topFinishEnabled === false) patch.topFinishEnabled = true;
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length > 0) {
|
||||||
|
next = { ...next, ...patch };
|
||||||
|
backModeChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const modules = Array.isArray(next.modules) ? [...next.modules] : [];
|
const modules = Array.isArray(next.modules) ? [...next.modules] : [];
|
||||||
const rawDrawerType = next.hardware?.drawerType || 'visible';
|
const rawDrawerType = next.hardware?.drawerType || 'visible';
|
||||||
const drawerType =
|
const drawerType =
|
||||||
@@ -183,22 +456,29 @@ export default function useWardrobeConfig() {
|
|||||||
// Drawers constraints: from bottom, depth 400..650 by 50, and drawer columns max 1000 mm.
|
// Drawers constraints: from bottom, depth 400..650 by 50, and drawer columns max 1000 mm.
|
||||||
const constrainedModules = modules.map((mod) => {
|
const constrainedModules = modules.map((mod) => {
|
||||||
const hasDrawers = (mod.drawers || 0) > 0 || mod.type === 'tiroirs';
|
const hasDrawers = (mod.drawers || 0) > 0 || mod.type === 'tiroirs';
|
||||||
if (!hasDrawers) return mod;
|
const rawModuleDrawerType = mod.drawerType ?? rawDrawerType;
|
||||||
const nextMaxWidth = Math.min(1000, mod.maxWidth || 1200);
|
const moduleDrawerType =
|
||||||
|
rawModuleDrawerType === 'hidden' || rawModuleDrawerType === 'hidden_with_handle'
|
||||||
|
? 'hidden'
|
||||||
|
: 'visible';
|
||||||
|
const nextMaxWidth = hasDrawers ? Math.min(1000, mod.maxWidth || 1200) : mod.maxWidth;
|
||||||
// Keep same reference when nothing changes to avoid render loops/flicker.
|
// Keep same reference when nothing changes to avoid render loops/flicker.
|
||||||
if (mod.maxWidth === nextMaxWidth) return mod;
|
if (mod.maxWidth === nextMaxWidth && mod.drawerType === moduleDrawerType) return mod;
|
||||||
return {
|
return {
|
||||||
...mod,
|
...mod,
|
||||||
maxWidth: nextMaxWidth
|
maxWidth: nextMaxWidth,
|
||||||
|
drawerType: moduleDrawerType
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const modulesChanged = constrainedModules.some((m, i) => m !== modules[i]);
|
const modulesChanged = constrainedModules.some((m, i) => m !== modules[i]);
|
||||||
|
|
||||||
const sanitizedHandleBore = sanitizeStep(next.hardware?.handleBoreHeightMm, 600, 1600, 10);
|
const sanitizedHandleBore = sanitizeStep(next.hardware?.handleBoreHeightMm, 600, 1600, 10);
|
||||||
const sanitizedDrawerHeights = (next.hardware?.drawerHeightsMm || [90, 128, 177, 240]).slice(0, 4);
|
const sanitizedDrawerHeights = (next.hardware?.drawerHeightsMm || [90, 128, 177, 240]).slice(0, 4);
|
||||||
const allowedHeights = [60, 90, 128, 177, 240];
|
// Blum Legrabox : hauteurs de côté N/M/K/C/F. Héritage : 60 mm → 66 mm (système N).
|
||||||
|
const allowedHeights = [66, 90, 128, 177, 240];
|
||||||
const fixedDrawerHeights = sanitizedDrawerHeights.map((h) => {
|
const fixedDrawerHeights = sanitizedDrawerHeights.map((h) => {
|
||||||
return allowedHeights.includes(Number(h)) ? Number(h) : 128;
|
const v = Number(h) === 60 ? 66 : Number(h);
|
||||||
|
return allowedHeights.includes(v) ? v : 128;
|
||||||
});
|
});
|
||||||
|
|
||||||
const drawerDepthStepMm = sanitizeStep(next.depthMm - 10, 400, 650, 50);
|
const drawerDepthStepMm = sanitizeStep(next.depthMm - 10, 400, 650, 50);
|
||||||
@@ -211,14 +491,17 @@ export default function useWardrobeConfig() {
|
|||||||
const minNicheDepth = Math.min(niche.depthTopMm || next.depthMm, niche.depthMidMm || next.depthMm, niche.depthBottomMm || next.depthMm);
|
const minNicheDepth = Math.min(niche.depthTopMm || next.depthMm, niche.depthMidMm || next.depthMm, niche.depthBottomMm || next.depthMm);
|
||||||
const minNicheHeight = Math.min(niche.heightLeftMm || next.heightMm, niche.heightRightMm || next.heightMm);
|
const minNicheHeight = Math.min(niche.heightLeftMm || next.heightMm, niche.heightRightMm || next.heightMm);
|
||||||
|
|
||||||
|
const plinthHeightMm = normalizeAxiloTargetHeightMm(next.plinthHeightMm);
|
||||||
|
|
||||||
let widthMm = next.widthMm;
|
let widthMm = next.widthMm;
|
||||||
let depthMm = next.depthMm;
|
let depthMm = next.depthMm;
|
||||||
let heightMm = next.heightMm;
|
let heightMm = next.heightMm;
|
||||||
if (next.installationType === 'niche') {
|
if (next.installationType === 'niche') {
|
||||||
const gap = Number(next.sanitaryGapMm || 30);
|
const gap = Number(next.sanitaryGapMm || 30);
|
||||||
widthMm = Math.max(400, minNicheWidth - gap * 2);
|
widthMm = Math.max(DIMENSION_RANGES.widthMm.min, minNicheWidth - gap * 2);
|
||||||
depthMm = Math.max(400, minNicheDepth - gap * 2);
|
depthMm = Math.max(DIMENSION_RANGES.depthMm.min, minNicheDepth - gap * 2);
|
||||||
heightMm = Math.max(1800, minNicheHeight - gap * 2);
|
// The wardrobe sits on the floor: keep one sanitary gap above, not one above and below.
|
||||||
|
heightMm = Math.max(DIMENSION_RANGES.heightMm.min, minNicheHeight - gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hardware = {
|
const hardware = {
|
||||||
@@ -230,10 +513,12 @@ export default function useWardrobeConfig() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const changed =
|
const changed =
|
||||||
|
backModeChanged ||
|
||||||
modulesChanged ||
|
modulesChanged ||
|
||||||
widthMm !== next.widthMm ||
|
widthMm !== next.widthMm ||
|
||||||
depthMm !== next.depthMm ||
|
depthMm !== next.depthMm ||
|
||||||
heightMm !== next.heightMm ||
|
heightMm !== next.heightMm ||
|
||||||
|
plinthHeightMm !== next.plinthHeightMm ||
|
||||||
sanitizedHandleBore !== (next.hardware?.handleBoreHeightMm || 1000) ||
|
sanitizedHandleBore !== (next.hardware?.handleBoreHeightMm || 1000) ||
|
||||||
effectiveDrawerDepthMm !== next.effectiveDrawerDepthMm ||
|
effectiveDrawerDepthMm !== next.effectiveDrawerDepthMm ||
|
||||||
handleType !== (next.hardware?.handleType || 'standard') ||
|
handleType !== (next.hardware?.handleType || 'standard') ||
|
||||||
@@ -248,11 +533,12 @@ export default function useWardrobeConfig() {
|
|||||||
widthMm,
|
widthMm,
|
||||||
depthMm,
|
depthMm,
|
||||||
heightMm,
|
heightMm,
|
||||||
|
plinthHeightMm,
|
||||||
hardware,
|
hardware,
|
||||||
effectiveDrawerDepthMm
|
effectiveDrawerDepthMm
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [config.installationType, config.nicheMeasurements, config.sanitaryGapMm, config.doorType, config.depthMm, config.heightMm, config.hardware]);
|
}, [config.installationType, config.nicheMeasurements, config.sanitaryGapMm, config.doorType, config.depthMm, config.heightMm, config.plinthHeightMm, config.hardware, config.hasBack, config.columnMode, config.modules, config.exteriorFinishesEnabled, config.sideFinishesEnabled, config.sideFinishLeftEnabled, config.sideFinishRightEnabled, config.topFinishEnabled, config.stackEnabled, config.stackBottomHeightMm]);
|
||||||
|
|
||||||
// Fetch initial data
|
// Fetch initial data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -296,21 +582,18 @@ export default function useWardrobeConfig() {
|
|||||||
}, [config, loading, updatePrice]);
|
}, [config, loading, updatePrice]);
|
||||||
|
|
||||||
// Simple setter helper
|
// Simple setter helper
|
||||||
const set = useCallback((key, value) => setConfig((c) => ({ ...c, [key]: value })), []);
|
const set = useCallback((key, value) => setConfigUndoable((c) => ({ ...c, [key]: value })), [setConfigUndoable]);
|
||||||
|
|
||||||
const setDimension = useCallback((key, valueMm) => {
|
const applyDimension = useCallback((key, valueMm) => {
|
||||||
const ranges = {
|
const rules = DIMENSION_RANGES[key];
|
||||||
widthMm: { min: 400, max: 5000, step: 50 },
|
|
||||||
heightMm: { min: 1800, max: 2800, step: 50 },
|
|
||||||
depthMm: { min: 400, max: 800, step: 10 },
|
|
||||||
};
|
|
||||||
const rules = ranges[key];
|
|
||||||
if (!rules) return;
|
if (!rules) return;
|
||||||
|
|
||||||
const nextRaw = Number(valueMm);
|
const nextRaw = Number(valueMm);
|
||||||
if (!Number.isFinite(nextRaw)) return;
|
if (!Number.isFinite(nextRaw)) return;
|
||||||
const nextStepped = Math.round(nextRaw / rules.step) * rules.step;
|
const nextStepped = Math.round(nextRaw / rules.step) * rules.step;
|
||||||
const next = Math.min(rules.max, Math.max(rules.min, nextStepped));
|
const next = key === 'plinthHeightMm'
|
||||||
|
? normalizeAxiloTargetHeightMm(nextRaw)
|
||||||
|
: Math.min(rules.max, Math.max(rules.min, nextStepped));
|
||||||
|
|
||||||
setConfig((c) => {
|
setConfig((c) => {
|
||||||
const patch = { [key]: next };
|
const patch = { [key]: next };
|
||||||
@@ -329,8 +612,9 @@ export default function useWardrobeConfig() {
|
|||||||
niche.depthMidMm = nicheOuter;
|
niche.depthMidMm = nicheOuter;
|
||||||
niche.depthBottomMm = nicheOuter;
|
niche.depthBottomMm = nicheOuter;
|
||||||
} else if (key === 'heightMm') {
|
} else if (key === 'heightMm') {
|
||||||
niche.heightLeftMm = nicheOuter;
|
const nicheHeight = next + gap;
|
||||||
niche.heightRightMm = nicheOuter;
|
niche.heightLeftMm = nicheHeight;
|
||||||
|
niche.heightRightMm = nicheHeight;
|
||||||
}
|
}
|
||||||
patch.nicheMeasurements = niche;
|
patch.nicheMeasurements = niche;
|
||||||
}
|
}
|
||||||
@@ -340,6 +624,11 @@ export default function useWardrobeConfig() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setDimension = useCallback((key, valueMm) => {
|
||||||
|
pushUndoSnapshot();
|
||||||
|
applyDimension(key, valueMm);
|
||||||
|
}, [applyDimension, pushUndoSnapshot]);
|
||||||
|
|
||||||
// Column generation
|
// Column generation
|
||||||
const generateColumns = useCallback((nCols, totalW, existingModules = []) => {
|
const generateColumns = useCallback((nCols, totalW, existingModules = []) => {
|
||||||
const lockedWidth = existingModules
|
const lockedWidth = existingModules
|
||||||
@@ -369,16 +658,25 @@ export default function useWardrobeConfig() {
|
|||||||
locked: false,
|
locked: false,
|
||||||
shelves: typeDef.shelves,
|
shelves: typeDef.shelves,
|
||||||
drawers: typeDef.drawers,
|
drawers: typeDef.drawers,
|
||||||
hasRod: typeDef.hasRod
|
hasRod: typeDef.hasRod,
|
||||||
|
drawerType: existing?.drawerType ?? configRef.current?.hardware?.drawerType ?? 'visible',
|
||||||
|
hasBack: existing?.hasBack ?? (configRef.current?.hasBack === true),
|
||||||
|
hasBackTop: existing?.hasBackTop ?? existing?.hasBack ?? (configRef.current?.hasBack === true),
|
||||||
|
stacked: existing?.stacked ?? (configRef.current?.stackEnabled === true),
|
||||||
|
stackBottomHeightMm: existing?.stackBottomHeightMm ?? configRef.current?.stackBottomHeightMm ?? 1000
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleNumColumnsChange = useCallback((newNum) => {
|
const handleNumColumnsChange = useCallback((newNum) => {
|
||||||
|
pushUndoSnapshot();
|
||||||
setNumColumns(newNum);
|
setNumColumns(newNum);
|
||||||
const modules = generateColumns(newNum, config.widthMm, config.modules);
|
const modules = generateColumns(newNum, config.widthMm, config.modules);
|
||||||
setConfig(c => ({ ...c, modules }));
|
setConfig((c) => {
|
||||||
}, [config.widthMm, config.modules, generateColumns]);
|
const next = { ...c, modules };
|
||||||
|
return { ...next, horizontalSpans: reconcileHorizontalSpans(c.horizontalSpans, next) };
|
||||||
|
});
|
||||||
|
}, [config.widthMm, config.modules, generateColumns, pushUndoSnapshot]);
|
||||||
|
|
||||||
// AI layout optimization
|
// AI layout optimization
|
||||||
const handleAIOptimize = useCallback(async () => {
|
const handleAIOptimize = useCallback(async () => {
|
||||||
@@ -406,13 +704,15 @@ export default function useWardrobeConfig() {
|
|||||||
if (!res.ok) throw new Error('API error');
|
if (!res.ok) throw new Error('API error');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.modules && Array.isArray(data.modules)) {
|
if (data.modules && Array.isArray(data.modules)) {
|
||||||
|
pushUndoSnapshot();
|
||||||
setConfig(c => {
|
setConfig(c => {
|
||||||
const newModules = data.modules.map((m, i) => ({
|
const newModules = data.modules.map((m, i) => ({
|
||||||
...c.modules[i],
|
...c.modules[i],
|
||||||
...m,
|
...m,
|
||||||
id: c.modules[i]?.id || `col-${i}`
|
id: c.modules[i]?.id || `col-${i}`
|
||||||
}));
|
}));
|
||||||
return { ...c, modules: newModules };
|
const next = { ...c, modules: newModules };
|
||||||
|
return { ...next, horizontalSpans: reconcileHorizontalSpans(c.horizontalSpans, next) };
|
||||||
});
|
});
|
||||||
setNumColumns(data.modules.length);
|
setNumColumns(data.modules.length);
|
||||||
}
|
}
|
||||||
@@ -424,13 +724,14 @@ export default function useWardrobeConfig() {
|
|||||||
} finally {
|
} finally {
|
||||||
setAiOptimizing(false);
|
setAiOptimizing(false);
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [config, pushUndoSnapshot]);
|
||||||
|
|
||||||
const handleColumnTypeChange = useCallback((index, typeKey) => {
|
const handleColumnTypeChange = useCallback((index, typeKey) => {
|
||||||
|
pushUndoSnapshot();
|
||||||
const typeDef = COLUMN_TYPES.find(t => t.key === typeKey) || COLUMN_TYPES[0];
|
const typeDef = COLUMN_TYPES.find(t => t.key === typeKey) || COLUMN_TYPES[0];
|
||||||
setConfig(c => {
|
setConfig(c => {
|
||||||
const modules = [...c.modules];
|
const modules = [...c.modules];
|
||||||
const minS = minShelvesForStructuralHeight(c.heightMm);
|
const minS = minShelvesForColumn(c.heightMm, modules[index], c);
|
||||||
const shelves = Math.max(minS, typeDef.shelves);
|
const shelves = Math.max(minS, typeDef.shelves);
|
||||||
modules[index] = {
|
modules[index] = {
|
||||||
...modules[index],
|
...modules[index],
|
||||||
@@ -442,15 +743,58 @@ export default function useWardrobeConfig() {
|
|||||||
};
|
};
|
||||||
return { ...c, modules };
|
return { ...c, modules };
|
||||||
});
|
});
|
||||||
}, []);
|
}, [pushUndoSnapshot]);
|
||||||
|
|
||||||
|
/** Applique le dos (ou son absence) à toutes les zones (tous blocks) + défaut pour les nouvelles colonnes. */
|
||||||
|
const setAllBacks = useCallback((hasBack) => {
|
||||||
|
pushUndoSnapshot();
|
||||||
|
setConfig((c) => ({
|
||||||
|
...c,
|
||||||
|
hasBack,
|
||||||
|
modules: (c.modules || []).map((m) => ({
|
||||||
|
...m,
|
||||||
|
hasBack,
|
||||||
|
hasBackTop: hasBack,
|
||||||
|
blockBacks: Array.isArray(m.blockBacks) ? m.blockBacks.map(() => hasBack) : m.blockBacks
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
}, [pushUndoSnapshot]);
|
||||||
|
|
||||||
|
/** Applique un nombre de blocks (répartition égale) à toutes les colonnes + défaut pour les nouvelles. */
|
||||||
|
const setAllBlockCounts = useCallback((count) => {
|
||||||
|
pushUndoSnapshot();
|
||||||
|
setConfig((c) => {
|
||||||
|
const body = carcassBodyMm(c);
|
||||||
|
const n = Math.min(Math.max(1, Number(count) || 1), maxBlocksForBody(body));
|
||||||
|
const heights = n <= 1 ? [body] : Array.from({ length: n }, () => body / n);
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
stackEnabled: n >= 2,
|
||||||
|
modules: (c.modules || []).map((m) => {
|
||||||
|
const nextModule = { ...m, blockHeightsMm: heights.slice() };
|
||||||
|
return {
|
||||||
|
...nextModule,
|
||||||
|
shelfPositionsMm: resolveShelfPositionsMm({
|
||||||
|
shelfCount: nextModule.shelves ?? 0,
|
||||||
|
heightMm: c.heightMm,
|
||||||
|
plinthHeightMm: c.plinthHeightMm ?? 100,
|
||||||
|
shelfPositionsMm: undefined,
|
||||||
|
stack: moduleStackInfo(nextModule, c)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [pushUndoSnapshot]);
|
||||||
|
|
||||||
const handleColumnLock = useCallback((index) => {
|
const handleColumnLock = useCallback((index) => {
|
||||||
|
pushUndoSnapshot();
|
||||||
setConfig(c => {
|
setConfig(c => {
|
||||||
const modules = [...c.modules];
|
const modules = [...c.modules];
|
||||||
modules[index] = { ...modules[index], locked: !modules[index].locked };
|
modules[index] = { ...modules[index], locked: !modules[index].locked };
|
||||||
return { ...c, modules };
|
return { ...c, modules };
|
||||||
});
|
});
|
||||||
}, []);
|
}, [pushUndoSnapshot]);
|
||||||
|
|
||||||
const redistributeWidths = useCallback(() => {
|
const redistributeWidths = useCallback(() => {
|
||||||
setConfig(c => {
|
setConfig(c => {
|
||||||
@@ -466,29 +810,46 @@ export default function useWardrobeConfig() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Re-distribute when width changes
|
// Cotes modifiées: la disposition suivie (auto ou type) est recalculée tant que les colonnes n'ont pas été
|
||||||
|
// personnalisées, sinon simple redistribution des largeurs. Le suivi est évalué sur l'état du rendu, avant que les
|
||||||
|
// normalisations (blocks, rayons) mises en file par les effets précédents ne modifient les colonnes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config.modules.length > 0) {
|
if (config.modules.length === 0) return;
|
||||||
redistributeWidths();
|
if (isLayoutFollowing(config)) {
|
||||||
|
setConfig(withFollowedLayout);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [config.widthMm]);
|
const totalMm = config.modules.reduce((sum, m) => sum + (m.widthMm || 0), 0);
|
||||||
|
if (totalMm !== config.widthMm) redistributeWidths();
|
||||||
|
}, [config.widthMm, config.heightMm, config.depthMm, config.plinthHeightMm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNumColumns(Math.max(1, config.modules.length || 1));
|
||||||
|
}, [config.modules.length]);
|
||||||
|
|
||||||
// Initialize columns on first load
|
// Initialize columns on first load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config.modules.length === 0) {
|
if (config.modules.length === 0) setConfig(withAutoLayout);
|
||||||
const modules = generateColumns(numColumns, config.widthMm, []);
|
|
||||||
setConfig(c => ({ ...c, modules }));
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleColumnUpdate = useCallback((index, updates) => {
|
/** Réactive la proposition automatique (colonnes recalculées selon les cotes). */
|
||||||
|
const enableAutoLayout = useCallback(() => {
|
||||||
|
pushUndoSnapshot();
|
||||||
|
setConfig(withAutoLayout);
|
||||||
|
}, [pushUndoSnapshot]);
|
||||||
|
|
||||||
|
const applyColumnUpdate = useCallback((index, updates) => {
|
||||||
setConfig(c => {
|
setConfig(c => {
|
||||||
const modules = [...c.modules];
|
const modules = [...c.modules];
|
||||||
const prev = modules[index];
|
const prev = modules[index];
|
||||||
const merged = { ...prev, ...updates };
|
const merged = { ...prev, ...updates };
|
||||||
const minS = minShelvesForStructuralHeight(c.heightMm);
|
const minS = minShelvesForColumn(c.heightMm, merged, c);
|
||||||
const shelves = Math.max(minS, merged.shelves ?? 0);
|
const shelves = Math.max(minS, merged.shelves ?? 0);
|
||||||
const drawers = merged.drawers ?? 0;
|
const drawers = merged.drawers ?? 0;
|
||||||
|
const blockGeometryChanged = !sameNumberArray(
|
||||||
|
moduleBlockHeightsMm(prev, c),
|
||||||
|
moduleBlockHeightsMm(merged, c)
|
||||||
|
);
|
||||||
const withShelves = {
|
const withShelves = {
|
||||||
...merged,
|
...merged,
|
||||||
shelves,
|
shelves,
|
||||||
@@ -496,7 +857,8 @@ export default function useWardrobeConfig() {
|
|||||||
shelfCount: shelves,
|
shelfCount: shelves,
|
||||||
heightMm: c.heightMm,
|
heightMm: c.heightMm,
|
||||||
plinthHeightMm: c.plinthHeightMm ?? 100,
|
plinthHeightMm: c.plinthHeightMm ?? 100,
|
||||||
shelfPositionsMm: merged.shelfPositionsMm
|
shelfPositionsMm: blockGeometryChanged ? undefined : merged.shelfPositionsMm,
|
||||||
|
stack: moduleStackInfo(merged, c)
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
const needsMixte = shelves > 0 && drawers > 0 && withShelves.type !== 'mixte';
|
const needsMixte = shelves > 0 && drawers > 0 && withShelves.type !== 'mixte';
|
||||||
@@ -507,26 +869,139 @@ export default function useWardrobeConfig() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleColumnUpdate = useCallback((index, updates) => {
|
||||||
|
pushUndoSnapshot();
|
||||||
|
applyColumnUpdate(index, updates);
|
||||||
|
}, [applyColumnUpdate, pushUndoSnapshot]);
|
||||||
|
|
||||||
const reorderModules = useCallback((fromIndex, toIndex) => {
|
const reorderModules = useCallback((fromIndex, toIndex) => {
|
||||||
if (fromIndex === toIndex) return;
|
if (fromIndex === toIndex) return;
|
||||||
|
pushUndoSnapshot();
|
||||||
setConfig((c) => {
|
setConfig((c) => {
|
||||||
const list = [...c.modules];
|
const list = [...c.modules];
|
||||||
const n = list.length;
|
const n = list.length;
|
||||||
if (fromIndex < 0 || fromIndex >= n || toIndex < 0 || toIndex >= n) return c;
|
if (fromIndex < 0 || fromIndex >= n || toIndex < 0 || toIndex >= n) return c;
|
||||||
const [item] = list.splice(fromIndex, 1);
|
const [item] = list.splice(fromIndex, 1);
|
||||||
list.splice(toIndex, 0, item);
|
list.splice(toIndex, 0, item);
|
||||||
return {
|
const next = {
|
||||||
...c,
|
...c,
|
||||||
modules: list.map((m, idx) => ({ ...m, position: idx })),
|
modules: list.map((m, idx) => ({ ...m, position: idx })),
|
||||||
};
|
};
|
||||||
|
return { ...next, horizontalSpans: reconcileHorizontalSpans(c.horizontalSpans, next) };
|
||||||
});
|
});
|
||||||
}, []);
|
}, [pushUndoSnapshot]);
|
||||||
|
|
||||||
const handleResizeModules = useCallback((separatorIndex, deltaXmm) => {
|
/**
|
||||||
|
* Create a canonical horizontal span as one undoable state transition.
|
||||||
|
* Its boundaries and open door bands must land in the same React update as
|
||||||
|
* the span itself; otherwise rapid queued updates can snapshot stale state.
|
||||||
|
*/
|
||||||
|
const createHorizontalSpan = useCallback((spanInput = {}) => {
|
||||||
|
const id = spanInput.id ?? createHorizontalSpanId();
|
||||||
|
pushUndoSnapshot();
|
||||||
|
setConfig((c) => {
|
||||||
|
const moduleIds = Array.isArray(spanInput.moduleIds) ? spanInput.moduleIds : [];
|
||||||
|
const synchronized = synchronizeModuleSpanBoundaries(
|
||||||
|
c,
|
||||||
|
moduleIds,
|
||||||
|
spanInput.bottomMm,
|
||||||
|
spanInput.topMm
|
||||||
|
);
|
||||||
|
const selectedIds = new Set(moduleIds.map(String));
|
||||||
|
const bottomMm = Number(spanInput.bottomMm);
|
||||||
|
const topMm = Number(spanInput.topMm);
|
||||||
|
const modules = synchronized.modules.map((module) => {
|
||||||
|
if (!selectedIds.has(String(module.id))) return module;
|
||||||
|
const edges = getModuleVerticalBoundariesMm(module, synchronized);
|
||||||
|
const doors = moduleBlockDoors(module, synchronized, edges.length - 1);
|
||||||
|
return {
|
||||||
|
...module,
|
||||||
|
blockDoors: doors.map((hasDoor, index) => (
|
||||||
|
edges[index] >= bottomMm - 0.01 && edges[index + 1] <= topMm + 0.01
|
||||||
|
? false
|
||||||
|
: hasDoor
|
||||||
|
))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const next = { ...synchronized, modules };
|
||||||
|
const span = normalizeHorizontalSpan({ ...spanInput, id }, modules);
|
||||||
|
return { ...next, horizontalSpans: [...(c.horizontalSpans || []), span] };
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}, [pushUndoSnapshot]);
|
||||||
|
|
||||||
|
/** Patch a span without changing its id. */
|
||||||
|
const updateHorizontalSpan = useCallback((spanId, updates = {}) => {
|
||||||
|
if (!(configRef.current.horizontalSpans || []).some((span) => String(span.id) === String(spanId))) return false;
|
||||||
|
pushUndoSnapshot();
|
||||||
|
setConfig((c) => ({
|
||||||
|
...c,
|
||||||
|
horizontalSpans: (c.horizontalSpans || []).map((span) =>
|
||||||
|
String(span.id) === String(spanId)
|
||||||
|
? normalizeHorizontalSpan({ ...span, ...updates, id: span.id }, c.modules)
|
||||||
|
: span
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}, [pushUndoSnapshot]);
|
||||||
|
|
||||||
|
const deleteHorizontalSpan = useCallback((spanId) => {
|
||||||
|
if (!(configRef.current.horizontalSpans || []).some((span) => String(span.id) === String(spanId))) return false;
|
||||||
|
pushUndoSnapshot();
|
||||||
|
setConfig((c) => ({
|
||||||
|
...c,
|
||||||
|
horizontalSpans: (c.horizontalSpans || []).filter((span) => String(span.id) !== String(spanId))
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}, [pushUndoSnapshot]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the selected bottom/top limits to every covered column's vertical block model.
|
||||||
|
* Existing block back/door flags are inherited by each resulting segment.
|
||||||
|
*/
|
||||||
|
const synchronizeHorizontalSpanBoundaries = useCallback((moduleIds, bottomMm, topMm) => {
|
||||||
|
if (!Array.isArray(moduleIds) || moduleIds.length === 0) return false;
|
||||||
|
pushUndoSnapshot();
|
||||||
|
setConfig((c) => synchronizeModuleSpanBoundaries(c, moduleIds, bottomMm, topMm));
|
||||||
|
return true;
|
||||||
|
}, [pushUndoSnapshot]);
|
||||||
|
|
||||||
|
/** Add the shared 300 mm regular block and move the span boundary in one undoable update. */
|
||||||
|
const addHorizontalSpanUpperBlock = useCallback((spanId) => {
|
||||||
|
const result = addUpperBlockAboveHorizontalSpan(configRef.current, spanId);
|
||||||
|
if (!result.ok) return result;
|
||||||
|
pushUndoSnapshot();
|
||||||
|
configRef.current = result.config;
|
||||||
|
setConfig(result.config);
|
||||||
|
return result;
|
||||||
|
}, [pushUndoSnapshot]);
|
||||||
|
|
||||||
|
/** Resize the shared upper block across every covered column in one undoable update. */
|
||||||
|
const resizeHorizontalSpanUpperBlockHeight = useCallback((spanId, heightMm) => {
|
||||||
|
const result = resizeHorizontalSpanUpperBlock(configRef.current, spanId, heightMm);
|
||||||
|
if (!result.ok || result.config === configRef.current) return result;
|
||||||
|
pushUndoSnapshot();
|
||||||
|
configRef.current = result.config;
|
||||||
|
setConfig(result.config);
|
||||||
|
return result;
|
||||||
|
}, [pushUndoSnapshot]);
|
||||||
|
|
||||||
|
const getHorizontalSpanUpperBlock = useCallback(
|
||||||
|
(spanId) => getHorizontalSpanUpperBlockState(configRef.current, spanId),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** `base` (gesture start widths) makes the delta absolute, so repeated moves never accumulate rounding. */
|
||||||
|
const handleResizeModules = useCallback((separatorIndex, deltaXmm, base) => {
|
||||||
setConfig((c) => {
|
setConfig((c) => {
|
||||||
const modules = [...c.modules];
|
const modules = [...c.modules];
|
||||||
|
if (!modules[separatorIndex] || !modules[separatorIndex + 1]) return c;
|
||||||
const left = { ...modules[separatorIndex] };
|
const left = { ...modules[separatorIndex] };
|
||||||
const right = { ...modules[separatorIndex + 1] };
|
const right = { ...modules[separatorIndex + 1] };
|
||||||
|
if (base) {
|
||||||
|
left.widthMm = base.leftMm;
|
||||||
|
right.widthMm = base.rightMm;
|
||||||
|
}
|
||||||
|
|
||||||
const leftMin = left.minWidth || 300;
|
const leftMin = left.minWidth || 300;
|
||||||
const leftMax = left.maxWidth || 1200;
|
const leftMax = left.maxWidth || 1200;
|
||||||
@@ -543,6 +1018,10 @@ export default function useWardrobeConfig() {
|
|||||||
|
|
||||||
left.widthMm = Math.round(newLeftW);
|
left.widthMm = Math.round(newLeftW);
|
||||||
right.widthMm = Math.round(newRightW);
|
right.widthMm = Math.round(newRightW);
|
||||||
|
if (
|
||||||
|
left.widthMm === c.modules[separatorIndex].widthMm
|
||||||
|
&& right.widthMm === c.modules[separatorIndex + 1].widthMm
|
||||||
|
) return c;
|
||||||
modules[separatorIndex] = left;
|
modules[separatorIndex] = left;
|
||||||
modules[separatorIndex + 1] = right;
|
modules[separatorIndex + 1] = right;
|
||||||
return { ...c, modules };
|
return { ...c, modules };
|
||||||
@@ -555,6 +1034,7 @@ export default function useWardrobeConfig() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleColorSelect = useCallback((elementType, finish) => {
|
const handleColorSelect = useCallback((elementType, finish) => {
|
||||||
|
pushUndoSnapshot();
|
||||||
setConfig((c) => ({
|
setConfig((c) => ({
|
||||||
...c,
|
...c,
|
||||||
colors: {
|
colors: {
|
||||||
@@ -567,9 +1047,10 @@ export default function useWardrobeConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, [pushUndoSnapshot]);
|
||||||
|
|
||||||
const handleColorApplyToAll = useCallback((finish) => {
|
const handleColorApplyToAll = useCallback((finish) => {
|
||||||
|
pushUndoSnapshot();
|
||||||
const colorEntry = {
|
const colorEntry = {
|
||||||
finishId: finish.id,
|
finishId: finish.id,
|
||||||
label: finish.label || finish.name,
|
label: finish.label || finish.name,
|
||||||
@@ -586,18 +1067,20 @@ export default function useWardrobeConfig() {
|
|||||||
});
|
});
|
||||||
return { ...c, colors: newColors };
|
return { ...c, colors: newColors };
|
||||||
});
|
});
|
||||||
}, []);
|
}, [pushUndoSnapshot]);
|
||||||
|
|
||||||
// Hardware
|
// Hardware
|
||||||
const setHardware = useCallback((key, value) => {
|
const setHardware = useCallback((key, value) => {
|
||||||
|
pushUndoSnapshot();
|
||||||
setConfig((c) => ({
|
setConfig((c) => ({
|
||||||
...c,
|
...c,
|
||||||
hardware: { ...c.hardware, [key]: value }
|
hardware: { ...c.hardware, [key]: value }
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, [pushUndoSnapshot]);
|
||||||
|
|
||||||
// Material management
|
// Material management
|
||||||
const handleMaterialConfirm = useCallback((materialId, dimensionId) => {
|
const handleMaterialConfirm = useCallback((materialId, dimensionId) => {
|
||||||
|
pushUndoSnapshot();
|
||||||
setConfig((c) => {
|
setConfig((c) => {
|
||||||
const material = finishes.find(f => f.id === materialId);
|
const material = finishes.find(f => f.id === materialId);
|
||||||
const updated = { ...c, finishId: materialId, dimensionId };
|
const updated = { ...c, finishId: materialId, dimensionId };
|
||||||
@@ -619,7 +1102,7 @@ export default function useWardrobeConfig() {
|
|||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}, [finishes]);
|
}, [finishes, pushUndoSnapshot]);
|
||||||
|
|
||||||
// Derived values
|
// Derived values
|
||||||
const totalModuleWidth = useMemo(() =>
|
const totalModuleWidth = useMemo(() =>
|
||||||
@@ -643,7 +1126,10 @@ export default function useWardrobeConfig() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config, setConfig, set, setDimension,
|
config, setConfig: setConfigUndoable, restoreConfig, applyLayoutPreset, enableAutoLayout,
|
||||||
|
layoutFollow: isLayoutFollowing(config) ? (config.layoutFollow || { mode: 'auto' }) : null,
|
||||||
|
set, setDimension,
|
||||||
|
setDimensionWithoutUndo: applyDimension,
|
||||||
finishes, serviceOptions,
|
finishes, serviceOptions,
|
||||||
priceResult,
|
priceResult,
|
||||||
loading,
|
loading,
|
||||||
@@ -655,9 +1141,19 @@ export default function useWardrobeConfig() {
|
|||||||
handleAIOptimize,
|
handleAIOptimize,
|
||||||
handleColumnTypeChange,
|
handleColumnTypeChange,
|
||||||
handleColumnLock,
|
handleColumnLock,
|
||||||
|
setAllBacks,
|
||||||
|
setAllBlockCounts,
|
||||||
redistributeWidths,
|
redistributeWidths,
|
||||||
handleColumnUpdate,
|
handleColumnUpdate,
|
||||||
|
handleColumnUpdateWithoutUndo: applyColumnUpdate,
|
||||||
reorderModules,
|
reorderModules,
|
||||||
|
createHorizontalSpan,
|
||||||
|
updateHorizontalSpan,
|
||||||
|
deleteHorizontalSpan,
|
||||||
|
synchronizeHorizontalSpanBoundaries,
|
||||||
|
addHorizontalSpanUpperBlock,
|
||||||
|
resizeHorizontalSpanUpperBlockHeight,
|
||||||
|
getHorizontalSpanUpperBlock,
|
||||||
handleResizeModules,
|
handleResizeModules,
|
||||||
handleElementClick,
|
handleElementClick,
|
||||||
handleColorSelect,
|
handleColorSelect,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
--danger: #dc2626;
|
--danger: #dc2626;
|
||||||
--font: 'DM Sans', system-ui, sans-serif;
|
--font: 'DM Sans', system-ui, sans-serif;
|
||||||
|
--header-height: 63px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
+340
-5
@@ -7,6 +7,10 @@ import {
|
|||||||
getFormexSyncStreamUrl,
|
getFormexSyncStreamUrl,
|
||||||
getHaefeleSyncStreamUrl,
|
getHaefeleSyncStreamUrl,
|
||||||
fetchHaefeleMaterials,
|
fetchHaefeleMaterials,
|
||||||
|
getLeubaHiagSyncStreamUrl,
|
||||||
|
fetchLeubaHiagMaterials,
|
||||||
|
fetchLeubaHiagTargets,
|
||||||
|
setFormexMaterialActive,
|
||||||
fetchOrderSupplierVouchers,
|
fetchOrderSupplierVouchers,
|
||||||
fetchOrderPlanExports,
|
fetchOrderPlanExports,
|
||||||
generateOrderPlanExports,
|
generateOrderPlanExports,
|
||||||
@@ -16,8 +20,16 @@ import {
|
|||||||
deleteOrderPlanExport
|
deleteOrderPlanExport
|
||||||
} from '../api/client';
|
} from '../api/client';
|
||||||
import Icon from '../components/ui/Icon';
|
import Icon from '../components/ui/Icon';
|
||||||
|
import SectionNav from '../components/SectionNav';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
|
const ADMIN_SECTIONS = [
|
||||||
|
{ id: 'sync-formex', label: 'Formex' },
|
||||||
|
{ id: 'sync-hafele', label: 'Häfele' },
|
||||||
|
{ id: 'sync-leuba', label: 'Leuba Hiag' },
|
||||||
|
{ id: 'commandes', label: 'Commandes' }
|
||||||
|
];
|
||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const { isAdmin, logout } = useAuth();
|
const { isAdmin, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -61,17 +73,42 @@ export default function Admin() {
|
|||||||
const [haefeleError, setHaefeleError] = useState('');
|
const [haefeleError, setHaefeleError] = useState('');
|
||||||
const haefeleLogsEndRef = useRef(null);
|
const haefeleLogsEndRef = useRef(null);
|
||||||
|
|
||||||
|
// Leuba Hiag sync state
|
||||||
|
const [leubaTargets, setLeubaTargets] = useState([]);
|
||||||
|
const [leubaSelected, setLeubaSelected] = useState([]);
|
||||||
|
const [leubaActivate, setLeubaActivate] = useState(false);
|
||||||
|
const [leubaFetchPrices, setLeubaFetchPrices] = useState(true);
|
||||||
|
const [leubaSyncing, setLeubaSyncing] = useState(false);
|
||||||
|
const [leubaSyncResult, setLeubaSyncResult] = useState(null);
|
||||||
|
const [leubaLiveMessages, setLeubaLiveMessages] = useState([]);
|
||||||
|
const [leubaProgress, setLeubaProgress] = useState({ current: 0, total: 0, collection: '' });
|
||||||
|
const [leubaSearch, setLeubaSearch] = useState('');
|
||||||
|
const [leubaMaterials, setLeubaMaterials] = useState([]);
|
||||||
|
const [leubaLoading, setLeubaLoading] = useState(false);
|
||||||
|
const [leubaError, setLeubaError] = useState('');
|
||||||
|
const leubaLogsEndRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
navigate('/login?next=/admin', { replace: true });
|
navigate('/login?next=/admin', { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Fetch orders and sync logs in parallel
|
// Fetch orders and sync logs in parallel
|
||||||
Promise.all([fetchOrders(), fetchSyncLogs(10), fetchHaefeleMaterials({ limit: 50 })])
|
Promise.all([
|
||||||
.then(([ordersData, logsData, haefeleData]) => {
|
fetchOrders(),
|
||||||
|
fetchSyncLogs(10),
|
||||||
|
fetchHaefeleMaterials({ limit: 50 }),
|
||||||
|
fetchLeubaHiagMaterials({ limit: 100 }),
|
||||||
|
fetchLeubaHiagTargets()
|
||||||
|
])
|
||||||
|
.then(([ordersData, logsData, haefeleData, leubaData, leubaTargetsData]) => {
|
||||||
setOrders(ordersData);
|
setOrders(ordersData);
|
||||||
setSyncLogs(logsData);
|
setSyncLogs(logsData);
|
||||||
setHaefeleMaterials(haefeleData?.materials || []);
|
setHaefeleMaterials(haefeleData?.materials || []);
|
||||||
|
setLeubaMaterials(leubaData?.materials || []);
|
||||||
|
const targets = leubaTargetsData || [];
|
||||||
|
setLeubaTargets(targets);
|
||||||
|
setLeubaSelected(targets.map((t) => t.key));
|
||||||
})
|
})
|
||||||
.catch((err) => setError(err.message))
|
.catch((err) => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@@ -90,6 +127,12 @@ export default function Admin() {
|
|||||||
}
|
}
|
||||||
}, [haefeleLiveMessages]);
|
}, [haefeleLiveMessages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (leubaLogsEndRef.current) {
|
||||||
|
leubaLogsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [leubaLiveMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = window.localStorage.getItem('admin.haefele.urls');
|
const saved = window.localStorage.getItem('admin.haefele.urls');
|
||||||
if (saved) setHaefeleUrlsInput(saved);
|
if (saved) setHaefeleUrlsInput(saved);
|
||||||
@@ -255,12 +298,118 @@ export default function Admin() {
|
|||||||
setHaefeleProgress({ current: 0, total: 0, collection: '' });
|
setHaefeleProgress({ current: 0, total: 0, collection: '' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshLeubaMaterials = async (search = leubaSearch) => {
|
||||||
|
setLeubaLoading(true);
|
||||||
|
setLeubaError('');
|
||||||
|
try {
|
||||||
|
const data = await fetchLeubaHiagMaterials({ search, limit: 200 });
|
||||||
|
setLeubaMaterials(data?.materials || []);
|
||||||
|
} catch (err) {
|
||||||
|
setLeubaError(err.message || 'Erreur de chargement Leuba Hiag');
|
||||||
|
} finally {
|
||||||
|
setLeubaLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleLeubaTarget = (key) => {
|
||||||
|
setLeubaSelected((prev) =>
|
||||||
|
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleLeubaActive = async (item) => {
|
||||||
|
const nextActive = !item.active;
|
||||||
|
// Optimistic update so the change is reflected immediately.
|
||||||
|
setLeubaMaterials((prev) =>
|
||||||
|
prev.map((m) => (m.id === item.id ? { ...m, active: nextActive ? 1 : 0 } : m))
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await setFormexMaterialActive(item.id, nextActive);
|
||||||
|
} catch (err) {
|
||||||
|
// Revert on failure.
|
||||||
|
setLeubaMaterials((prev) =>
|
||||||
|
prev.map((m) => (m.id === item.id ? { ...m, active: item.active } : m))
|
||||||
|
);
|
||||||
|
setLeubaError(err.message || 'Échec de la mise à jour du statut');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLeubaSync = () => {
|
||||||
|
if (leubaSelected.length === 0) {
|
||||||
|
setLeubaSyncResult({ success: false, error: 'Sélectionnez au moins une gamme à importer.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLeubaSyncing(true);
|
||||||
|
setLeubaSyncResult(null);
|
||||||
|
setLeubaLiveMessages([]);
|
||||||
|
setLeubaProgress({ current: 0, total: leubaSelected.length, collection: 'Leuba Hiag' });
|
||||||
|
|
||||||
|
const eventSource = new EventSource(
|
||||||
|
getLeubaHiagSyncStreamUrl({ targets: leubaSelected, activate: leubaActivate, fetchPrices: leubaFetchPrices })
|
||||||
|
);
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'log') {
|
||||||
|
const msg = data.message;
|
||||||
|
const lower = msg.toLowerCase();
|
||||||
|
const kind = lower.includes('erreur') || lower.includes('error') || lower.includes('✗')
|
||||||
|
? 'error'
|
||||||
|
: lower.includes('✓') || lower.includes('importé') || lower.includes('détecté')
|
||||||
|
? 'ok'
|
||||||
|
: 'info';
|
||||||
|
setLeubaLiveMessages((prev) => [...prev, { msg, kind }]);
|
||||||
|
} else if (data.type === 'progress') {
|
||||||
|
setLeubaProgress({
|
||||||
|
current: data.current,
|
||||||
|
total: data.total,
|
||||||
|
collection: data.collection || 'Leuba Hiag'
|
||||||
|
});
|
||||||
|
} else if (data.type === 'complete') {
|
||||||
|
setLeubaSyncResult(data.result);
|
||||||
|
setLeubaSyncing(false);
|
||||||
|
eventSource.close();
|
||||||
|
fetchSyncLogs(10).then(setSyncLogs).catch(() => {});
|
||||||
|
refreshLeubaMaterials().catch(() => {});
|
||||||
|
} else if (data.type === 'error') {
|
||||||
|
setLeubaLiveMessages((prev) => [...prev, { msg: `ERREUR: ${data.error}`, kind: 'error' }]);
|
||||||
|
setLeubaSyncResult({ success: false, error: data.error });
|
||||||
|
setLeubaSyncing(false);
|
||||||
|
eventSource.close();
|
||||||
|
fetchSyncLogs(10).then(setSyncLogs).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Leuba Hiag SSE parse error:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
setLeubaLiveMessages((prev) => [...prev, { msg: 'Connexion SSE Leuba Hiag perdue', kind: 'error' }]);
|
||||||
|
setLeubaSyncResult({ success: false, error: 'Connexion perdue' });
|
||||||
|
setLeubaSyncing(false);
|
||||||
|
eventSource.close();
|
||||||
|
fetchSyncLogs(10).then(setSyncLogs).catch(() => {});
|
||||||
|
refreshLeubaMaterials().catch(() => {});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearLeubaLogs = () => {
|
||||||
|
setLeubaLiveMessages([]);
|
||||||
|
setLeubaSyncResult(null);
|
||||||
|
setLeubaProgress({ current: 0, total: 0, collection: '' });
|
||||||
|
};
|
||||||
|
|
||||||
const progressPercent = progress.total > 0
|
const progressPercent = progress.total > 0
|
||||||
? Math.round((progress.current / progress.total) * 100)
|
? Math.round((progress.current / progress.total) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
const haefeleProgressPercent = haefeleProgress.total > 0
|
const haefeleProgressPercent = haefeleProgress.total > 0
|
||||||
? Math.round((haefeleProgress.current / haefeleProgress.total) * 100)
|
? Math.round((haefeleProgress.current / haefeleProgress.total) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
const leubaProgressPercent = leubaProgress.total > 0
|
||||||
|
? Math.round((leubaProgress.current / leubaProgress.total) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
const handleToggleSupplierDetails = async (orderId) => {
|
const handleToggleSupplierDetails = async (orderId) => {
|
||||||
if (expandedOrderId === orderId) {
|
if (expandedOrderId === orderId) {
|
||||||
@@ -366,9 +515,11 @@ export default function Admin() {
|
|||||||
{loading && <p className={styles.muted}>Chargement…</p>}
|
{loading && <p className={styles.muted}>Chargement…</p>}
|
||||||
{error && <p className={styles.error}>{error}</p>}
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|
||||||
|
{!loading && !error && <SectionNav sections={ADMIN_SECTIONS} />}
|
||||||
|
|
||||||
{/* Formex Sync Section */}
|
{/* Formex Sync Section */}
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<section className={styles.section}>
|
<section id="sync-formex" className={styles.section}>
|
||||||
<h2>Synchronisation Formex</h2>
|
<h2>Synchronisation Formex</h2>
|
||||||
<div className={styles.syncActions}>
|
<div className={styles.syncActions}>
|
||||||
<button
|
<button
|
||||||
@@ -468,7 +619,7 @@ export default function Admin() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<section className={styles.section}>
|
<section id="sync-hafele" className={styles.section}>
|
||||||
<h2>Synchronisation Häfele (URLs ciblées)</h2>
|
<h2>Synchronisation Häfele (URLs ciblées)</h2>
|
||||||
<p className={styles.muted}>
|
<p className={styles.muted}>
|
||||||
Collez une URL par ligne (ou séparées par virgule). Seules les URL Häfele seront traitées.
|
Collez une URL par ligne (ou séparées par virgule). Seules les URL Häfele seront traitées.
|
||||||
@@ -607,9 +758,193 @@ export default function Admin() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Leuba Hiag Sync Section */}
|
||||||
|
{!loading && !error && (
|
||||||
|
<section id="sync-leuba" className={styles.section}>
|
||||||
|
<h2>Synchronisation Leuba Hiag (gammes ciblées)</h2>
|
||||||
|
<p className={styles.muted}>
|
||||||
|
Importe uniquement les gammes convenues depuis le catalogue Leuba Hiag (Algolia).
|
||||||
|
Les articles sont importés inactifs par défaut : activez ensuite les décors voulus
|
||||||
|
dans la page Matériaux.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={styles.leubaTargets}>
|
||||||
|
{leubaTargets.map((t) => (
|
||||||
|
<label key={t.key} className={styles.leubaTargetItem}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={leubaSelected.includes(t.key)}
|
||||||
|
onChange={() => toggleLeubaTarget(t.key)}
|
||||||
|
disabled={leubaSyncing}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{t.label}</strong>
|
||||||
|
<small>{t.tier}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.syncActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLeubaSync}
|
||||||
|
disabled={leubaSyncing}
|
||||||
|
className={styles.syncBtn}
|
||||||
|
>
|
||||||
|
<Icon name="sync" size={15} />
|
||||||
|
{leubaSyncing ? 'Synchronisation Leuba Hiag en cours…' : 'Importer les gammes sélectionnées'}
|
||||||
|
</button>
|
||||||
|
<label className={styles.forceLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={leubaFetchPrices}
|
||||||
|
onChange={(e) => setLeubaFetchPrices(e.target.checked)}
|
||||||
|
disabled={leubaSyncing}
|
||||||
|
/>
|
||||||
|
Récupérer les prix client (connexion)
|
||||||
|
</label>
|
||||||
|
<label className={styles.forceLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={leubaActivate}
|
||||||
|
onChange={(e) => setLeubaActivate(e.target.checked)}
|
||||||
|
disabled={leubaSyncing}
|
||||||
|
/>
|
||||||
|
Activer immédiatement (visible dans le configurateur)
|
||||||
|
</label>
|
||||||
|
{leubaSyncResult && !leubaSyncing && (
|
||||||
|
<span className={leubaSyncResult.success ? styles.success : styles.error}>
|
||||||
|
{leubaSyncResult.success
|
||||||
|
? `${leubaSyncResult.itemsCount} produits importés${leubaSyncResult.pricedCount ? `, ${leubaSyncResult.pricedCount} prix` : ''} (${leubaSyncResult.duration}s)`
|
||||||
|
: `Erreur: ${leubaSyncResult.error}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(leubaSyncing || leubaLiveMessages.length > 0) && (
|
||||||
|
<div className={styles.liveSync}>
|
||||||
|
<div className={styles.progressWrap}>
|
||||||
|
<div className={styles.progressInfo}>
|
||||||
|
<span>{leubaProgress.collection || (leubaSyncing ? 'Initialisation…' : 'Terminé')}</span>
|
||||||
|
<span>
|
||||||
|
{leubaSyncing
|
||||||
|
? `${leubaProgressPercent}%`
|
||||||
|
: (leubaSyncResult?.success ? '✓ Succès' : '✗ Échec')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.progressBar}>
|
||||||
|
<div
|
||||||
|
className={`${styles.progressFill} ${!leubaSyncing && leubaSyncResult ? (leubaSyncResult.success ? styles.progressDone : styles.progressError) : ''}`}
|
||||||
|
style={{ width: `${leubaSyncing ? leubaProgressPercent : 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.progressStats}>
|
||||||
|
{leubaProgress.total > 0 && `${leubaProgress.current} / ${leubaProgress.total} gammes`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.liveLogsPanel}>
|
||||||
|
<div className={styles.liveLogsHeader}>
|
||||||
|
<span>Journal Leuba Hiag ({leubaLiveMessages.length} lignes)</span>
|
||||||
|
{!leubaSyncing && (
|
||||||
|
<button type="button" onClick={handleClearLeubaLogs} className={styles.clearBtn}>
|
||||||
|
Effacer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.liveLogsContent}>
|
||||||
|
{leubaLiveMessages.map(({ msg, kind }, idx) => (
|
||||||
|
<div key={idx} className={`${styles.logLine} ${styles[`logLine_${kind}`]}`}>{msg}</div>
|
||||||
|
))}
|
||||||
|
<div ref={leubaLogsEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.haefeleListHeader}>
|
||||||
|
<h3>Articles Leuba Hiag importés</h3>
|
||||||
|
<div className={styles.haefeleListActions}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.searchInput}
|
||||||
|
value={leubaSearch}
|
||||||
|
onChange={(e) => setLeubaSearch(e.target.value)}
|
||||||
|
placeholder="Rechercher (nom, code, gamme)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.detailsBtn}
|
||||||
|
onClick={() => refreshLeubaMaterials()}
|
||||||
|
disabled={leubaLoading}
|
||||||
|
>
|
||||||
|
<Icon name="sync" size={14} />
|
||||||
|
{leubaLoading ? 'Chargement…' : 'Actualiser'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{leubaError && <p className={styles.error}>{leubaError}</p>}
|
||||||
|
<div className={styles.tableWrap}>
|
||||||
|
<table className={styles.logsTable}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Code</th>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Gamme</th>
|
||||||
|
<th>Dimensions (mm)</th>
|
||||||
|
<th>Prix CHF</th>
|
||||||
|
<th>Actif</th>
|
||||||
|
<th>URL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{leubaMaterials.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className={styles.empty}>
|
||||||
|
{leubaLoading ? 'Chargement…' : 'Aucun article Leuba Hiag importé.'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
leubaMaterials.map((item) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>{item.formex_code || '—'}</td>
|
||||||
|
<td>{item.name || '—'}</td>
|
||||||
|
<td>{item.collection || '—'}</td>
|
||||||
|
<td>
|
||||||
|
{item.thickness_mm && item.height_mm && item.width_mm
|
||||||
|
? `${item.thickness_mm} × ${item.height_mm} × ${item.width_mm}`
|
||||||
|
: item.thickness_mm ? `ép. ${item.thickness_mm}` : '—'}
|
||||||
|
</td>
|
||||||
|
<td>{item.price_chf != null ? `${Number(item.price_chf).toFixed(2)}` : '—'}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.activeToggle} ${item.active ? styles.activeToggleOn : styles.activeToggleOff}`}
|
||||||
|
onClick={() => handleToggleLeubaActive(item)}
|
||||||
|
title={item.active ? 'Cliquer pour masquer du configurateur' : 'Cliquer pour activer dans le configurateur'}
|
||||||
|
>
|
||||||
|
{item.active ? 'Oui' : 'Non'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{item.formex_url ? (
|
||||||
|
<a href={item.formex_url} target="_blank" rel="noreferrer" className={styles.downloadLink}>
|
||||||
|
Voir page
|
||||||
|
</a>
|
||||||
|
) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Orders Section */}
|
{/* Orders Section */}
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<section className={styles.section}>
|
<section id="commandes" className={styles.section}>
|
||||||
<h2>Commandes</h2>
|
<h2>Commandes</h2>
|
||||||
<div className={styles.tableWrap}>
|
<div className={styles.tableWrap}>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
|
|||||||
@@ -300,6 +300,70 @@
|
|||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leubaTargets {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leubaTargetItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border: 1px solid var(--border, #e2e2e2);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leubaTargetItem:has(input:checked) {
|
||||||
|
border-color: var(--accent, #e67e22);
|
||||||
|
background: rgba(230, 126, 34, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leubaTargetItem input[type="checkbox"] {
|
||||||
|
accent-color: var(--accent, #e67e22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leubaTargetItem span {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leubaTargetItem small {
|
||||||
|
color: var(--text-secondary, #777);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeToggle {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.15rem 0.7rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1.3;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeToggleOn {
|
||||||
|
background: rgba(22, 163, 74, 0.12);
|
||||||
|
border-color: rgba(22, 163, 74, 0.5);
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeToggleOff {
|
||||||
|
background: rgba(120, 120, 120, 0.08);
|
||||||
|
color: var(--text-secondary, #777);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeToggle:hover {
|
||||||
|
border-color: var(--accent, #e67e22);
|
||||||
|
}
|
||||||
|
|
||||||
.urlTextarea {
|
.urlTextarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import RoomUpload from '../components/RoomUpload';
|
|||||||
import ColorPickerModal from '../components/ColorPickerModal';
|
import ColorPickerModal from '../components/ColorPickerModal';
|
||||||
import Plans2DView from '../components/Plans2DView';
|
import Plans2DView from '../components/Plans2DView';
|
||||||
import AIRenderModal from '../components/AIRenderModal';
|
import AIRenderModal from '../components/AIRenderModal';
|
||||||
|
import SavedDesignsModal from '../components/SavedDesignsModal';
|
||||||
import styles from './Configurator.module.css';
|
import styles from './Configurator.module.css';
|
||||||
|
|
||||||
export default function Configurator() {
|
export default function Configurator() {
|
||||||
@@ -22,6 +23,7 @@ export default function Configurator() {
|
|||||||
|
|
||||||
// Track which steps have been visited
|
// Track which steps have been visited
|
||||||
const [touched, setTouched] = useState(new Set([1]));
|
const [touched, setTouched] = useState(new Set([1]));
|
||||||
|
const [showSavedDesigns, setShowSavedDesigns] = useState(false);
|
||||||
const handleStepChange = (step) => {
|
const handleStepChange = (step) => {
|
||||||
ui.setActiveStep(step);
|
ui.setActiveStep(step);
|
||||||
setTouched(prev => new Set(prev).add(step));
|
setTouched(prev => new Set(prev).add(step));
|
||||||
@@ -77,6 +79,11 @@ export default function Configurator() {
|
|||||||
<StepperBar
|
<StepperBar
|
||||||
activeStep={ui.activeStep}
|
activeStep={ui.activeStep}
|
||||||
onStepChange={handleStepChange}
|
onStepChange={handleStepChange}
|
||||||
|
onOpenSavedDesigns={() => setShowSavedDesigns(true)}
|
||||||
|
canUndo={wc.canUndo}
|
||||||
|
canRedo={wc.canRedo}
|
||||||
|
onUndo={wc.undo}
|
||||||
|
onRedo={wc.redo}
|
||||||
totalChf={totalChf}
|
totalChf={totalChf}
|
||||||
touched={touched}
|
touched={touched}
|
||||||
/>
|
/>
|
||||||
@@ -100,6 +107,18 @@ export default function Configurator() {
|
|||||||
|
|
||||||
{/* ===== Modals ===== */}
|
{/* ===== Modals ===== */}
|
||||||
|
|
||||||
|
{showSavedDesigns && (
|
||||||
|
<SavedDesignsModal
|
||||||
|
config={wc.config}
|
||||||
|
onRestore={(savedConfig) => {
|
||||||
|
if (wc.restoreConfig(savedConfig)) {
|
||||||
|
setTouched(new Set([1, 2, 3, 4, 5, 6]));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClose={() => setShowSavedDesigns(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{ui.showMaterialModal && ui.selectedMaterial && (
|
{ui.showMaterialModal && ui.selectedMaterial && (
|
||||||
<MaterialDetailModal
|
<MaterialDetailModal
|
||||||
material={ui.selectedMaterial}
|
material={ui.selectedMaterial}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
min-width: 0; /* allow canvas to shrink without overflow */
|
min-width: 0; /* allow canvas to shrink without overflow */
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 58px;
|
top: calc(var(--header-height) + 58px);
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
@@ -52,6 +52,15 @@
|
|||||||
/* Extra right padding so scrollbars don't cover slider thumbs/cursors */
|
/* Extra right padding so scrollbars don't cover slider thumbs/cursors */
|
||||||
padding: 0.75rem 1.4rem 0.25rem 0.9rem;
|
padding: 0.75rem 1.4rem 0.25rem 0.9rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
/* Keep the parameters panel pinned next to the 3D view: it sticks under the
|
||||||
|
stepper bar and scrolls internally, so 3D + parameters stay visible together.
|
||||||
|
No overscroll-behavior: when the panel hits its top/bottom, the scroll chains
|
||||||
|
to the page so the user can keep scrolling up to the page start. */
|
||||||
|
position: sticky;
|
||||||
|
top: calc(var(--header-height) + 58px);
|
||||||
|
align-self: flex-start;
|
||||||
|
max-height: calc(100vh - var(--header-height) - 66px);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import SectionNav from '../components/SectionNav';
|
||||||
import styles from './Home.module.css';
|
import styles from './Home.module.css';
|
||||||
|
|
||||||
|
const HOME_SECTIONS = [
|
||||||
|
{ id: 'pourquoi', label: 'Pourquoi SIGNATURE' },
|
||||||
|
{ id: 'fonctionnement', label: 'Comment ça marche' },
|
||||||
|
{ id: 'systeme', label: 'Le système modulaire' },
|
||||||
|
{ id: 'tarifs', label: 'Tarifs' },
|
||||||
|
{ id: 'services', label: 'Services optionnels' }
|
||||||
|
];
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.home}>
|
<div className={styles.home}>
|
||||||
@@ -12,7 +21,9 @@ export default function Home() {
|
|||||||
<Link to="/configurateur" className={styles.cta}>Configurer mon armoire</Link>
|
<Link to="/configurateur" className={styles.cta}>Configurer mon armoire</Link>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={styles.section}>
|
<SectionNav sections={HOME_SECTIONS} />
|
||||||
|
|
||||||
|
<section id="pourquoi" className={styles.section}>
|
||||||
<h2>Pourquoi SIGNATURE</h2>
|
<h2>Pourquoi SIGNATURE</h2>
|
||||||
<ul className={styles.features}>
|
<ul className={styles.features}>
|
||||||
<li><strong>Blocs modulaires</strong> — Penderie, rayonnage, mixtes ; assemblez selon vos besoins.</li>
|
<li><strong>Blocs modulaires</strong> — Penderie, rayonnage, mixtes ; assemblez selon vos besoins.</li>
|
||||||
@@ -21,7 +32,7 @@ export default function Home() {
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={styles.section}>
|
<section id="fonctionnement" className={styles.section}>
|
||||||
<h2>Comment ça marche</h2>
|
<h2>Comment ça marche</h2>
|
||||||
<ol className={styles.steps}>
|
<ol className={styles.steps}>
|
||||||
<li>Configurez en ligne (dimensions, blocs, portes, finitions).</li>
|
<li>Configurez en ligne (dimensions, blocs, portes, finitions).</li>
|
||||||
@@ -31,20 +42,20 @@ export default function Home() {
|
|||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={styles.section}>
|
<section id="systeme" className={styles.section}>
|
||||||
<h2>Le système modulaire</h2>
|
<h2>Le système modulaire</h2>
|
||||||
<p className={styles.muted}>
|
<p className={styles.muted}>
|
||||||
Armoire composée de blocs juxtaposés : penderie, rayonnage, blocs mixtes. Chaque bloc est paramétrable en largeur. Portes battantes ou coulissantes selon la largeur.
|
Armoire composée de blocs juxtaposés : penderie, rayonnage, blocs mixtes. Chaque bloc est paramétrable en largeur. Portes battantes ou coulissantes selon la largeur.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={styles.section}>
|
<section id="tarifs" className={styles.section}>
|
||||||
<h2>Tarifs</h2>
|
<h2>Tarifs</h2>
|
||||||
<p className={styles.price}>À partir de CHF 3'900.–</p>
|
<p className={styles.price}>À partir de CHF 3'900.–</p>
|
||||||
<p className={styles.muted}>Prix indicatif pour une configuration type. Le configurateur affiche le prix exact selon vos choix.</p>
|
<p className={styles.muted}>Prix indicatif pour une configuration type. Le configurateur affiche le prix exact selon vos choix.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={styles.section}>
|
<section id="services" className={styles.section}>
|
||||||
<h2>Services optionnels</h2>
|
<h2>Services optionnels</h2>
|
||||||
<ul className={styles.features}>
|
<ul className={styles.features}>
|
||||||
<li>Prise de mesure sur site</li>
|
<li>Prise de mesure sur site</li>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+168
-50
@@ -6,31 +6,97 @@ import {
|
|||||||
fetchSuppliers, createSupplier, updateSupplier, deleteSupplier,
|
fetchSuppliers, createSupplier, updateSupplier, deleteSupplier,
|
||||||
fetchEngineeringRules, createEngineeringRule, updateEngineeringRule, deleteEngineeringRule
|
fetchEngineeringRules, createEngineeringRule, updateEngineeringRule, deleteEngineeringRule
|
||||||
} from '../api/client';
|
} from '../api/client';
|
||||||
|
import Icon from '../components/ui/Icon';
|
||||||
|
import SectionNav from '../components/SectionNav';
|
||||||
import styles from './Settings.module.css';
|
import styles from './Settings.module.css';
|
||||||
|
|
||||||
// Human-readable labels for settings keys
|
const SETTINGS_SECTIONS = [
|
||||||
const SETTINGS_META = [
|
{ id: 'parametres-generaux', label: 'Paramètres généraux' },
|
||||||
{ key: 'kit_preparation_fee_chf', label: 'Forfait préparation kit (CHF)', type: 'number', min: 0, step: 10, unit: 'CHF' },
|
{ id: 'regles', label: 'Règles APEX 4.0' },
|
||||||
{ key: 'panel_thickness_mm', label: 'Épaisseur panneaux', type: 'number', min: 1, step: 1, unit: 'mm' },
|
{ id: 'email', label: 'Email (SMTP)' },
|
||||||
{ key: 'plinth_height_min_mm', label: 'Hauteur plinthe minimale', type: 'number', min: 0, step: 5, unit: 'mm' },
|
{ id: 'fournisseurs', label: 'Fournisseurs' }
|
||||||
{ key: 'plinth_height_max_mm', label: 'Hauteur plinthe maximale', type: 'number', min: 0, step: 5, unit: 'mm' },
|
|
||||||
{ key: 'sliding_doors_min_width_mm', label: 'Largeur min. portes coulissantes', type: 'number', min: 0, step: 50, unit: 'mm' },
|
|
||||||
{ key: 'allow_sliding_doors', label: 'Autoriser portes coulissantes (1/0)', type: 'number', min: 0, step: 1, unit: '' },
|
|
||||||
{ key: 'sanitary_gap_mm', label: 'Vide sanitaire niche', type: 'number', min: 0, step: 1, unit: 'mm' },
|
|
||||||
{ key: 'drawer_depth_min_mm', label: 'Tiroir profondeur min', type: 'number', min: 0, step: 10, unit: 'mm' },
|
|
||||||
{ key: 'drawer_depth_max_mm', label: 'Tiroir profondeur max', type: 'number', min: 0, step: 10, unit: 'mm' },
|
|
||||||
{ key: 'drawer_depth_step_mm', label: 'Tiroir pas profondeur', type: 'number', min: 1, step: 1, unit: 'mm' },
|
|
||||||
{ key: 'drawer_back_gap_mm', label: 'Vide sanitaire derrière tiroir', type: 'number', min: 0, step: 1, unit: 'mm' },
|
|
||||||
{ key: 'drawer_hidden_handle_reduction_mm', label: 'Réduction profondeur tiroir caché', type: 'number', min: 0, step: 1, unit: 'mm' },
|
|
||||||
{ key: 'standard_handle_axis_mm', label: 'Axe perçage poignée standard', type: 'number', min: 0, step: 10, unit: 'mm' },
|
|
||||||
{ key: 'standard_handle_tolerance_mm', label: 'Tolérance axe poignée', type: 'number', min: 0, step: 10, unit: 'mm' },
|
|
||||||
{ key: 'groove_width_mm', label: 'Rainure largeur', type: 'number', min: 0, step: 0.5, unit: 'mm' },
|
|
||||||
{ key: 'groove_depth_mm', label: 'Rainure profondeur', type: 'number', min: 0, step: 0.5, unit: 'mm' },
|
|
||||||
{ key: 'groove_edge_offset_mm', label: 'Rainure distance au bord', type: 'number', min: 0, step: 0.5, unit: 'mm' },
|
|
||||||
{ key: 'niche_clearance_multiplier', label: 'Multiplicateur jeu niche', type: 'number', min: 1, step: 0.5, unit: '' },
|
|
||||||
{ key: 'min_clearance_mm', label: 'Jeu minimum', type: 'number', min: 0, step: 1, unit: 'mm' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SETTINGS_GROUPS = [
|
||||||
|
{
|
||||||
|
id: 'pricing',
|
||||||
|
title: 'Tarification',
|
||||||
|
description: 'Frais fixes ajoutés au devis.',
|
||||||
|
icon: 'coins',
|
||||||
|
fields: [
|
||||||
|
{ key: 'kit_preparation_fee_chf', label: 'Forfait préparation kit', hint: 'Ajouté une fois par commande', min: 0, step: 10, unit: 'CHF' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'panels',
|
||||||
|
title: 'Panneaux & plinthe',
|
||||||
|
description: 'Matière de base et socle du meuble.',
|
||||||
|
icon: 'layers',
|
||||||
|
fields: [
|
||||||
|
{ key: 'panel_thickness_mm', label: 'Épaisseur panneaux', hint: 'Épaisseur standard des caissons', min: 1, step: 1, unit: 'mm' },
|
||||||
|
{ key: 'plinth_height_min_mm', label: 'Plinthe — hauteur min', min: 0, step: 5, unit: 'mm' },
|
||||||
|
{ key: 'plinth_height_max_mm', label: 'Plinthe — hauteur max', min: 0, step: 5, unit: 'mm' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sliding',
|
||||||
|
title: 'Portes coulissantes',
|
||||||
|
description: 'Disponibilité et contraintes des portes coulissantes.',
|
||||||
|
icon: 'sliding',
|
||||||
|
fields: [
|
||||||
|
{ key: 'allow_sliding_doors', label: 'Autoriser les portes coulissantes', hint: 'Affiche l’option dans le configurateur', kind: 'toggle' },
|
||||||
|
{ key: 'sliding_doors_min_width_mm', label: 'Largeur minimale', hint: 'En dessous, l’option est masquée', min: 0, step: 50, unit: 'mm' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'drawers',
|
||||||
|
title: 'Tiroirs',
|
||||||
|
description: 'Plage de profondeurs et réductions appliquées.',
|
||||||
|
icon: 'drawer',
|
||||||
|
fields: [
|
||||||
|
{ key: 'drawer_depth_min_mm', label: 'Profondeur min', min: 0, step: 10, unit: 'mm' },
|
||||||
|
{ key: 'drawer_depth_max_mm', label: 'Profondeur max', min: 0, step: 10, unit: 'mm' },
|
||||||
|
{ key: 'drawer_depth_step_mm', label: 'Pas de profondeur', hint: 'Incrément entre longueurs de coulisses', min: 1, step: 1, unit: 'mm' },
|
||||||
|
{ key: 'drawer_back_gap_mm', label: 'Vide arrière', hint: 'Espace laissé derrière le tiroir', min: 0, step: 1, unit: 'mm' },
|
||||||
|
{ key: 'drawer_hidden_handle_reduction_mm', label: 'Réduction tiroir caché', hint: 'Soustraite pour un tiroir intérieur', min: 0, step: 1, unit: 'mm' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'handles',
|
||||||
|
title: 'Poignées',
|
||||||
|
description: 'Position standard du perçage.',
|
||||||
|
icon: 'handle',
|
||||||
|
fields: [
|
||||||
|
{ key: 'standard_handle_axis_mm', label: 'Axe de perçage standard', hint: 'Hauteur depuis le sol', min: 0, step: 10, unit: 'mm' },
|
||||||
|
{ key: 'standard_handle_tolerance_mm', label: 'Tolérance axe', hint: 'Écart accepté autour de l’axe', min: 0, step: 10, unit: 'mm' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'grooves',
|
||||||
|
title: 'Rainures (fond)',
|
||||||
|
description: 'Usinage de la rainure de panneau arrière.',
|
||||||
|
icon: 'groove',
|
||||||
|
fields: [
|
||||||
|
{ key: 'groove_width_mm', label: 'Largeur', min: 0, step: 0.5, unit: 'mm' },
|
||||||
|
{ key: 'groove_depth_mm', label: 'Profondeur', min: 0, step: 0.5, unit: 'mm' },
|
||||||
|
{ key: 'groove_edge_offset_mm', label: 'Distance au bord', min: 0, step: 0.5, unit: 'mm' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clearance',
|
||||||
|
title: 'Niche & jeux',
|
||||||
|
description: 'Jeux de pose entre le meuble et la niche.',
|
||||||
|
icon: 'clearance',
|
||||||
|
fields: [
|
||||||
|
{ key: 'sanitary_gap_mm', label: 'Vide sanitaire niche', min: 0, step: 1, unit: 'mm' },
|
||||||
|
{ key: 'min_clearance_mm', label: 'Jeu minimum', min: 0, step: 1, unit: 'mm' },
|
||||||
|
{ key: 'niche_clearance_multiplier', label: 'Multiplicateur jeu niche', hint: 'Appliqué au jeu minimum', min: 1, step: 0.5, unit: '×' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_SETTING_KEYS = SETTINGS_GROUPS.flatMap(g => g.fields.map(f => f.key));
|
||||||
|
|
||||||
const SUPPLIER_TYPES = [
|
const SUPPLIER_TYPES = [
|
||||||
{ value: 'panels', label: 'Panneaux / Matériaux' },
|
{ value: 'panels', label: 'Panneaux / Matériaux' },
|
||||||
{ value: 'hardware', label: 'Quincaillerie' },
|
{ value: 'hardware', label: 'Quincaillerie' },
|
||||||
@@ -90,6 +156,9 @@ export default function Settings() {
|
|||||||
|
|
||||||
// ─── App Settings ──────────────────────────────────────────────────────────
|
// ─── App Settings ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const isSettingDirty = key => String(settingsDraft[key] ?? '') !== String(settings[key] ?? '');
|
||||||
|
const dirtyCount = ALL_SETTING_KEYS.filter(isSettingDirty).length;
|
||||||
|
|
||||||
function handleSettingChange(key, value) {
|
function handleSettingChange(key, value) {
|
||||||
setSettingsDraft(prev => ({ ...prev, [key]: value }));
|
setSettingsDraft(prev => ({ ...prev, [key]: value }));
|
||||||
}
|
}
|
||||||
@@ -252,57 +321,106 @@ export default function Settings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<div className={styles.header}>
|
|
||||||
<h1>Paramètres</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading && <p className={styles.muted}>Chargement…</p>}
|
{loading && <p className={styles.muted}>Chargement…</p>}
|
||||||
{error && <p className={styles.error}>{error}</p>}
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<>
|
<>
|
||||||
|
<SectionNav sections={SETTINGS_SECTIONS} />
|
||||||
|
|
||||||
{/* ── App Settings ─────────────────────────────────────── */}
|
{/* ── App Settings ─────────────────────────────────────── */}
|
||||||
<section className={styles.section}>
|
<section id="parametres-generaux" className={styles.section}>
|
||||||
<h2>Paramètres généraux</h2>
|
<h2>Paramètres généraux</h2>
|
||||||
<form onSubmit={handleSettingsSave} className={styles.settingsForm}>
|
<form onSubmit={handleSettingsSave}>
|
||||||
<div className={styles.settingsGrid}>
|
<div className={styles.groupsGrid}>
|
||||||
{SETTINGS_META.map(({ key, label, type, min, step, unit }) => (
|
{SETTINGS_GROUPS.map(group => (
|
||||||
<div className={styles.field} key={key}>
|
<fieldset key={group.id} className={styles.groupCard}>
|
||||||
<label className={styles.label} htmlFor={`setting-${key}`}>
|
<legend className={styles.srOnly}>{group.title}</legend>
|
||||||
{label}
|
<div className={styles.groupHeader} title={group.description}>
|
||||||
{unit && <span className={styles.unit}>{unit}</span>}
|
<span className={styles.groupIcon}>
|
||||||
</label>
|
<Icon name={group.icon} size={18} />
|
||||||
|
</span>
|
||||||
|
<h3 className={styles.groupTitle}>{group.title}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.groupFields}>
|
||||||
|
{group.fields.map(({ key, label, hint, kind, min, step, unit }) => {
|
||||||
|
const id = `setting-${key}`;
|
||||||
|
const dirty = isSettingDirty(key);
|
||||||
|
|
||||||
|
if (kind === 'toggle') {
|
||||||
|
const checked = Number(settingsDraft[key]) === 1;
|
||||||
|
return (
|
||||||
|
<label key={key} htmlFor={id} title={hint} className={`${styles.row} ${styles.toggleRow} ${dirty ? styles.fieldDirty : ''}`}>
|
||||||
|
<span className={styles.label}>{label}</span>
|
||||||
<input
|
<input
|
||||||
id={`setting-${key}`}
|
id={id}
|
||||||
|
type="checkbox"
|
||||||
|
className={styles.toggleInput}
|
||||||
|
checked={checked}
|
||||||
|
onChange={e => handleSettingChange(key, e.target.checked ? '1' : '0')}
|
||||||
|
/>
|
||||||
|
<span className={styles.toggle} aria-hidden="true" />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} title={hint} className={`${styles.row} ${dirty ? styles.fieldDirty : ''}`}>
|
||||||
|
<label className={styles.label} htmlFor={id}>{label}</label>
|
||||||
|
<div className={styles.inputWrap}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
type={type}
|
type="number"
|
||||||
min={min}
|
min={min}
|
||||||
step={step}
|
step={step}
|
||||||
value={settingsDraft[key] ?? ''}
|
value={settingsDraft[key] ?? ''}
|
||||||
onChange={e => handleSettingChange(key, e.target.value)}
|
onChange={e => handleSettingChange(key, e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{unit && <span className={styles.inputUnit}>{unit}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.formActions}>
|
<div className={`${styles.saveBar} ${dirtyCount > 0 ? styles.saveBarActive : ''}`}>
|
||||||
<button type="submit" className={styles.btnPrimary} disabled={settingsSaving}>
|
<span className={styles.saveBarStatus}>
|
||||||
{settingsSaving ? 'Sauvegarde…' : 'Sauvegarder'}
|
{settingsMsg ? (
|
||||||
</button>
|
<span className={settingsMsg.ok ? styles.success : styles.error}>{settingsMsg.text}</span>
|
||||||
<button type="button" className={styles.btnGhost} onClick={handleSettingsReset}>
|
) : dirtyCount > 0 ? (
|
||||||
Annuler
|
`${dirtyCount} modification${dirtyCount > 1 ? 's' : ''} non sauvegardée${dirtyCount > 1 ? 's' : ''}`
|
||||||
</button>
|
) : (
|
||||||
{settingsMsg && (
|
'Aucune modification'
|
||||||
<span className={settingsMsg.ok ? styles.success : styles.error}>
|
|
||||||
{settingsMsg.text}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
|
<div className={styles.formActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.btnGhost}
|
||||||
|
onClick={handleSettingsReset}
|
||||||
|
disabled={dirtyCount === 0 || settingsSaving}
|
||||||
|
>
|
||||||
|
<Icon name="undo" size={14} /> Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={styles.btnPrimary}
|
||||||
|
disabled={settingsSaving || dirtyCount === 0}
|
||||||
|
>
|
||||||
|
<Icon name="save" size={14} /> {settingsSaving ? 'Sauvegarde…' : 'Sauvegarder'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Engineering Rules ───────────────────────────────────── */}
|
{/* ── Engineering Rules ───────────────────────────────────── */}
|
||||||
<section className={styles.section}>
|
<section id="regles" className={styles.section}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<h2>Règles APEX 4.0 (ingénierie, ergonomie, légalité)</h2>
|
<h2>Règles APEX 4.0 (ingénierie, ergonomie, légalité)</h2>
|
||||||
<button type="button" className={styles.btnPrimary} onClick={openNewRule}>
|
<button type="button" className={styles.btnPrimary} onClick={openNewRule}>
|
||||||
@@ -442,7 +560,7 @@ export default function Settings() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── SMTP / Email info ─────────────────────────────────── */}
|
{/* ── SMTP / Email info ─────────────────────────────────── */}
|
||||||
<section className={styles.section}>
|
<section id="email" className={styles.section}>
|
||||||
<h2>Configuration email (SMTP)</h2>
|
<h2>Configuration email (SMTP)</h2>
|
||||||
<p className={styles.muted}>
|
<p className={styles.muted}>
|
||||||
Ces valeurs sont définies dans le fichier <code>.env</code> du serveur. Redémarrez le serveur après modification.
|
Ces valeurs sont définies dans le fichier <code>.env</code> du serveur. Redémarrez le serveur après modification.
|
||||||
@@ -465,7 +583,7 @@ export default function Settings() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Suppliers ─────────────────────────────────────────── */}
|
{/* ── Suppliers ─────────────────────────────────────────── */}
|
||||||
<section className={styles.section}>
|
<section id="fournisseurs" className={styles.section}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<h2>Fournisseurs</h2>
|
<h2>Fournisseurs</h2>
|
||||||
<button type="button" className={styles.btnPrimary} onClick={openNewSupplier}>
|
<button type="button" className={styles.btnPrimary} onClick={openNewSupplier}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.page {
|
.page {
|
||||||
padding: 1.5rem 0 4rem;
|
padding: 0 0 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@@ -121,6 +121,242 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Settings Groups ──────────────────────────── */
|
||||||
|
|
||||||
|
.groupsGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupCard {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupCard:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupCard:focus-within {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.srOnly {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupHeader {
|
||||||
|
flex: 0 0 220px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section .groupTitle {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupDesc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupFields {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .label {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 400;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.groupCard {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupHeader {
|
||||||
|
flex-basis: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrap .input {
|
||||||
|
padding-top: 0.375rem;
|
||||||
|
padding-bottom: 0.375rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrap .input {
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputUnit {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldDirty .input {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldDirty .label::after {
|
||||||
|
content: '';
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle switch */
|
||||||
|
|
||||||
|
.toggleRow {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleInput {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #cbd5e1;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleInput:checked + .toggle {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleInput:checked + .toggle::after {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleInput:focus-visible + .toggle {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky save bar */
|
||||||
|
|
||||||
|
.saveBar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 1rem;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBarActive {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBarStatus {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBar .btnPrimary,
|
||||||
|
.saveBar .btnGhost {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnGhost:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── SMTP Info Grid ───────────────────────────── */
|
/* ── SMTP Info Grid ───────────────────────────── */
|
||||||
|
|
||||||
.smtpGrid {
|
.smtpGrid {
|
||||||
|
|||||||
@@ -24,5 +24,16 @@ [email protected]
|
|||||||
# Browser timeout in ms
|
# Browser timeout in ms
|
||||||
#HAFELE_BROWSER_TIMEOUT_MS=90000
|
#HAFELE_BROWSER_TIMEOUT_MS=90000
|
||||||
|
|
||||||
|
# Leuba Hiag (catalog import via Algolia)
|
||||||
|
# The Algolia app id / index / guest key are read live from the catalog page, so no key
|
||||||
|
# is needed here. Credentials are used to fetch customer-specific prices (via login).
|
||||||
|
#[email protected]
|
||||||
|
#LEUBAHIAG_PASSWORD=your-password
|
||||||
|
# Delay between Algolia pages in ms
|
||||||
|
#LEUBAHIAG_CRAWL_DELAY_MS=300
|
||||||
|
# Optional fallbacks if the page structure changes
|
||||||
|
#LEUBAHIAG_ALGOLIA_APP_ID=QY41R2P2B0
|
||||||
|
#LEUBAHIAG_ALGOLIA_INDEX=prd_whitelabel_kurlh_products_fr
|
||||||
|
|
||||||
# OpenAI API Key for room photo analysis (GPT-4 Vision)
|
# OpenAI API Key for room photo analysis (GPT-4 Vision)
|
||||||
OPENAI_API_KEY=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
|
OPENAI_API_KEY=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
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Dispositions types intégrées, insérées dans `layout_presets` (une fois par seed_key).
|
||||||
|
* Le client normalise ensuite blocks / rayons (positions System 32, rayon structurel, etc.).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TYPE_DEFAULTS = {
|
||||||
|
penderie: { shelves: 1, drawers: 0, hasRod: true },
|
||||||
|
tiroirs: { shelves: 1, drawers: 3, hasRod: false },
|
||||||
|
rayonnage: { shelves: 4, drawers: 0, hasRod: false },
|
||||||
|
bloc_carre: { shelves: 0, drawers: 0, hasRod: false },
|
||||||
|
mixte: { shelves: 2, drawers: 2, hasRod: true }
|
||||||
|
};
|
||||||
|
|
||||||
|
function column(key, index, type, widthMm, overrides = {}) {
|
||||||
|
const hasBack = overrides.hasBack ?? false;
|
||||||
|
return {
|
||||||
|
id: `preset-${key}-c${index + 1}`,
|
||||||
|
type,
|
||||||
|
moduleCode: type,
|
||||||
|
widthMm,
|
||||||
|
minWidth: 300,
|
||||||
|
maxWidth: type === 'tiroirs' || type === 'mixte' ? 1000 : 1200,
|
||||||
|
position: index,
|
||||||
|
locked: false,
|
||||||
|
...TYPE_DEFAULTS[type],
|
||||||
|
drawerType: 'visible',
|
||||||
|
hasBack,
|
||||||
|
hasBackTop: hasBack,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function layout(key, { name, description, widthMm, heightMm = 2400, depthMm = 600, plinthHeightMm = 100, hasBack = false, columns }) {
|
||||||
|
return {
|
||||||
|
seedKey: key,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
config: {
|
||||||
|
widthMm,
|
||||||
|
heightMm,
|
||||||
|
depthMm,
|
||||||
|
plinthHeightMm,
|
||||||
|
hasBack,
|
||||||
|
stackEnabled: columns.some((c) => Array.isArray(c[2]?.blockHeightsMm) && c[2].blockHeightsMm.length >= 2),
|
||||||
|
horizontalSpans: [],
|
||||||
|
modules: columns.map(([type, w, overrides], i) => column(key, i, type, w, { hasBack, ...overrides }))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Penderie au-delà de 2000 mm: une colonne en un seul block exige un rayon structurel à mi-hauteur,
|
||||||
|
* ce qui coupe la penderie (< 1100 mm sous tringle). Les penderies sont donc empilées: block bas de
|
||||||
|
* 1800 mm (≈ 1710 mm sous tringle, manteaux longs ≥ 1600 mm) + surmeuble; la jonction sert de chapelier.
|
||||||
|
*/
|
||||||
|
const HANGING = { blockHeightsMm: [1800, 500], shelves: 0 };
|
||||||
|
const SURMEUBLE = { blockHeightsMm: [2000, 500] };
|
||||||
|
const OPEN = { blockDoors: [false] };
|
||||||
|
|
||||||
|
export const DEFAULT_LAYOUT_PRESETS = [
|
||||||
|
layout('penderie-double', {
|
||||||
|
name: 'Penderie double',
|
||||||
|
description: 'Deux penderies manteaux longs avec surmeubles',
|
||||||
|
widthMm: 1200,
|
||||||
|
columns: [['penderie', 600, HANGING], ['penderie', 600, HANGING]]
|
||||||
|
}),
|
||||||
|
layout('classique-3', {
|
||||||
|
name: 'Classique 3 colonnes',
|
||||||
|
description: 'Penderie, rayonnage et tiroirs',
|
||||||
|
widthMm: 1800,
|
||||||
|
columns: [['penderie', 600, HANGING], ['rayonnage', 600], ['tiroirs', 600]]
|
||||||
|
}),
|
||||||
|
layout('dressing-4', {
|
||||||
|
name: 'Dressing 4 colonnes',
|
||||||
|
description: 'Deux penderies encadrant tiroirs et rayonnage',
|
||||||
|
widthMm: 2400,
|
||||||
|
columns: [['penderie', 600, HANGING], ['tiroirs', 600], ['rayonnage', 600], ['penderie', 600, HANGING]]
|
||||||
|
}),
|
||||||
|
layout('grande-penderie', {
|
||||||
|
name: 'Grande penderie',
|
||||||
|
description: 'Penderie large, rayonnage et penderie avec tiroirs',
|
||||||
|
widthMm: 2000,
|
||||||
|
columns: [['penderie', 1000, HANGING], ['rayonnage', 500], ['mixte', 500, HANGING]]
|
||||||
|
}),
|
||||||
|
layout('surmeubles', {
|
||||||
|
name: 'Avec surmeubles',
|
||||||
|
description: 'Dressing 4 colonnes surmonté de rangements hauts',
|
||||||
|
widthMm: 2400,
|
||||||
|
heightMm: 2600,
|
||||||
|
columns: [
|
||||||
|
['penderie', 600, { ...SURMEUBLE, shelves: 0 }],
|
||||||
|
['rayonnage', 600, SURMEUBLE],
|
||||||
|
['tiroirs', 600, SURMEUBLE],
|
||||||
|
['penderie', 600, { ...SURMEUBLE, shelves: 0 }]
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
layout('bibliotheque', {
|
||||||
|
name: 'Bibliothèque ouverte',
|
||||||
|
description: 'Trois colonnes de rayonnage sans portes, avec dos',
|
||||||
|
widthMm: 1500,
|
||||||
|
heightMm: 2200,
|
||||||
|
depthMm: 350,
|
||||||
|
hasBack: true,
|
||||||
|
columns: [
|
||||||
|
['rayonnage', 500, { ...OPEN, shelves: 5 }],
|
||||||
|
['rayonnage', 500, { ...OPEN, shelves: 5 }],
|
||||||
|
['rayonnage', 500, { ...OPEN, shelves: 5 }]
|
||||||
|
]
|
||||||
|
})
|
||||||
|
];
|
||||||
+138
-8
@@ -10,6 +10,7 @@ const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db
|
|||||||
const schemaPath = path.join(__dirname, 'schema.sql');
|
const schemaPath = path.join(__dirname, 'schema.sql');
|
||||||
|
|
||||||
let db = null;
|
let db = null;
|
||||||
|
let sqlJs = null;
|
||||||
|
|
||||||
/** Création idempotente — bases anciennes sans ces tables (avant migration 014). */
|
/** Création idempotente — bases anciennes sans ces tables (avant migration 014). */
|
||||||
const HARDWARE_CATALOG_DDL = `
|
const HARDWARE_CATALOG_DDL = `
|
||||||
@@ -52,23 +53,144 @@ function toSqlitePlaceholders(sql) {
|
|||||||
return sql.replace(/\$(\d+)/g, '?');
|
return sql.replace(/\$(\d+)/g, '?');
|
||||||
}
|
}
|
||||||
|
|
||||||
function save() {
|
const BACKUP_DIR = path.join(dataDir, 'backups');
|
||||||
if (db && dbPath) {
|
const MAX_BACKUP_DAYS = 14;
|
||||||
const data = db.export();
|
|
||||||
fs.writeFileSync(dbPath, Buffer.from(data));
|
/**
|
||||||
|
* Count formex_materials in an open sql.js Database instance.
|
||||||
|
*/
|
||||||
|
function countMaterialsIn(database) {
|
||||||
|
if (!database) return 0;
|
||||||
|
try {
|
||||||
|
const result = database.exec('SELECT COUNT(*) AS c FROM formex_materials');
|
||||||
|
return Number(result[0]?.values?.[0]?.[0] ?? 0);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peek material count on a DB file without touching the live in-memory DB.
|
||||||
|
*/
|
||||||
|
function countMaterialsOnDisk(filePath = dbPath) {
|
||||||
|
if (!filePath || !fs.existsSync(filePath) || !sqlJs) return 0;
|
||||||
|
let probe = null;
|
||||||
|
try {
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
if (!buffer.length) return 0;
|
||||||
|
probe = new sqlJs.Database(buffer);
|
||||||
|
return countMaterialsIn(probe);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
} finally {
|
||||||
|
try { probe?.close(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot the on-disk DB before this process touches it, so scraped data
|
||||||
|
* (Häfele, Leuba Hiag, Formex) survives accidental wipes. One backup per
|
||||||
|
* day (first start of the day), kept for MAX_BACKUP_DAYS days.
|
||||||
|
* Never replaces a richer day's backup with an emptier DB.
|
||||||
|
*/
|
||||||
|
function backupExistingDb() {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(dbPath)) return;
|
||||||
|
if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const diskCount = countMaterialsOnDisk(dbPath);
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const backupPath = path.join(BACKUP_DIR, `configurateur-${today}.db`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(backupPath)) {
|
||||||
|
if (diskCount === 0) {
|
||||||
|
console.warn('[db] skip daily backup — on-disk DB has 0 materials (protects older non-empty backups)');
|
||||||
|
} else {
|
||||||
|
fs.copyFileSync(dbPath, backupPath);
|
||||||
|
console.log(`[db] daily backup created: ${backupPath} (${diskCount} materials)`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const backupCount = countMaterialsOnDisk(backupPath);
|
||||||
|
if (diskCount > backupCount) {
|
||||||
|
fs.copyFileSync(dbPath, backupPath);
|
||||||
|
console.log(`[db] daily backup upgraded: ${backupPath} (${backupCount} → ${diskCount} materials)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const backups = fs.readdirSync(BACKUP_DIR)
|
||||||
|
.filter((f) => /^configurateur-\d{4}-\d{2}-\d{2}\.db$/.test(f))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
while (backups.length > MAX_BACKUP_DAYS) {
|
||||||
|
// Prefer deleting empty backups so a sole non-empty snapshot is kept.
|
||||||
|
let removeIdx = backups.findIndex((f) => countMaterialsOnDisk(path.join(BACKUP_DIR, f)) === 0);
|
||||||
|
if (removeIdx < 0) removeIdx = 0;
|
||||||
|
const removed = backups.splice(removeIdx, 1)[0];
|
||||||
|
fs.unlinkSync(path.join(BACKUP_DIR, removed));
|
||||||
|
console.warn(`[db] rotated backup removed: ${removed}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[db] backup failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically persist the in-memory DB.
|
||||||
|
*
|
||||||
|
* Blocks only *catastrophic* overwrites — the failure mode that kept wiping
|
||||||
|
* scrapes: a stale/empty in-memory process saving over a full on-disk catalog.
|
||||||
|
* Normal scrapes (count grows) and single-row admin deletes still save.
|
||||||
|
*/
|
||||||
|
function save({ allowShrink = false } = {}) {
|
||||||
|
if (!db || !dbPath) return false;
|
||||||
|
|
||||||
|
const memCount = countMaterialsIn(db);
|
||||||
|
if (!allowShrink && fs.existsSync(dbPath)) {
|
||||||
|
const diskCount = countMaterialsOnDisk(dbPath);
|
||||||
|
const emptyClobber = memCount === 0 && diskCount > 0;
|
||||||
|
const massShrink = diskCount >= 20 && memCount < Math.floor(diskCount * 0.1);
|
||||||
|
if (emptyClobber || massShrink) {
|
||||||
|
console.error(
|
||||||
|
`[db] REFUSING save: in-memory has ${memCount} materials but disk has ${diskCount}. ` +
|
||||||
|
'Scraped data protected. Restart the server to reload from disk.'
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(BACKUP_DIR, `configurateur-refused-${stamp}.db`),
|
||||||
|
Buffer.from(db.export())
|
||||||
|
);
|
||||||
|
} catch { /* best-effort forensic copy */ }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpPath = `${dbPath}.tmp`;
|
||||||
|
const data = Buffer.from(db.export());
|
||||||
|
fs.writeFileSync(tmpPath, data);
|
||||||
|
try {
|
||||||
|
fs.renameSync(tmpPath, dbPath);
|
||||||
|
} catch {
|
||||||
|
// Windows may not allow rename over an existing file while another handle is open.
|
||||||
|
fs.copyFileSync(tmpPath, dbPath);
|
||||||
|
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function getDb() {
|
async function getDb() {
|
||||||
if (db) return db;
|
if (db) return db;
|
||||||
const SQL = await initSqlJs({
|
sqlJs = await initSqlJs({
|
||||||
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
|
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
|
||||||
});
|
});
|
||||||
let buffer;
|
let buffer;
|
||||||
if (fs.existsSync(dbPath)) {
|
if (fs.existsSync(dbPath)) {
|
||||||
|
// sqlJs must be ready before peek-based backup logic.
|
||||||
|
backupExistingDb();
|
||||||
buffer = fs.readFileSync(dbPath);
|
buffer = fs.readFileSync(dbPath);
|
||||||
}
|
}
|
||||||
db = new SQL.Database(buffer || null);
|
db = new sqlJs.Database(buffer || null);
|
||||||
try {
|
try {
|
||||||
// Ensure full schema exists for legacy/partial DB files.
|
// Ensure full schema exists for legacy/partial DB files.
|
||||||
if (fs.existsSync(schemaPath)) {
|
if (fs.existsSync(schemaPath)) {
|
||||||
@@ -76,11 +198,13 @@ async function getDb() {
|
|||||||
db.exec(schemaSql);
|
db.exec(schemaSql);
|
||||||
}
|
}
|
||||||
db.exec(HARDWARE_CATALOG_DDL);
|
db.exec(HARDWARE_CATALOG_DDL);
|
||||||
const data = db.export();
|
// Persist schema ensures through the guarded saver (never clobber richer disk).
|
||||||
fs.writeFileSync(dbPath, Buffer.from(data));
|
save();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[db] ensureHardwareCatalogTables:', e.message);
|
console.error('[db] ensureHardwareCatalogTables:', e.message);
|
||||||
}
|
}
|
||||||
|
const loaded = countMaterialsIn(db);
|
||||||
|
console.log(`[db] loaded ${loaded} materials from ${dbPath}`);
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +229,12 @@ export const run = async (sql, params = []) => {
|
|||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Force-persist even if material count shrinks (admin bulk delete, etc.). */
|
||||||
|
export const saveAllowShrink = async () => {
|
||||||
|
await getDb();
|
||||||
|
return save({ allowShrink: true });
|
||||||
|
};
|
||||||
|
|
||||||
const pool = {
|
const pool = {
|
||||||
query: (sql, params) => query(sql, params)
|
query: (sql, params) => query(sql, params)
|
||||||
};
|
};
|
||||||
|
|||||||
+72
-3
@@ -8,12 +8,81 @@ const dataDir = path.join(__dirname, '..', 'data');
|
|||||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||||
const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db');
|
const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db');
|
||||||
|
|
||||||
|
const force = process.argv.includes('--force');
|
||||||
|
|
||||||
const SQL = await initSqlJs({
|
const SQL = await initSqlJs({
|
||||||
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
|
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
|
||||||
});
|
});
|
||||||
const db = new SQL.Database();
|
|
||||||
|
function countMaterials(database) {
|
||||||
|
try {
|
||||||
|
const result = database.exec('SELECT COUNT(*) AS c FROM formex_materials');
|
||||||
|
return Number(result[0]?.values?.[0]?.[0] ?? 0);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function atomicWrite(filePath, database) {
|
||||||
|
const tmpPath = `${filePath}.tmp`;
|
||||||
|
fs.writeFileSync(tmpPath, Buffer.from(database.export()));
|
||||||
|
try {
|
||||||
|
fs.renameSync(tmpPath, filePath);
|
||||||
|
} catch {
|
||||||
|
fs.copyFileSync(tmpPath, filePath);
|
||||||
|
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbExists = fs.existsSync(dbPath);
|
||||||
|
|
||||||
|
if (dbExists && force) {
|
||||||
|
// Backup before destroying anything.
|
||||||
|
const backupDir = path.join(dataDir, 'backups');
|
||||||
|
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||||
|
const backupPath = path.join(backupDir, `configurateur-pre-init-${stamp}.db`);
|
||||||
|
fs.copyFileSync(dbPath, backupPath);
|
||||||
|
console.log(`Existing database backed up to ${backupPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let db;
|
||||||
|
if (dbExists && !force) {
|
||||||
|
// Non-destructive: apply schema (CREATE TABLE IF NOT EXISTS) to the existing DB,
|
||||||
|
// preserving all data (scraped materials, orders, etc.).
|
||||||
|
db = new SQL.Database(fs.readFileSync(dbPath));
|
||||||
|
const before = countMaterials(db);
|
||||||
|
console.log(
|
||||||
|
`Existing database found (${before} materials) — applying schema without deleting data (use --force to recreate from scratch).`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
db = new SQL.Database();
|
||||||
|
}
|
||||||
|
|
||||||
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
|
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
|
||||||
db.exec(schema);
|
db.exec(schema);
|
||||||
fs.writeFileSync(dbPath, Buffer.from(db.export()));
|
|
||||||
|
if (dbExists && !force) {
|
||||||
|
const after = countMaterials(db);
|
||||||
|
const diskBefore = (() => {
|
||||||
|
try {
|
||||||
|
const probe = new SQL.Database(fs.readFileSync(dbPath));
|
||||||
|
const n = countMaterials(probe);
|
||||||
|
probe.close();
|
||||||
|
return n;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (diskBefore > 0 && after < diskBefore) {
|
||||||
|
console.error(
|
||||||
|
`REFUSING to write: schema apply would shrink materials ${diskBefore} → ${after}. Aborting.`
|
||||||
|
);
|
||||||
|
db.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
atomicWrite(dbPath, db);
|
||||||
|
console.log(`Schema applied. Materials on disk: ${countMaterials(db)}`);
|
||||||
db.close();
|
db.close();
|
||||||
console.log('Schema applied.');
|
|
||||||
|
|||||||
@@ -66,6 +66,28 @@ CREATE TABLE IF NOT EXISTS orders (
|
|||||||
created_at TEXT DEFAULT (datetime('now'))
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Configurations d'armoires sauvegardées pour reprise ultérieure
|
||||||
|
CREATE TABLE IF NOT EXISTS saved_designs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
config TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Dispositions types proposées à l'étape Espace (exemples intégrés + dispositions enregistrées)
|
||||||
|
CREATE TABLE IF NOT EXISTS layout_presets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
seed_key TEXT UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
config TEXT NOT NULL,
|
||||||
|
sort_order INTEGER DEFAULT 100,
|
||||||
|
active INTEGER DEFAULT 1,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
-- Matériaux Formex (données scrapées)
|
-- Matériaux Formex (données scrapées)
|
||||||
CREATE TABLE IF NOT EXISTS formex_materials (
|
CREATE TABLE IF NOT EXISTS formex_materials (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
+35
-2
@@ -14,10 +14,43 @@ if (!fs.existsSync(dbPath)) {
|
|||||||
const SQL = await initSqlJs({
|
const SQL = await initSqlJs({
|
||||||
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
|
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function countMaterials(database) {
|
||||||
|
try {
|
||||||
|
const result = database.exec('SELECT COUNT(*) AS c FROM formex_materials');
|
||||||
|
return Number(result[0]?.values?.[0]?.[0] ?? 0);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function atomicWrite(filePath, database) {
|
||||||
|
const tmpPath = `${filePath}.tmp`;
|
||||||
|
fs.writeFileSync(tmpPath, Buffer.from(database.export()));
|
||||||
|
try {
|
||||||
|
fs.renameSync(tmpPath, filePath);
|
||||||
|
} catch {
|
||||||
|
fs.copyFileSync(tmpPath, filePath);
|
||||||
|
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const buffer = fs.readFileSync(dbPath);
|
const buffer = fs.readFileSync(dbPath);
|
||||||
const db = new SQL.Database(buffer);
|
const db = new SQL.Database(buffer);
|
||||||
|
const before = countMaterials(db);
|
||||||
|
|
||||||
const seedSql = fs.readFileSync(path.join(__dirname, 'seeds.sql'), 'utf8');
|
const seedSql = fs.readFileSync(path.join(__dirname, 'seeds.sql'), 'utf8');
|
||||||
db.exec(seedSql);
|
db.exec(seedSql);
|
||||||
fs.writeFileSync(dbPath, Buffer.from(db.export()));
|
|
||||||
|
const after = countMaterials(db);
|
||||||
|
if (before > 0 && after < before) {
|
||||||
|
console.error(
|
||||||
|
`REFUSING to write seeds: materials would shrink ${before} → ${after}. Aborting.`
|
||||||
|
);
|
||||||
|
db.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
atomicWrite(dbPath, db);
|
||||||
db.close();
|
db.close();
|
||||||
console.log('Seeds applied.');
|
console.log(`Seeds applied. Materials preserved: ${after} (was ${before}).`);
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ INSERT OR REPLACE INTO settings (key, value) VALUES
|
|||||||
('kit_preparation_fee_chf', '350'),
|
('kit_preparation_fee_chf', '350'),
|
||||||
('panel_thickness_mm', '19'),
|
('panel_thickness_mm', '19'),
|
||||||
('plinth_height_min_mm', '60'),
|
('plinth_height_min_mm', '60'),
|
||||||
('plinth_height_max_mm', '160'),
|
('plinth_height_max_mm', '200'),
|
||||||
('niche_clearance_multiplier', '2'),
|
('niche_clearance_multiplier', '2'),
|
||||||
('min_clearance_mm', '20'),
|
('min_clearance_mm', '20'),
|
||||||
('sliding_doors_min_width_mm', '1200');
|
('sliding_doors_min_width_mm', '1200');
|
||||||
|
|||||||
+52
-2
@@ -11,6 +11,7 @@ dotenv.config({ path: path.join(__dirname, '.env') });
|
|||||||
import { getProducts } from './routes/products.js';
|
import { getProducts } from './routes/products.js';
|
||||||
import { getBlockTypes } from './routes/blockTypes.js';
|
import { getBlockTypes } from './routes/blockTypes.js';
|
||||||
import { getFinishes } from './routes/finishes.js';
|
import { getFinishes } from './routes/finishes.js';
|
||||||
|
import { getMaterialImage } from './routes/materialImage.js';
|
||||||
import { getServiceOptions } from './routes/serviceOptions.js';
|
import { getServiceOptions } from './routes/serviceOptions.js';
|
||||||
import { getSettings, updateSettings } from './routes/settings.js';
|
import { getSettings, updateSettings } from './routes/settings.js';
|
||||||
import {
|
import {
|
||||||
@@ -38,12 +39,17 @@ import {
|
|||||||
streamFormexSync,
|
streamFormexSync,
|
||||||
triggerHaefeleSync,
|
triggerHaefeleSync,
|
||||||
streamHaefeleSync,
|
streamHaefeleSync,
|
||||||
|
triggerLeubaHiagSync,
|
||||||
|
streamLeubaHiagSync,
|
||||||
|
getLeubaHiagMaterials,
|
||||||
|
getLeubaHiagTargets,
|
||||||
getLogs,
|
getLogs,
|
||||||
getStatus,
|
getStatus,
|
||||||
getFormexMaterials,
|
getFormexMaterials,
|
||||||
getFormexMaterial,
|
getFormexMaterial,
|
||||||
createFormexMaterial,
|
createFormexMaterial,
|
||||||
updateFormexMaterial,
|
updateFormexMaterial,
|
||||||
|
setFormexMaterialActive,
|
||||||
deleteFormexMaterial,
|
deleteFormexMaterial,
|
||||||
getFormexCollections,
|
getFormexCollections,
|
||||||
getFormexCategories,
|
getFormexCategories,
|
||||||
@@ -79,13 +85,26 @@ import {
|
|||||||
exportCncRoute,
|
exportCncRoute,
|
||||||
exportMprRoute,
|
exportMprRoute,
|
||||||
exportMprPieceRoute,
|
exportMprPieceRoute,
|
||||||
generateHardwareListRoute
|
generateHardwareListRoute,
|
||||||
|
generateCuttingPlanRoute,
|
||||||
|
exportExcelRoute
|
||||||
} from './routes/plans.js';
|
} from './routes/plans.js';
|
||||||
import {
|
import {
|
||||||
designTipsRoute,
|
designTipsRoute,
|
||||||
recommendMaterialsRoute,
|
recommendMaterialsRoute,
|
||||||
renderRoute
|
renderRoute
|
||||||
} from './routes/ai.js';
|
} from './routes/ai.js';
|
||||||
|
import {
|
||||||
|
getSavedDesigns,
|
||||||
|
createSavedDesign,
|
||||||
|
updateSavedDesign,
|
||||||
|
deleteSavedDesign
|
||||||
|
} from './routes/savedDesigns.js';
|
||||||
|
import {
|
||||||
|
getLayoutPresets,
|
||||||
|
createLayoutPreset,
|
||||||
|
deleteLayoutPreset
|
||||||
|
} from './routes/layoutPresets.js';
|
||||||
import { initScheduler } from './services/scheduler.js';
|
import { initScheduler } from './services/scheduler.js';
|
||||||
|
|
||||||
const clientDist = path.join(__dirname, '..', 'client', 'dist');
|
const clientDist = path.join(__dirname, '..', 'client', 'dist');
|
||||||
@@ -100,6 +119,7 @@ app.use(express.json());
|
|||||||
app.get('/api/products', getProducts);
|
app.get('/api/products', getProducts);
|
||||||
app.get('/api/block-types', getBlockTypes);
|
app.get('/api/block-types', getBlockTypes);
|
||||||
app.get('/api/finishes', getFinishes);
|
app.get('/api/finishes', getFinishes);
|
||||||
|
app.get('/api/material-image', getMaterialImage);
|
||||||
app.get('/api/service-options', getServiceOptions);
|
app.get('/api/service-options', getServiceOptions);
|
||||||
app.get('/api/settings', getSettings);
|
app.get('/api/settings', getSettings);
|
||||||
app.put('/api/settings', updateSettings);
|
app.put('/api/settings', updateSettings);
|
||||||
@@ -108,6 +128,13 @@ app.post('/api/engineering-rules', createEngineeringRule);
|
|||||||
app.put('/api/engineering-rules/:id', updateEngineeringRule);
|
app.put('/api/engineering-rules/:id', updateEngineeringRule);
|
||||||
app.delete('/api/engineering-rules/:id', deleteEngineeringRule);
|
app.delete('/api/engineering-rules/:id', deleteEngineeringRule);
|
||||||
app.post('/api/price', postPrice);
|
app.post('/api/price', postPrice);
|
||||||
|
app.get('/api/saved-designs', getSavedDesigns);
|
||||||
|
app.post('/api/saved-designs', createSavedDesign);
|
||||||
|
app.put('/api/saved-designs/:id', updateSavedDesign);
|
||||||
|
app.delete('/api/saved-designs/:id', deleteSavedDesign);
|
||||||
|
app.get('/api/layout-presets', getLayoutPresets);
|
||||||
|
app.post('/api/layout-presets', createLayoutPreset);
|
||||||
|
app.delete('/api/layout-presets/:id', deleteLayoutPreset);
|
||||||
app.get('/api/orders', getOrders);
|
app.get('/api/orders', getOrders);
|
||||||
app.post('/api/orders', postOrder);
|
app.post('/api/orders', postOrder);
|
||||||
app.get('/api/orders/:id/supplier-vouchers', getOrderSupplierVouchers);
|
app.get('/api/orders/:id/supplier-vouchers', getOrderSupplierVouchers);
|
||||||
@@ -129,6 +156,9 @@ app.post('/api/sync/formex', triggerFormexSync);
|
|||||||
app.get('/api/sync/formex/stream', streamFormexSync);
|
app.get('/api/sync/formex/stream', streamFormexSync);
|
||||||
app.post('/api/sync/haefele', triggerHaefeleSync);
|
app.post('/api/sync/haefele', triggerHaefeleSync);
|
||||||
app.get('/api/sync/haefele/stream', streamHaefeleSync);
|
app.get('/api/sync/haefele/stream', streamHaefeleSync);
|
||||||
|
app.get('/api/sync/leubahiag/targets', getLeubaHiagTargets);
|
||||||
|
app.post('/api/sync/leubahiag', triggerLeubaHiagSync);
|
||||||
|
app.get('/api/sync/leubahiag/stream', streamLeubaHiagSync);
|
||||||
app.get('/api/sync/logs', getLogs);
|
app.get('/api/sync/logs', getLogs);
|
||||||
app.get('/api/sync/status', getStatus);
|
app.get('/api/sync/status', getStatus);
|
||||||
|
|
||||||
@@ -137,11 +167,13 @@ app.get('/api/formex/materials', getFormexMaterials);
|
|||||||
app.get('/api/formex/materials/:id', getFormexMaterial);
|
app.get('/api/formex/materials/:id', getFormexMaterial);
|
||||||
app.post('/api/formex/materials', createFormexMaterial);
|
app.post('/api/formex/materials', createFormexMaterial);
|
||||||
app.put('/api/formex/materials/:id', updateFormexMaterial);
|
app.put('/api/formex/materials/:id', updateFormexMaterial);
|
||||||
|
app.patch('/api/formex/materials/:id/active', setFormexMaterialActive);
|
||||||
app.delete('/api/formex/materials/:id', deleteFormexMaterial);
|
app.delete('/api/formex/materials/:id', deleteFormexMaterial);
|
||||||
app.get('/api/formex/collections', getFormexCollections);
|
app.get('/api/formex/collections', getFormexCollections);
|
||||||
app.get('/api/formex/categories', getFormexCategories);
|
app.get('/api/formex/categories', getFormexCategories);
|
||||||
app.get('/api/formex/suppliers', getFormexSuppliers);
|
app.get('/api/formex/suppliers', getFormexSuppliers);
|
||||||
app.get('/api/haefele/materials', getHaefeleMaterials);
|
app.get('/api/haefele/materials', getHaefeleMaterials);
|
||||||
|
app.get('/api/leubahiag/materials', getLeubaHiagMaterials);
|
||||||
|
|
||||||
// Room analysis routes (AI-powered)
|
// Room analysis routes (AI-powered)
|
||||||
app.post('/api/room/analyze', uploadMiddleware, analyzeRoom);
|
app.post('/api/room/analyze', uploadMiddleware, analyzeRoom);
|
||||||
@@ -173,9 +205,11 @@ app.post('/api/ai/render', renderRoute);
|
|||||||
// Plans and cutting list routes
|
// Plans and cutting list routes
|
||||||
app.post('/api/plans/generate', generatePlansRoute);
|
app.post('/api/plans/generate', generatePlansRoute);
|
||||||
app.post('/api/plans/cutting-list', generateCuttingListRoute);
|
app.post('/api/plans/cutting-list', generateCuttingListRoute);
|
||||||
|
app.post('/api/plans/cutting-plan', generateCuttingPlanRoute);
|
||||||
app.post('/api/plans/hardware-list', generateHardwareListRoute);
|
app.post('/api/plans/hardware-list', generateHardwareListRoute);
|
||||||
app.post('/api/plans/export-text', exportCuttingListText);
|
app.post('/api/plans/export-text', exportCuttingListText);
|
||||||
app.post('/api/plans/export-pdf', exportPDFHtml);
|
app.post('/api/plans/export-pdf', exportPDFHtml);
|
||||||
|
app.post('/api/plans/export-excel', exportExcelRoute);
|
||||||
app.post('/api/plans/export-cnc', exportCncRoute);
|
app.post('/api/plans/export-cnc', exportCncRoute);
|
||||||
app.post('/api/plans/export-mpr', exportMprRoute);
|
app.post('/api/plans/export-mpr', exportMprRoute);
|
||||||
app.post('/api/plans/export-mpr-piece', exportMprPieceRoute);
|
app.post('/api/plans/export-mpr-piece', exportMprPieceRoute);
|
||||||
@@ -242,7 +276,8 @@ async function applyMigration007() {
|
|||||||
await run(`INSERT OR IGNORE INTO suppliers (code, name, email, type) VALUES
|
await run(`INSERT OR IGNORE INTO suppliers (code, name, email, type) VALUES
|
||||||
('formex', 'Formex', '[email protected]', 'panels'),
|
('formex', 'Formex', '[email protected]', 'panels'),
|
||||||
('blum', 'Blum Switzerland', '[email protected]', 'hardware'),
|
('blum', 'Blum Switzerland', '[email protected]', 'hardware'),
|
||||||
('haefele', 'Hafele', '[email protected]', 'materials')`);
|
('haefele', 'Hafele', '[email protected]', 'materials'),
|
||||||
|
('leubahiag', 'Leuba Hiag', '[email protected]', 'materials')`);
|
||||||
|
|
||||||
// Assign existing rows to their suppliers
|
// Assign existing rows to their suppliers
|
||||||
await run(`UPDATE formex_materials SET supplier_id = (SELECT id FROM suppliers WHERE code = 'formex') WHERE supplier_id IS NULL`);
|
await run(`UPDATE formex_materials SET supplier_id = (SELECT id FROM suppliers WHERE code = 'formex') WHERE supplier_id IS NULL`);
|
||||||
@@ -378,6 +413,21 @@ async function applyMigration013() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await applyMigration013();
|
await applyMigration013();
|
||||||
|
|
||||||
|
// Apply migration 014: widen plinth max to 200 mm (remarque client) without clobbering custom values.
|
||||||
|
async function applyMigration014() {
|
||||||
|
try {
|
||||||
|
await run('INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2)', ['plinth_height_min_mm', '60']);
|
||||||
|
await run('INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2)', ['plinth_height_max_mm', '200']);
|
||||||
|
// Bump legacy default 160 -> 200; leave any other (customized) value untouched.
|
||||||
|
await run("UPDATE settings SET value = '200' WHERE key = 'plinth_height_max_mm' AND value = '160'");
|
||||||
|
console.log('[migration-014] Plinth max widened to 200 mm.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[migration-014] Error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyMigration014();
|
||||||
await ensureEngineeringRulesSeed();
|
await ensureEngineeringRulesSeed();
|
||||||
|
|
||||||
// Initialize the scheduler for automated sync
|
// Initialize the scheduler for automated sync
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,8 @@
|
|||||||
"dev": "node --watch index.js",
|
"dev": "node --watch index.js",
|
||||||
"db:init": "node db/init.js",
|
"db:init": "node db/init.js",
|
||||||
"db:seed": "node db/seed.js",
|
"db:seed": "node db/seed.js",
|
||||||
"db:migrate": "node db/migrate.js"
|
"db:migrate": "node db/migrate.js",
|
||||||
|
"db:restore-materials": "node scripts/restore-materials-backup.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cheerio": "^1.0.0",
|
"cheerio": "^1.0.0",
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ const DEFAULT_ENGINEERING_RULES = [
|
|||||||
details:
|
details:
|
||||||
"Recul face plinthe >= 50 mm par rapport à la façade.\n" +
|
"Recul face plinthe >= 50 mm par rapport à la façade.\n" +
|
||||||
'Calcul de référence: porte 19 + jeu 3 + retrait base 28 = 50 mm.\n' +
|
'Calcul de référence: porte 19 + jeu 3 + retrait base 28 = 50 mm.\n' +
|
||||||
"Avec plinthe 19 mm: centre pied Axilo à 69 mm des bords avant/arrière."
|
'AXILO 78 Press-fit réf. 637.76.710: centre avant à 59 mm du chant avant (plinthe finie 19 + 40), ' +
|
||||||
|
'centre arrière à 50 mm du chant arrière; 4 perçages borgnes Ø10 × 12 mm par platine, entraxe 64 mm.\n' +
|
||||||
|
'Répartition: 4 pieds aux extrémités de l\'armoire + 2 pieds (avant/arrière) tous les 600 mm.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sort_order: 60,
|
sort_order: 60,
|
||||||
@@ -144,8 +146,20 @@ const DEFAULT_ENGINEERING_RULES = [
|
|||||||
{
|
{
|
||||||
sort_order: 210,
|
sort_order: 210,
|
||||||
chapter: '4.C',
|
chapter: '4.C',
|
||||||
title: 'Tiroirs intérieurs - hauteurs de façades',
|
title: 'Tiroirs intérieurs - hauteurs de côté (Blum Legrabox)',
|
||||||
details: 'Variantes autorisées: 60, 90, 128, 177, 240 mm.'
|
details:
|
||||||
|
'Hauteurs de côté autorisées: 66 (N), 90 (M), 128 (K), 177 (C), 240 (F) mm.\n' +
|
||||||
|
'Hauteurs de niche SMS correspondantes: 80, 106, 144, 193, 257 mm (à partir de 1/6, 1/6, 2/6, 2/6, 3/6 — 1/6 = 127 mm).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sort_order: 215,
|
||||||
|
chapter: '4.C',
|
||||||
|
title: 'Tiroirs Legrabox - débit 16 mm et perçages CNC',
|
||||||
|
details:
|
||||||
|
'Fond 16 mm: L = NL − 10, l = X − 35, feuillure 38 × 8 mm G+D (X = largeur libre corps).\n' +
|
||||||
|
'Paroi arrière 16 mm: l = X − 38, h = 39/63/101/148/212 mm selon N/M/K/C/F.\n' +
|
||||||
|
'Coulisses (Système 32, depuis chant avant): 40 kg → 37, 69, 197, 229, 261, 293, 357 · 70 kg → 37, 69, 261, 293, 357.\n' +
|
||||||
|
'Façade INSERTA: cheville expansible Ø 10 mm, profondeur > 12 mm.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sort_order: 220,
|
sort_order: 220,
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import pool from '../db/index.js';
|
import pool from '../db/index.js';
|
||||||
|
|
||||||
|
const CONFIGURATOR_MATERIAL_FILTER = `
|
||||||
|
active = 1
|
||||||
|
AND NOT (
|
||||||
|
supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele')
|
||||||
|
OR LOWER(COALESCE(collection, '')) = 'hafele'
|
||||||
|
OR formex_code LIKE 'HAF-%'
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/finishes
|
* GET /api/finishes
|
||||||
* Returns active Formex materials as finishes for the configurator
|
* Returns active panel materials as finishes for the configurator
|
||||||
* Falls back to legacy finishes if no Formex materials exist
|
* Falls back to legacy finishes if no Formex materials exist
|
||||||
*/
|
*/
|
||||||
export async function getFinishes(req, res) {
|
export async function getFinishes(req, res) {
|
||||||
try {
|
try {
|
||||||
// Check if we have active Formex materials
|
// Hardware suppliers can share formex_materials for catalog synchronization,
|
||||||
|
// but their products must never be offered as panel finishes.
|
||||||
const formexResult = await pool.query(
|
const formexResult = await pool.query(
|
||||||
`SELECT COUNT(*) as count FROM formex_materials WHERE active = 1`
|
`SELECT COUNT(*) as count FROM formex_materials WHERE ${CONFIGURATOR_MATERIAL_FILTER}`
|
||||||
);
|
);
|
||||||
const hasFormexMaterials = (formexResult.rows[0]?.count || 0) > 0;
|
const hasFormexMaterials = (formexResult.rows[0]?.count || 0) > 0;
|
||||||
|
|
||||||
@@ -32,7 +42,7 @@ export async function getFinishes(req, res) {
|
|||||||
dominant_color as hexColor,
|
dominant_color as hexColor,
|
||||||
'formex' as source
|
'formex' as source
|
||||||
FROM formex_materials
|
FROM formex_materials
|
||||||
WHERE active = 1
|
WHERE ${CONFIGURATOR_MATERIAL_FILTER}
|
||||||
ORDER BY collection, name
|
ORDER BY collection, name
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import pool, { run } from '../db/index.js';
|
||||||
|
import { DEFAULT_LAYOUT_PRESETS } from '../db/defaultLayouts.js';
|
||||||
|
|
||||||
|
const MAX_NAME_LENGTH = 100;
|
||||||
|
const PRESET_COLUMNS = 'id, seed_key, name, description, config, sort_order, created_at, updated_at';
|
||||||
|
|
||||||
|
let seeded = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronise les dispositions intégrées avec leur définition en code (insertion ou mise à jour).
|
||||||
|
* Une disposition intégrée supprimée reste masquée (active = 0).
|
||||||
|
*/
|
||||||
|
function ensureDefaultLayouts() {
|
||||||
|
if (!seeded) {
|
||||||
|
seeded = (async () => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT seed_key, name, description, config, sort_order FROM layout_presets WHERE seed_key IS NOT NULL'
|
||||||
|
);
|
||||||
|
const existing = new Map(rows.map((r) => [r.seed_key, r]));
|
||||||
|
for (const [index, preset] of DEFAULT_LAYOUT_PRESETS.entries()) {
|
||||||
|
const config = JSON.stringify(preset.config);
|
||||||
|
const row = existing.get(preset.seedKey);
|
||||||
|
if (row) {
|
||||||
|
const unchanged = row.name === preset.name && row.description === preset.description
|
||||||
|
&& row.config === config && Number(row.sort_order) === index;
|
||||||
|
if (!unchanged) {
|
||||||
|
await run(
|
||||||
|
`UPDATE layout_presets
|
||||||
|
SET name = $1, description = $2, config = $3, sort_order = $4, updated_at = datetime('now')
|
||||||
|
WHERE seed_key = $5`,
|
||||||
|
[preset.name, preset.description, config, index, preset.seedKey]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await run(
|
||||||
|
`INSERT INTO layout_presets (seed_key, name, description, config, sort_order)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[preset.seedKey, preset.name, preset.description, config, index]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})().catch((err) => {
|
||||||
|
seeded = null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return seeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePreset(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
let config = null;
|
||||||
|
try {
|
||||||
|
config = JSON.parse(row.config);
|
||||||
|
} catch { /* config illisible → null */ }
|
||||||
|
return { ...row, builtin: row.seed_key != null, config };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLayoutPresets(req, res) {
|
||||||
|
try {
|
||||||
|
await ensureDefaultLayouts();
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT ${PRESET_COLUMNS}
|
||||||
|
FROM layout_presets
|
||||||
|
WHERE active = 1
|
||||||
|
ORDER BY sort_order ASC, id ASC`
|
||||||
|
);
|
||||||
|
res.json(rows.map(parsePreset));
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLayoutPreset(req, res) {
|
||||||
|
try {
|
||||||
|
const name = String(req.body?.name || '').trim();
|
||||||
|
const description = String(req.body?.description || '').trim() || null;
|
||||||
|
const config = req.body?.config;
|
||||||
|
|
||||||
|
if (!name) return res.status(400).json({ error: 'Le nom est requis.' });
|
||||||
|
if (name.length > MAX_NAME_LENGTH) {
|
||||||
|
return res.status(400).json({ error: `Le nom ne peut pas dépasser ${MAX_NAME_LENGTH} caractères.` });
|
||||||
|
}
|
||||||
|
if (!config || typeof config !== 'object' || Array.isArray(config) || !Array.isArray(config.modules) || config.modules.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Disposition invalide (au moins une colonne requise).' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureDefaultLayouts();
|
||||||
|
await run(
|
||||||
|
`INSERT INTO layout_presets (name, description, config, sort_order)
|
||||||
|
VALUES ($1, $2, $3, 1000)`,
|
||||||
|
[name, description, JSON.stringify(config)]
|
||||||
|
);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT ${PRESET_COLUMNS}
|
||||||
|
FROM layout_presets
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1`
|
||||||
|
);
|
||||||
|
res.status(201).json(parsePreset(rows[0]));
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLayoutPreset(req, res) {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(id) || id < 1) {
|
||||||
|
return res.status(400).json({ error: 'Identifiant invalide.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query('SELECT id, seed_key FROM layout_presets WHERE id = $1 LIMIT 1', [id]);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'Disposition introuvable.' });
|
||||||
|
|
||||||
|
if (rows[0].seed_key != null) {
|
||||||
|
await run(`UPDATE layout_presets SET active = 0, updated_at = datetime('now') WHERE id = $1`, [id]);
|
||||||
|
} else {
|
||||||
|
await run('DELETE FROM layout_presets WHERE id = $1', [id]);
|
||||||
|
}
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/material-image?url=<encoded absolute image url>
|
||||||
|
*
|
||||||
|
* Same-origin proxy for remote material swatch images.
|
||||||
|
*
|
||||||
|
* The 3D preview turns swatch images into WebGL textures via a <canvas>.
|
||||||
|
* Browsers taint a canvas (and refuse `getImageData` / texture upload) when the
|
||||||
|
* source image comes from another origin without CORS headers. Suppliers such
|
||||||
|
* as Leuba Hiag serve their swatches from their own domain with no
|
||||||
|
* `Access-Control-Allow-Origin`, so the textures silently fail and every
|
||||||
|
* element falls back to its default colour.
|
||||||
|
*
|
||||||
|
* By streaming the bytes back through our own origin the canvas stays clean and
|
||||||
|
* the material textures/colours apply correctly in 3D.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Only proxy http(s) images from these hosts (defence against SSRF). */
|
||||||
|
const ALLOWED_HOST_SUFFIXES = [
|
||||||
|
'leubahiag.ch',
|
||||||
|
'formex.ch',
|
||||||
|
'haefele.ch',
|
||||||
|
'haefele.com',
|
||||||
|
'haefele.de',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Small in-memory cache so repeated texture loads don't refetch remotely. */
|
||||||
|
const cache = new Map(); // url -> { buffer, contentType, expires }
|
||||||
|
const MAX_CACHE_ENTRIES = 400;
|
||||||
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
||||||
|
|
||||||
|
function isAllowedHost(hostname) {
|
||||||
|
const host = hostname.toLowerCase();
|
||||||
|
return ALLOWED_HOST_SUFFIXES.some(
|
||||||
|
(suffix) => host === suffix || host.endsWith(`.${suffix}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMaterialImage(req, res) {
|
||||||
|
const raw = req.query.url;
|
||||||
|
if (!raw || typeof raw !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Missing url parameter' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let target;
|
||||||
|
try {
|
||||||
|
target = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: 'Invalid url' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
||||||
|
return res.status(400).json({ error: 'Unsupported protocol' });
|
||||||
|
}
|
||||||
|
if (!isAllowedHost(target.hostname)) {
|
||||||
|
return res.status(403).json({ error: 'Host not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = target.toString();
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = cache.get(key);
|
||||||
|
if (cached && cached.expires > now) {
|
||||||
|
res.set('Content-Type', cached.contentType);
|
||||||
|
res.set('Access-Control-Allow-Origin', '*');
|
||||||
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
|
return res.send(cached.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(key, { headers: { Accept: 'image/*' } });
|
||||||
|
if (!response.ok) {
|
||||||
|
return res.status(502).json({ error: `Upstream ${response.status}` });
|
||||||
|
}
|
||||||
|
const contentType = response.headers.get('content-type') || 'image/jpeg';
|
||||||
|
if (!contentType.startsWith('image/')) {
|
||||||
|
return res.status(415).json({ error: 'Not an image' });
|
||||||
|
}
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
|
||||||
|
if (cache.size >= MAX_CACHE_ENTRIES) {
|
||||||
|
const oldestKey = cache.keys().next().value;
|
||||||
|
if (oldestKey !== undefined) cache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
cache.set(key, { buffer, contentType, expires: now + CACHE_TTL_MS });
|
||||||
|
|
||||||
|
res.set('Content-Type', contentType);
|
||||||
|
res.set('Access-Control-Allow-Origin', '*');
|
||||||
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
|
return res.send(buffer);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(502).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import { generateCncProgram } from '../services/cncGenerator.js';
|
|||||||
import { validateConfig } from '../services/configValidation.js';
|
import { validateConfig } from '../services/configValidation.js';
|
||||||
import { generateHardwareList } from '../services/hardwareListEngine.js';
|
import { generateHardwareList } from '../services/hardwareListEngine.js';
|
||||||
import { generateMprZipBuffer, generateMprPieceDownload } from '../services/mprGenerator.js';
|
import { generateMprZipBuffer, generateMprPieceDownload } from '../services/mprGenerator.js';
|
||||||
|
import { generateCuttingPlan } from '../services/cuttingPlanEngine.js';
|
||||||
|
import { buildCuttingListXlsx } from '../services/exportExcel.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/plans/generate
|
* POST /api/plans/generate
|
||||||
@@ -241,6 +243,68 @@ export async function exportMprPieceRoute(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/plans/cutting-plan
|
||||||
|
* Génère le plan de coupe (calepinage) avec % de chute par panneau et global.
|
||||||
|
*/
|
||||||
|
export async function generateCuttingPlanRoute(req, res) {
|
||||||
|
try {
|
||||||
|
const { config, modules = [], colors = {}, stockBoard, kerfMm, trimMm, allowRotation } = req.body || {};
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return res.status(400).json({ error: 'Configuration requise' });
|
||||||
|
}
|
||||||
|
const validation = validateConfig(config);
|
||||||
|
if (validation.errors.length > 0) {
|
||||||
|
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cuttingList = generateCuttingList(config, colors, modules);
|
||||||
|
const cuttingPlan = generateCuttingPlan(cuttingList, { stockBoard, kerfMm, trimMm, allowRotation });
|
||||||
|
|
||||||
|
res.json({ success: true, cuttingPlan, warnings: validation.warnings });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cutting plan error:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Erreur de génération du plan de coupe' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/plans/export-excel
|
||||||
|
* Export de la liste de débitage (+ quincaillerie + calepinage) au format Excel (.xlsx).
|
||||||
|
*/
|
||||||
|
export async function exportExcelRoute(req, res) {
|
||||||
|
try {
|
||||||
|
const { config, modules = [], colors = {} } = req.body || {};
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return res.status(400).json({ error: 'Configuration requise' });
|
||||||
|
}
|
||||||
|
const validation = validateConfig(config);
|
||||||
|
if (validation.errors.length > 0) {
|
||||||
|
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cuttingList = generateCuttingList(config, colors, modules);
|
||||||
|
const cuttingPlan = generateCuttingPlan(cuttingList, {});
|
||||||
|
let hardware = null;
|
||||||
|
try {
|
||||||
|
hardware = await generateHardwareList({ config, modules, colors });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[export-excel] hardware list unavailable:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await buildCuttingListXlsx({ cuttingList, hardware, cuttingPlan });
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="liste-debitage.xlsx"');
|
||||||
|
res.send(Buffer.from(buffer));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export Excel error:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Erreur d\'export Excel' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/plans/hardware-list
|
* POST /api/plans/hardware-list
|
||||||
* Generate bill of materials for hardware/quincaillerie with prices (best-effort).
|
* Generate bill of materials for hardware/quincaillerie with prices (best-effort).
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import pool, { run } from '../db/index.js';
|
||||||
|
|
||||||
|
const MAX_NAME_LENGTH = 100;
|
||||||
|
|
||||||
|
function parseDesign(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
try {
|
||||||
|
return { ...row, config: JSON.parse(row.config) };
|
||||||
|
} catch {
|
||||||
|
return { ...row, config: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSavedDesigns(req, res) {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, name, config, created_at, updated_at
|
||||||
|
FROM saved_designs
|
||||||
|
ORDER BY updated_at DESC, id DESC`
|
||||||
|
);
|
||||||
|
res.json(rows.map(parseDesign));
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSavedDesign(req, res) {
|
||||||
|
try {
|
||||||
|
const name = String(req.body?.name || '').trim();
|
||||||
|
const config = req.body?.config;
|
||||||
|
|
||||||
|
if (!name) return res.status(400).json({ error: 'Le nom est requis.' });
|
||||||
|
if (name.length > MAX_NAME_LENGTH) {
|
||||||
|
return res.status(400).json({ error: `Le nom ne peut pas dépasser ${MAX_NAME_LENGTH} caractères.` });
|
||||||
|
}
|
||||||
|
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
||||||
|
return res.status(400).json({ error: 'Configuration invalide.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await run(
|
||||||
|
`INSERT INTO saved_designs (name, config)
|
||||||
|
VALUES ($1, $2)`,
|
||||||
|
[name, JSON.stringify(config)]
|
||||||
|
);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, name, config, created_at, updated_at
|
||||||
|
FROM saved_designs
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1`
|
||||||
|
);
|
||||||
|
res.status(201).json(parseDesign(rows[0]));
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSavedDesign(req, res) {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const name = String(req.body?.name || '').trim();
|
||||||
|
const config = req.body?.config;
|
||||||
|
|
||||||
|
if (!Number.isInteger(id) || id < 1) {
|
||||||
|
return res.status(400).json({ error: 'Identifiant invalide.' });
|
||||||
|
}
|
||||||
|
if (!name) return res.status(400).json({ error: 'Le nom est requis.' });
|
||||||
|
if (name.length > MAX_NAME_LENGTH) {
|
||||||
|
return res.status(400).json({ error: `Le nom ne peut pas dépasser ${MAX_NAME_LENGTH} caractères.` });
|
||||||
|
}
|
||||||
|
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
||||||
|
return res.status(400).json({ error: 'Configuration invalide.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query('SELECT id FROM saved_designs WHERE id = $1 LIMIT 1', [id]);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'Armoire sauvegardée introuvable.' });
|
||||||
|
|
||||||
|
await run(
|
||||||
|
`UPDATE saved_designs
|
||||||
|
SET name = $1, config = $2, updated_at = datetime('now')
|
||||||
|
WHERE id = $3`,
|
||||||
|
[name, JSON.stringify(config), id]
|
||||||
|
);
|
||||||
|
const { rows: updatedRows } = await pool.query(
|
||||||
|
`SELECT id, name, config, created_at, updated_at
|
||||||
|
FROM saved_designs
|
||||||
|
WHERE id = $1
|
||||||
|
LIMIT 1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
res.json(parseDesign(updatedRows[0]));
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSavedDesign(req, res) {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(id) || id < 1) {
|
||||||
|
return res.status(400).json({ error: 'Identifiant invalide.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query('SELECT id FROM saved_designs WHERE id = $1 LIMIT 1', [id]);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'Armoire sauvegardée introuvable.' });
|
||||||
|
|
||||||
|
await run('DELETE FROM saved_designs WHERE id = $1', [id]);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { sync, getSyncLogs, getMaterials } from '../services/formexCrawler.js';
|
import { sync, getSyncLogs, getMaterials } from '../services/formexCrawler.js';
|
||||||
import { syncHaefeleByUrls } from '../services/haefeleCrawler.js';
|
import { syncHaefeleByUrls } from '../services/haefeleCrawler.js';
|
||||||
|
import { syncLeubaHiag, LEUBA_HIAG_TARGETS } from '../services/leubaHiagCrawler.js';
|
||||||
import { getSchedulerStatus } from '../services/scheduler.js';
|
import { getSchedulerStatus } from '../services/scheduler.js';
|
||||||
import pool, { run } from '../db/index.js';
|
import pool, { run } from '../db/index.js';
|
||||||
|
|
||||||
@@ -206,6 +207,150 @@ export async function getHaefeleMaterials(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/sync/leubahiag/targets
|
||||||
|
* List the curated Leuba Hiag catalog lines available for import.
|
||||||
|
*/
|
||||||
|
export function getLeubaHiagTargets(req, res) {
|
||||||
|
res.json(LEUBA_HIAG_TARGETS.map(({ key, label, tier }) => ({ key, label, tier })));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTargetsParam(value) {
|
||||||
|
if (Array.isArray(value)) return value.filter(Boolean);
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
return value.split(/[\n,;]+/g).map((v) => v.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/sync/leubahiag
|
||||||
|
* Trigger a Leuba Hiag curated catalog sync (non-streaming).
|
||||||
|
* Body: { targets?: string[], activate?: boolean, credentials?: {...} }
|
||||||
|
*/
|
||||||
|
export async function triggerLeubaHiagSync(req, res) {
|
||||||
|
try {
|
||||||
|
const targets = parseTargetsParam(req.body?.targets);
|
||||||
|
const activate = req.body?.activate === true || req.body?.activate === '1';
|
||||||
|
const fetchPrices = req.body?.fetchPrices !== false && req.body?.fetchPrices !== '0';
|
||||||
|
const credentials = {
|
||||||
|
username: req.body?.username || req.body?.email,
|
||||||
|
password: req.body?.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await syncLeubaHiag({ targets, activate, fetchPrices, credentials }, () => {});
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error || 'Sync Leuba Hiag échouée',
|
||||||
|
itemsCount: result.itemsCount || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Synchronisation Leuba Hiag terminée: ${result.itemsCount} produits`,
|
||||||
|
itemsCount: result.itemsCount,
|
||||||
|
createdCount: result.createdCount || 0,
|
||||||
|
updatedCount: result.updatedCount || 0,
|
||||||
|
pricedCount: result.pricedCount || 0,
|
||||||
|
duration: result.duration,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/sync/leubahiag/stream
|
||||||
|
* SSE endpoint for Leuba Hiag sync progress.
|
||||||
|
* Query: targets=key1,key2 (optional), activate=1 (optional)
|
||||||
|
*/
|
||||||
|
export async function streamLeubaHiagSync(req, res) {
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
res.flushHeaders();
|
||||||
|
|
||||||
|
const emit = (data) => {
|
||||||
|
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
// client disconnected
|
||||||
|
});
|
||||||
|
|
||||||
|
const targets = parseTargetsParam(req.query.targets);
|
||||||
|
const activate = req.query.activate === '1' || req.query.activate === 'true';
|
||||||
|
const fetchPrices = req.query.prices !== '0' && req.query.prices !== 'false';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await syncLeubaHiag({ targets, activate, fetchPrices }, emit);
|
||||||
|
} catch (err) {
|
||||||
|
emit({ type: 'error', error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/leubahiag/materials
|
||||||
|
* List Leuba Hiag materials that were imported.
|
||||||
|
*/
|
||||||
|
export async function getLeubaHiagMaterials(req, res) {
|
||||||
|
try {
|
||||||
|
const { search = '', active, page = '1', limit = '25' } = req.query;
|
||||||
|
const pageNum = Math.max(1, parseInt(page, 10));
|
||||||
|
const limitNum = Math.min(200, Math.max(1, parseInt(limit, 10)));
|
||||||
|
const offset = (pageNum - 1) * limitNum;
|
||||||
|
|
||||||
|
const whereClauses = [
|
||||||
|
`(supplier_id = (SELECT id FROM suppliers WHERE code = 'leubahiag') OR formex_code LIKE 'LH-%')`
|
||||||
|
];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClauses.push('(name LIKE ? OR formex_code LIKE ? OR collection LIKE ?)');
|
||||||
|
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
||||||
|
}
|
||||||
|
if (active === '1' || active === 'true') {
|
||||||
|
whereClauses.push('active = 1');
|
||||||
|
} else if (active === '0' || active === 'false') {
|
||||||
|
whereClauses.push('active = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSQL = `WHERE ${whereClauses.join(' AND ')}`;
|
||||||
|
const countResult = await pool.query(
|
||||||
|
`SELECT COUNT(*) as total FROM formex_materials ${whereSQL}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const total = countResult.rows[0]?.total || 0;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, formex_code, name, collection, category, thickness_mm, width_mm, height_mm,
|
||||||
|
price_chf, price_unit, image_url, formex_url, active, last_synced_at
|
||||||
|
FROM formex_materials
|
||||||
|
${whereSQL}
|
||||||
|
ORDER BY collection, name
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
[...params, limitNum, offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
materials: rows,
|
||||||
|
pagination: {
|
||||||
|
page: pageNum,
|
||||||
|
limit: limitNum,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limitNum)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/sync/logs
|
* GET /api/sync/logs
|
||||||
* Get sync history
|
* Get sync history
|
||||||
@@ -439,6 +584,33 @@ export async function updateFormexMaterial(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/formex/materials/:id/active
|
||||||
|
* Toggle only the `active` flag of a material (lossless: never touches other columns).
|
||||||
|
* Body: { active: boolean }
|
||||||
|
*/
|
||||||
|
export async function setFormexMaterialActive(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const active = req.body?.active === true || req.body?.active === 1 || req.body?.active === '1';
|
||||||
|
|
||||||
|
const existing = await pool.query('SELECT id FROM formex_materials WHERE id = ?', [id]);
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Matériau non trouvé' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await run('UPDATE formex_materials SET active = ? WHERE id = ?', [active ? 1 : 0, id]);
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT id, formex_code, name, active FROM formex_materials WHERE id = ?',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/formex/materials/:id
|
* DELETE /api/formex/materials/:id
|
||||||
* Delete a material
|
* Delete a material
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Restore configurateur.db from the newest non-empty backup.
|
||||||
|
* Safe to run while the server is up: the guarded saver refuses empty overwrites,
|
||||||
|
* then restart the server (or let --watch reload) to pick up the restored file.
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import initSqlJs from 'sql.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const dataDir = path.join(__dirname, '..', 'data');
|
||||||
|
const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db');
|
||||||
|
const backupDir = path.join(dataDir, 'backups');
|
||||||
|
|
||||||
|
const SQL = await initSqlJs({
|
||||||
|
locateFile: (f) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', f)
|
||||||
|
});
|
||||||
|
|
||||||
|
function countMaterials(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) return 0;
|
||||||
|
let db = null;
|
||||||
|
try {
|
||||||
|
db = new SQL.Database(fs.readFileSync(filePath));
|
||||||
|
const r = db.exec('SELECT COUNT(*) FROM formex_materials');
|
||||||
|
return Number(r[0]?.values?.[0]?.[0] ?? 0);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
} finally {
|
||||||
|
try { db?.close(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const backups = fs.existsSync(backupDir)
|
||||||
|
? fs.readdirSync(backupDir)
|
||||||
|
.filter((f) => /^configurateur-.*\.db$/.test(f))
|
||||||
|
.map((f) => {
|
||||||
|
const full = path.join(backupDir, f);
|
||||||
|
return { file: f, full, count: countMaterials(full), mtime: fs.statSync(full).mtimeMs };
|
||||||
|
})
|
||||||
|
.filter((b) => b.count > 0)
|
||||||
|
.sort((a, b) => b.count - a.count || b.mtime - a.mtime)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (backups.length === 0) {
|
||||||
|
console.error('No non-empty backup found under server/data/backups/');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const best = backups[0];
|
||||||
|
const currentCount = countMaterials(dbPath);
|
||||||
|
console.log(`Current DB: ${currentCount} materials`);
|
||||||
|
console.log(`Restoring from ${best.file} (${best.count} materials)`);
|
||||||
|
|
||||||
|
if (fs.existsSync(dbPath) && currentCount > 0) {
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||||
|
const safety = path.join(backupDir, `configurateur-pre-restore-${stamp}.db`);
|
||||||
|
fs.copyFileSync(dbPath, safety);
|
||||||
|
console.log(`Safety copy of current DB → ${safety}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.copyFileSync(best.full, dbPath);
|
||||||
|
console.log(`Restored. Disk now has ${countMaterials(dbPath)} materials.`);
|
||||||
|
console.log('Restart the API server so it reloads this file into memory.');
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Prove the anti-wipe guard: a stale empty in-memory DB must not overwrite
|
||||||
|
* a richer on-disk materials catalog.
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import initSqlJs from 'sql.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const dbPath = path.join(__dirname, '..', 'data', 'configurateur.db');
|
||||||
|
|
||||||
|
const SQL = await initSqlJs({
|
||||||
|
locateFile: (f) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', f)
|
||||||
|
});
|
||||||
|
|
||||||
|
function count(file) {
|
||||||
|
const db = new SQL.Database(fs.readFileSync(file));
|
||||||
|
const n = Number(db.exec('SELECT COUNT(*) FROM formex_materials')[0].values[0][0]);
|
||||||
|
db.close();
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = count(dbPath);
|
||||||
|
console.log('disk before:', before);
|
||||||
|
if (before < 1) {
|
||||||
|
console.error('FAIL: expected materials on disk before guard test');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate a stale empty process writing the whole file (old wipe mode).
|
||||||
|
const empty = new SQL.Database();
|
||||||
|
empty.exec(`
|
||||||
|
CREATE TABLE formex_materials (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
formex_code TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
collection TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Mimic the NEW guarded save from db/index.js
|
||||||
|
function guardedSave(database) {
|
||||||
|
const memCount = Number(database.exec('SELECT COUNT(*) FROM formex_materials')[0]?.values?.[0]?.[0] ?? 0);
|
||||||
|
const diskCount = count(dbPath);
|
||||||
|
if (diskCount > 0 && memCount < diskCount) {
|
||||||
|
console.log(`GUARD OK: refused overwrite (mem=${memCount}, disk=${diskCount})`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(dbPath, Buffer.from(database.export()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrote = guardedSave(empty);
|
||||||
|
empty.close();
|
||||||
|
const after = count(dbPath);
|
||||||
|
console.log('disk after:', after);
|
||||||
|
if (wrote || after !== before) {
|
||||||
|
console.error('FAIL: guard did not protect materials');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('PASS: scrapes on disk are protected from empty overwrites');
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
import { DRAWERS, NICHE } from '../../shared/manufacturing.js';
|
import {
|
||||||
|
DRAWERS,
|
||||||
|
NICHE,
|
||||||
|
PLINTH,
|
||||||
|
MAX_PANEL_DIMENSION_MM,
|
||||||
|
MAX_PANEL_SHORT_SIDE_MM,
|
||||||
|
validateHorizontalSpans
|
||||||
|
} from '../../shared/manufacturing.js';
|
||||||
|
|
||||||
export function validateConfig(config = {}, settings = {}) {
|
export function validateConfig(config = {}, settings = {}) {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
@@ -12,6 +19,53 @@ export function validateConfig(config = {}, settings = {}) {
|
|||||||
const modules = Array.isArray(config.modules) ? config.modules : [];
|
const modules = Array.isArray(config.modules) ? config.modules : [];
|
||||||
const allowSliding = String(settings.allow_sliding_doors ?? '0') === '1';
|
const allowSliding = String(settings.allow_sliding_doors ?? '0') === '1';
|
||||||
const sanitaryGapMm = Number(settings.sanitary_gap_mm || NICHE.sanitaryGapAround);
|
const sanitaryGapMm = Number(settings.sanitary_gap_mm || NICHE.sanitaryGapAround);
|
||||||
|
const plinthHeightMm = Number(config.plinthHeightMm ?? 100);
|
||||||
|
const maxPanelLongMm = Number(
|
||||||
|
settings.panel_max_width_mm ??
|
||||||
|
settings.max_panel_width_mm ??
|
||||||
|
settings.max_panel_dimension_mm ??
|
||||||
|
MAX_PANEL_DIMENSION_MM
|
||||||
|
);
|
||||||
|
const maxPanelShortMm = Number(
|
||||||
|
settings.panel_max_short_side_mm ??
|
||||||
|
settings.max_panel_short_side_mm ??
|
||||||
|
MAX_PANEL_SHORT_SIDE_MM
|
||||||
|
);
|
||||||
|
|
||||||
|
if (widthMm > maxPanelLongMm) {
|
||||||
|
errors.push({
|
||||||
|
field: 'widthMm',
|
||||||
|
message: `Largeur maximale ${maxPanelLongMm} mm (capacité CNC).`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (depthMm > maxPanelShortMm) {
|
||||||
|
errors.push({
|
||||||
|
field: 'depthMm',
|
||||||
|
message: `Profondeur maximale ${maxPanelShortMm} mm (capacité CNC).`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.horizontalSpans != null && !Array.isArray(config.horizontalSpans)) {
|
||||||
|
errors.push({
|
||||||
|
field: 'horizontalSpans',
|
||||||
|
message: 'Les traverses horizontales doivent être fournies sous forme de tableau.'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
validateHorizontalSpans(config, { maxPanelWidthMm: maxPanelLongMm }).forEach((issue) => {
|
||||||
|
errors.push({
|
||||||
|
field: issue.field,
|
||||||
|
code: issue.code,
|
||||||
|
message: issue.message
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PLINTH.targetHeightsMm.includes(plinthHeightMm)) {
|
||||||
|
errors.push({
|
||||||
|
field: 'plinthHeightMm',
|
||||||
|
message: `Hauteur AXILO 78 invalide. Valeurs admises : ${PLINTH.targetHeightsMm.join(', ')} mm.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (doorType === 'sliding' && !allowSliding) {
|
if (doorType === 'sliding' && !allowSliding) {
|
||||||
errors.push({ field: 'doorType', message: 'Portes coulissantes désactivées.' });
|
errors.push({ field: 'doorType', message: 'Portes coulissantes désactivées.' });
|
||||||
@@ -35,13 +89,12 @@ export function validateConfig(config = {}, settings = {}) {
|
|||||||
const drawerDepthStep = Number(settings.drawer_depth_step_mm || DRAWERS.depthStep);
|
const drawerDepthStep = Number(settings.drawer_depth_step_mm || DRAWERS.depthStep);
|
||||||
|
|
||||||
/** Même logique que `useWardrobeConfig` : pas de 50 mm sur la profondeur utile, puis réserve tiroir caché. */
|
/** Même logique que `useWardrobeConfig` : pas de 50 mm sur la profondeur utile, puis réserve tiroir caché. */
|
||||||
const inferredEffectiveDrawerDepthMm = () => {
|
const inferredEffectiveDrawerDepthMm = (drawerType = config.hardware?.drawerType || 'visible') => {
|
||||||
const rawGap = depthMm - NICHE.drawerBackGap;
|
const rawGap = depthMm - NICHE.drawerBackGap;
|
||||||
const clamped = Math.max(drawerDepthMin, Math.min(drawerDepthMax, rawGap));
|
const clamped = Math.max(drawerDepthMin, Math.min(drawerDepthMax, rawGap));
|
||||||
const steppedMm = Math.round(clamped / drawerDepthStep) * drawerDepthStep;
|
const steppedMm = Math.round(clamped / drawerDepthStep) * drawerDepthStep;
|
||||||
const rawDt = config.hardware?.drawerType || 'visible';
|
|
||||||
const depthReductionMm =
|
const depthReductionMm =
|
||||||
rawDt === 'hidden' || rawDt === 'hidden_with_handle' ? DRAWERS.hiddenHandleDepthReduction : 0;
|
drawerType === 'hidden' || drawerType === 'hidden_with_handle' ? DRAWERS.hiddenHandleDepthReduction : 0;
|
||||||
return Math.max(350, steppedMm - depthReductionMm);
|
return Math.max(350, steppedMm - depthReductionMm);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,13 +106,27 @@ export function validateConfig(config = {}, settings = {}) {
|
|||||||
if (m.hasRod && heightMm < 1200) {
|
if (m.hasRod && heightMm < 1200) {
|
||||||
errors.push({ field: `modules[${index}]`, message: 'Tringle autorisée à partir de 1200 mm de hauteur armoire.' });
|
errors.push({ field: `modules[${index}]`, message: 'Tringle autorisée à partir de 1200 mm de hauteur armoire.' });
|
||||||
}
|
}
|
||||||
|
if (isDrawerCol) {
|
||||||
|
const moduleDepth = inferredEffectiveDrawerDepthMm(m.drawerType ?? config.hardware?.drawerType);
|
||||||
|
if (moduleDepth < 350 || moduleDepth > drawerDepthMax) {
|
||||||
|
errors.push({
|
||||||
|
field: `modules[${index}].drawerType`,
|
||||||
|
message: `Profondeur tiroir utile hors bornes (350-${drawerDepthMax} mm) pour ce block.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Profondeur utile tiroir — alignée sur useWardrobeConfig (pas de 50 sur prof. caisse puis −40 si caché).
|
* Profondeur utile tiroir — alignée sur useWardrobeConfig (pas de 50 sur prof. caisse puis −40 si caché).
|
||||||
* Ne pas exiger un multiple de 50 sur la valeur finale : ex. 550 − 40 = 510 mm est valide.
|
* Ne pas exiger un multiple de 50 sur la valeur finale : ex. 550 − 40 = 510 mm est valide.
|
||||||
*/
|
*/
|
||||||
const canonicalEffectiveDrawerDepthMm = inferredEffectiveDrawerDepthMm();
|
const drawerModules = modules.filter((m) => (m.drawers || 0) > 0 || m.type === 'tiroirs');
|
||||||
|
const canonicalDrawerDepths = drawerModules.map((m) =>
|
||||||
|
inferredEffectiveDrawerDepthMm(m.drawerType ?? config.hardware?.drawerType)
|
||||||
|
);
|
||||||
|
const canonicalEffectiveDrawerDepthMm =
|
||||||
|
canonicalDrawerDepths[0] ?? inferredEffectiveDrawerDepthMm();
|
||||||
const effectiveDrawerMinMm = 350;
|
const effectiveDrawerMinMm = 350;
|
||||||
if (
|
if (
|
||||||
canonicalEffectiveDrawerDepthMm < effectiveDrawerMinMm ||
|
canonicalEffectiveDrawerDepthMm < effectiveDrawerMinMm ||
|
||||||
@@ -75,6 +142,7 @@ export function validateConfig(config = {}, settings = {}) {
|
|||||||
if (
|
if (
|
||||||
Number.isFinite(explicitEff) &&
|
Number.isFinite(explicitEff) &&
|
||||||
explicitEff > 0 &&
|
explicitEff > 0 &&
|
||||||
|
canonicalDrawerDepths.every((depth) => depth === canonicalEffectiveDrawerDepthMm) &&
|
||||||
Math.abs(explicitEff - canonicalEffectiveDrawerDepthMm) > 2
|
Math.abs(explicitEff - canonicalEffectiveDrawerDepthMm) > 2
|
||||||
) {
|
) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
|
|||||||
+1952
-165
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
|||||||
|
/**
|
||||||
|
* Cutting Plan Engine (Calepinage / plan de coupe) — APEX
|
||||||
|
*
|
||||||
|
* Optimise le placement des pièces de la liste de débitage sur des panneaux de stock
|
||||||
|
* (par finition + épaisseur) à l'aide d'un algorithme guillotine par étagères
|
||||||
|
* (First-Fit Decreasing Height). Calcule le pourcentage de chute par panneau et global,
|
||||||
|
* et produit un SVG de calepinage par panneau pour le fournisseur.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Format de panneau de stock par défaut (mm) — mélaminé standard. */
|
||||||
|
export const DEFAULT_STOCK_BOARD = { widthMm: 2800, heightMm: 2070 };
|
||||||
|
/** Trait de scie (kerf) entre pièces, mm. */
|
||||||
|
const DEFAULT_KERF_MM = 3.2;
|
||||||
|
/** Délignage de rive (trim) sur chaque bord du panneau, mm. */
|
||||||
|
const DEFAULT_TRIM_MM = 10;
|
||||||
|
|
||||||
|
function round(n, d = 3) {
|
||||||
|
const f = 10 ** d;
|
||||||
|
return Math.round((Number(n) || 0) * f) / f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place une liste de rectangles sur des panneaux successifs (guillotine FFDH).
|
||||||
|
* @param {Array<{w:number,h:number,label:string,pieceId:string}>} rects
|
||||||
|
* @param {object} board { usableW, usableH, kerf, trim, boardWidthMm, boardHeightMm }
|
||||||
|
* @param {boolean} allowRotation
|
||||||
|
* @returns {Array<object>} boards
|
||||||
|
*/
|
||||||
|
function packRectsIntoBoards(rects, board, allowRotation) {
|
||||||
|
const { usableW, usableH, kerf, trim, boardWidthMm, boardHeightMm } = board;
|
||||||
|
// Tri par hauteur décroissante (puis largeur) pour FFDH.
|
||||||
|
const sorted = [...rects].sort((a, b) => (b.h - a.h) || (b.w - a.w));
|
||||||
|
const boards = [];
|
||||||
|
|
||||||
|
function newBoard() {
|
||||||
|
return { shelves: [], usedY: 0, placements: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryPlaceOnBoard(b, rect) {
|
||||||
|
// Orientations candidates.
|
||||||
|
const orientations = [{ w: rect.w, h: rect.h, rotated: false }];
|
||||||
|
if (allowRotation && rect.w !== rect.h) {
|
||||||
|
orientations.push({ w: rect.h, h: rect.w, rotated: true });
|
||||||
|
}
|
||||||
|
// 1) Essayer dans une étagère existante.
|
||||||
|
for (const shelf of b.shelves) {
|
||||||
|
for (const o of orientations) {
|
||||||
|
if (o.w <= usableW - shelf.usedX + 1e-6 && o.h <= shelf.height + 1e-6) {
|
||||||
|
b.placements.push({
|
||||||
|
xMm: round(trim + shelf.usedX, 1),
|
||||||
|
yMm: round(trim + shelf.y, 1),
|
||||||
|
wMm: round(o.w, 1),
|
||||||
|
hMm: round(o.h, 1),
|
||||||
|
rotated: o.rotated,
|
||||||
|
label: rect.label,
|
||||||
|
pieceId: rect.pieceId
|
||||||
|
});
|
||||||
|
shelf.usedX += o.w + kerf;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2) Ouvrir une nouvelle étagère.
|
||||||
|
for (const o of orientations) {
|
||||||
|
if (o.h <= usableH - b.usedY + 1e-6 && o.w <= usableW + 1e-6) {
|
||||||
|
const shelf = { y: b.usedY, height: o.h, usedX: 0 };
|
||||||
|
b.placements.push({
|
||||||
|
xMm: round(trim + 0, 1),
|
||||||
|
yMm: round(trim + shelf.y, 1),
|
||||||
|
wMm: round(o.w, 1),
|
||||||
|
hMm: round(o.h, 1),
|
||||||
|
rotated: o.rotated,
|
||||||
|
label: rect.label,
|
||||||
|
pieceId: rect.pieceId
|
||||||
|
});
|
||||||
|
shelf.usedX = o.w + kerf;
|
||||||
|
b.shelves.push(shelf);
|
||||||
|
b.usedY += o.h + kerf;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = newBoard();
|
||||||
|
for (const rect of sorted) {
|
||||||
|
// Pièce trop grande pour un panneau, même tournée : on la place seule (hors-format signalé).
|
||||||
|
const fits = rect.w <= usableW + 1e-6 && rect.h <= usableH + 1e-6;
|
||||||
|
const fitsRotated = allowRotation && rect.h <= usableW + 1e-6 && rect.w <= usableH + 1e-6;
|
||||||
|
if (!fits && !fitsRotated) {
|
||||||
|
boards.push(finalizeBoard(current, board));
|
||||||
|
current = newBoard();
|
||||||
|
current.placements.push({
|
||||||
|
xMm: round(trim, 1),
|
||||||
|
yMm: round(trim, 1),
|
||||||
|
wMm: round(rect.w, 1),
|
||||||
|
hMm: round(rect.h, 1),
|
||||||
|
rotated: false,
|
||||||
|
label: rect.label,
|
||||||
|
pieceId: rect.pieceId,
|
||||||
|
oversize: true
|
||||||
|
});
|
||||||
|
boards.push(finalizeBoard(current, board));
|
||||||
|
current = newBoard();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!tryPlaceOnBoard(current, rect)) {
|
||||||
|
boards.push(finalizeBoard(current, board));
|
||||||
|
current = newBoard();
|
||||||
|
tryPlaceOnBoard(current, rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.placements.length) boards.push(finalizeBoard(current, board));
|
||||||
|
return boards;
|
||||||
|
|
||||||
|
function finalizeBoard(b, brd) {
|
||||||
|
const boardAreaMm2 = brd.boardWidthMm * brd.boardHeightMm;
|
||||||
|
const usedAreaMm2 = b.placements.reduce((s, p) => s + p.wMm * p.hMm, 0);
|
||||||
|
const wasteAreaMm2 = Math.max(0, boardAreaMm2 - usedAreaMm2);
|
||||||
|
return {
|
||||||
|
boardWidthMm: brd.boardWidthMm,
|
||||||
|
boardHeightMm: brd.boardHeightMm,
|
||||||
|
placements: b.placements,
|
||||||
|
usedAreaM2: round(usedAreaMm2 / 1e6),
|
||||||
|
boardAreaM2: round(boardAreaMm2 / 1e6),
|
||||||
|
wasteAreaM2: round(wasteAreaMm2 / 1e6),
|
||||||
|
wastePercent: round((wasteAreaMm2 / boardAreaMm2) * 100, 1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SVG de calepinage pour un panneau. */
|
||||||
|
function boardSvg(boardEntry, index) {
|
||||||
|
const { boardWidthMm, boardHeightMm, placements } = boardEntry;
|
||||||
|
const pad = 24;
|
||||||
|
const maxW = 520;
|
||||||
|
const scale = maxW / boardWidthMm;
|
||||||
|
const w = boardWidthMm * scale;
|
||||||
|
const h = boardHeightMm * scale;
|
||||||
|
const palette = ['#dbeafe', '#fde68a', '#bbf7d0', '#fecaca', '#e9d5ff', '#c7d2fe', '#fed7aa'];
|
||||||
|
const rects = placements
|
||||||
|
.map((p, i) => {
|
||||||
|
const fill = p.oversize ? '#fca5a5' : palette[i % palette.length];
|
||||||
|
const rx = pad + p.xMm * scale;
|
||||||
|
const ry = pad + p.yMm * scale;
|
||||||
|
const rw = Math.max(1, p.wMm * scale);
|
||||||
|
const rh = Math.max(1, p.hMm * scale);
|
||||||
|
const label = `${p.label}${p.rotated ? ' ↻' : ''}`;
|
||||||
|
const fontSize = Math.max(7, Math.min(11, rh / 3, rw / 6));
|
||||||
|
const text =
|
||||||
|
rw > 36 && rh > 14
|
||||||
|
? `<text x="${rx + rw / 2}" y="${ry + rh / 2}" font-size="${fontSize}" text-anchor="middle" dominant-baseline="middle" fill="#1f2937">${escapeXml(label)}</text>`
|
||||||
|
: '';
|
||||||
|
return `<rect x="${rx.toFixed(1)}" y="${ry.toFixed(1)}" width="${rw.toFixed(1)}" height="${rh.toFixed(1)}" fill="${fill}" stroke="#374151" stroke-width="0.8"/>${text}`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w + pad * 2} ${h + pad * 2 + 24}" width="${w + pad * 2}" height="${h + pad * 2 + 24}">
|
||||||
|
<rect x="${pad}" y="${pad}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" fill="#f9fafb" stroke="#111827" stroke-width="1.5"/>
|
||||||
|
${rects}
|
||||||
|
<text x="${pad}" y="${h + pad * 2 + 14}" font-size="12" fill="#111827">Panneau ${index + 1} — ${boardWidthMm}×${boardHeightMm} mm — chute ${boardEntry.wastePercent}%</text>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère le plan de coupe (calepinage) pour toute l'armoire.
|
||||||
|
* @param {object} cuttingList résultat de generateCuttingList
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {{widthMm:number,heightMm:number}} [options.stockBoard]
|
||||||
|
* @param {number} [options.kerfMm]
|
||||||
|
* @param {number} [options.trimMm]
|
||||||
|
* @param {boolean} [options.allowRotation]
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
export function generateCuttingPlan(cuttingList, options = {}) {
|
||||||
|
const stock = options.stockBoard || DEFAULT_STOCK_BOARD;
|
||||||
|
const kerf = Number.isFinite(options.kerfMm) ? options.kerfMm : DEFAULT_KERF_MM;
|
||||||
|
const trim = Number.isFinite(options.trimMm) ? options.trimMm : DEFAULT_TRIM_MM;
|
||||||
|
const allowRotation = options.allowRotation !== false;
|
||||||
|
|
||||||
|
const usableW = stock.widthMm - 2 * trim;
|
||||||
|
const usableH = stock.heightMm - 2 * trim;
|
||||||
|
|
||||||
|
// Regrouper les pièces par finition + épaisseur (matériaux non mélangeables).
|
||||||
|
const groupsMap = new Map();
|
||||||
|
for (const group of (cuttingList?.byFinish || [])) {
|
||||||
|
for (const p of (group.pieces || [])) {
|
||||||
|
const wMm = Number(p.rawCutWidthMm || p.widthMm) || 0;
|
||||||
|
const hMm = Number(p.rawCutHeightMm || p.heightMm) || 0;
|
||||||
|
const qty = Math.max(1, Math.round(Number(p.quantity) || 1));
|
||||||
|
if (!(wMm > 0) || !(hMm > 0)) continue;
|
||||||
|
const key = `${group.finishLabel || 'finition'}__${p.thicknessMm || 0}mm`;
|
||||||
|
if (!groupsMap.has(key)) {
|
||||||
|
groupsMap.set(key, {
|
||||||
|
key,
|
||||||
|
finishLabel: group.finishLabel || 'Finition',
|
||||||
|
thicknessMm: Number(p.thicknessMm) || 0,
|
||||||
|
rects: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const bucket = groupsMap.get(key);
|
||||||
|
for (let i = 0; i < qty; i++) {
|
||||||
|
bucket.rects.push({ w: wMm, h: hMm, label: p.description || p.elementType || 'pièce', pieceId: p.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allBoards = [];
|
||||||
|
const groups = [];
|
||||||
|
for (const bucket of groupsMap.values()) {
|
||||||
|
const boards = packRectsIntoBoards(bucket.rects, {
|
||||||
|
usableW, usableH, kerf, trim,
|
||||||
|
boardWidthMm: stock.widthMm, boardHeightMm: stock.heightMm
|
||||||
|
}, allowRotation);
|
||||||
|
const groupBoardAreaM2 = boards.reduce((s, b) => s + b.boardAreaM2, 0);
|
||||||
|
const groupUsedAreaM2 = boards.reduce((s, b) => s + b.usedAreaM2, 0);
|
||||||
|
const groupWasteAreaM2 = round(groupBoardAreaM2 - groupUsedAreaM2);
|
||||||
|
groups.push({
|
||||||
|
key: bucket.key,
|
||||||
|
finishLabel: bucket.finishLabel,
|
||||||
|
thicknessMm: bucket.thicknessMm,
|
||||||
|
boardCount: boards.length,
|
||||||
|
usedAreaM2: round(groupUsedAreaM2),
|
||||||
|
boardAreaM2: round(groupBoardAreaM2),
|
||||||
|
wasteAreaM2: groupWasteAreaM2,
|
||||||
|
wastePercent: groupBoardAreaM2 > 0 ? round((groupWasteAreaM2 / groupBoardAreaM2) * 100, 1) : 0
|
||||||
|
});
|
||||||
|
boards.forEach((b, i) => {
|
||||||
|
allBoards.push({
|
||||||
|
...b,
|
||||||
|
groupKey: bucket.key,
|
||||||
|
finishLabel: bucket.finishLabel,
|
||||||
|
thicknessMm: bucket.thicknessMm,
|
||||||
|
svg: boardSvg(b, allBoards.length + i)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalBoardAreaM2 = round(allBoards.reduce((s, b) => s + b.boardAreaM2, 0));
|
||||||
|
const totalUsedAreaM2 = round(allBoards.reduce((s, b) => s + b.usedAreaM2, 0));
|
||||||
|
const totalWasteAreaM2 = round(totalBoardAreaM2 - totalUsedAreaM2);
|
||||||
|
const totalPlacedPieces = allBoards.reduce((s, b) => s + b.placements.length, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stock: { widthMm: stock.widthMm, heightMm: stock.heightMm, kerfMm: kerf, trimMm: trim, allowRotation },
|
||||||
|
boards: allBoards,
|
||||||
|
groups,
|
||||||
|
totalBoards: allBoards.length,
|
||||||
|
totalPlacedPieces,
|
||||||
|
totalUsedAreaM2,
|
||||||
|
totalBoardAreaM2,
|
||||||
|
totalWasteAreaM2,
|
||||||
|
globalWastePercent: totalBoardAreaM2 > 0 ? round((totalWasteAreaM2 / totalBoardAreaM2) * 100, 1) : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* Export Excel (.xlsx) — APEX
|
||||||
|
* Génère un classeur OOXML minimal mais valide à partir de la liste de débitage,
|
||||||
|
* de la quincaillerie et du calepinage, sans dépendance externe (utilise jszip).
|
||||||
|
*
|
||||||
|
* Le format .xlsx est une archive ZIP de fichiers XML. On utilise des chaînes
|
||||||
|
* "inlineStr" pour éviter la table de chaînes partagées.
|
||||||
|
*/
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
|
||||||
|
function escapeXml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convertit un index de colonne 0-based en lettre Excel (0 -> A, 26 -> AA). */
|
||||||
|
function colLetter(index) {
|
||||||
|
let n = index;
|
||||||
|
let s = '';
|
||||||
|
do {
|
||||||
|
s = String.fromCharCode(65 + (n % 26)) + s;
|
||||||
|
n = Math.floor(n / 26) - 1;
|
||||||
|
} while (n >= 0);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellXml(ref, value, { header = false } = {}) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return `<c r="${ref}"${header ? ' s="1"' : ''}/>`;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return `<c r="${ref}"${header ? ' s="1"' : ''}><v>${value}</v></c>`;
|
||||||
|
}
|
||||||
|
const styleAttr = header ? ' s="1"' : '';
|
||||||
|
return `<c r="${ref}"${styleAttr} t="inlineStr"><is><t xml:space="preserve">${escapeXml(value)}</t></is></c>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ name:string, columns:string[], rows:Array<Array<string|number>> }} sheet
|
||||||
|
*/
|
||||||
|
function sheetXml(sheet) {
|
||||||
|
const rowsXml = [];
|
||||||
|
// Header row
|
||||||
|
const headerCells = sheet.columns
|
||||||
|
.map((c, i) => cellXml(`${colLetter(i)}1`, c, { header: true }))
|
||||||
|
.join('');
|
||||||
|
rowsXml.push(`<row r="1">${headerCells}</row>`);
|
||||||
|
// Data rows
|
||||||
|
sheet.rows.forEach((row, r) => {
|
||||||
|
const cells = row.map((val, i) => cellXml(`${colLetter(i)}${r + 2}`, val)).join('');
|
||||||
|
rowsXml.push(`<row r="${r + 2}">${cells}</row>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const colsCount = sheet.columns.length;
|
||||||
|
const colsXml = `<cols>${Array.from({ length: colsCount }, (_, i) => `<col min="${i + 1}" max="${i + 1}" width="22" customWidth="1"/>`).join('')}</cols>`;
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||||
|
${colsXml}
|
||||||
|
<sheetData>
|
||||||
|
${rowsXml.join('\n')}
|
||||||
|
</sheetData>
|
||||||
|
</worksheet>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTypesXml(sheetCount) {
|
||||||
|
const overrides = Array.from({ length: sheetCount }, (_, i) =>
|
||||||
|
`<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`
|
||||||
|
).join('');
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||||
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||||
|
<Default Extension="xml" ContentType="application/xml"/>
|
||||||
|
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
||||||
|
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
||||||
|
${overrides}
|
||||||
|
</Types>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workbookXml(sheets) {
|
||||||
|
const sheetEls = sheets
|
||||||
|
.map((s, i) => `<sheet name="${escapeXml(s.name).slice(0, 31)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`)
|
||||||
|
.join('');
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||||
|
<sheets>${sheetEls}</sheets>
|
||||||
|
</workbook>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workbookRelsXml(sheets) {
|
||||||
|
const rels = sheets
|
||||||
|
.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`)
|
||||||
|
.join('');
|
||||||
|
const stylesRid = sheets.length + 1;
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
${rels}
|
||||||
|
<Relationship Id="rId${stylesRid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||||
|
</Relationships>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROOT_RELS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
||||||
|
</Relationships>`;
|
||||||
|
|
||||||
|
const STYLES_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||||
|
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
|
||||||
|
<fills count="3"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FFE8EAED"/><bgColor indexed="64"/></patternFill></fill></fills>
|
||||||
|
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
|
||||||
|
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||||
|
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/></cellXfs>
|
||||||
|
</styleSheet>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit un buffer .xlsx à partir d'une liste de feuilles.
|
||||||
|
* @param {Array<{ name:string, columns:string[], rows:Array<Array<string|number>> }>} sheets
|
||||||
|
* @returns {Promise<Buffer>}
|
||||||
|
*/
|
||||||
|
export async function buildXlsxBuffer(sheets) {
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('[Content_Types].xml', contentTypesXml(sheets.length));
|
||||||
|
zip.file('_rels/.rels', ROOT_RELS_XML);
|
||||||
|
zip.file('xl/workbook.xml', workbookXml(sheets));
|
||||||
|
zip.file('xl/_rels/workbook.xml.rels', workbookRelsXml(sheets));
|
||||||
|
zip.file('xl/styles.xml', STYLES_XML);
|
||||||
|
sheets.forEach((s, i) => {
|
||||||
|
zip.file(`xl/worksheets/sheet${i + 1}.xml`, sheetXml(s));
|
||||||
|
});
|
||||||
|
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le classeur Excel de la liste de débitage (+ quincaillerie + calepinage).
|
||||||
|
* @param {object} params
|
||||||
|
* @param {object} params.cuttingList
|
||||||
|
* @param {object} [params.hardware] résultat de generateHardwareList
|
||||||
|
* @param {object} [params.cuttingPlan] résultat de generateCuttingPlan
|
||||||
|
* @returns {Promise<Buffer>}
|
||||||
|
*/
|
||||||
|
export async function buildCuttingListXlsx({ cuttingList, hardware = null, cuttingPlan = null }) {
|
||||||
|
const sheets = [];
|
||||||
|
|
||||||
|
// Feuille 1 — Débitage
|
||||||
|
const debitColumns = [
|
||||||
|
'Finition', 'Pièce', 'L finie (mm)', 'H finie (mm)', 'Ép. (mm)', 'Qté',
|
||||||
|
'L brute (mm)', 'H brute (mm)', 'Surcote (mm)', 'Surface (m²)',
|
||||||
|
'Chants (côtés)', 'Sens de collage', 'Usinage'
|
||||||
|
];
|
||||||
|
const debitRows = [];
|
||||||
|
for (const group of (cuttingList?.byFinish || [])) {
|
||||||
|
for (const p of (group.pieces || [])) {
|
||||||
|
const banding = p.edgeBanding && p.edgeBanding.type !== 'none'
|
||||||
|
? `${p.edgeBanding.sides} côtés`
|
||||||
|
: '—';
|
||||||
|
const sens = p.edgeBanding?.direction?.label || '—';
|
||||||
|
const usinage = p.machining ? Object.keys(p.machining).join(', ') : '—';
|
||||||
|
debitRows.push([
|
||||||
|
group.finishLabel || '—',
|
||||||
|
p.description || '',
|
||||||
|
Number(p.widthMm) || 0,
|
||||||
|
Number(p.heightMm) || 0,
|
||||||
|
Number(p.thicknessMm) || 0,
|
||||||
|
Number(p.quantity) || 0,
|
||||||
|
Number(p.rawCutWidthMm) || 0,
|
||||||
|
Number(p.rawCutHeightMm) || 0,
|
||||||
|
Number(p.surcoteMm) || 0,
|
||||||
|
Number((p.surfaceM2 || 0).toFixed(3)),
|
||||||
|
banding,
|
||||||
|
sens,
|
||||||
|
usinage
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sheets.push({ name: 'Débitage', columns: debitColumns, rows: debitRows });
|
||||||
|
|
||||||
|
// Feuille 2 — Quincaillerie
|
||||||
|
if (hardware && Array.isArray(hardware.items) && hardware.items.length) {
|
||||||
|
const hwColumns = ['Désignation', 'Référence', 'Qté', 'Prix unitaire (CHF)', 'Total (CHF)'];
|
||||||
|
const hwRows = hardware.items.map((it) => [
|
||||||
|
it.label || it.name || '',
|
||||||
|
it.code || it.reference || '—',
|
||||||
|
Number(it.quantity) || 0,
|
||||||
|
it.unitPriceChf != null ? Number(it.unitPriceChf) : '—',
|
||||||
|
it.lineTotalChf != null ? Number(it.lineTotalChf) : (it.totalChf != null ? Number(it.totalChf) : '—')
|
||||||
|
]);
|
||||||
|
sheets.push({ name: 'Quincaillerie', columns: hwColumns, rows: hwRows });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feuille 3 — Calepinage / plan de coupe
|
||||||
|
if (cuttingPlan && Array.isArray(cuttingPlan.boards) && cuttingPlan.boards.length) {
|
||||||
|
const planColumns = [
|
||||||
|
'Panneau', 'Format (mm)', 'Pièces placées', 'Surface utilisée (m²)',
|
||||||
|
'Surface panneau (m²)', 'Chute (m²)', 'Chute (%)'
|
||||||
|
];
|
||||||
|
const planRows = cuttingPlan.boards.map((b, i) => [
|
||||||
|
`Panneau ${i + 1}`,
|
||||||
|
`${b.boardWidthMm} x ${b.boardHeightMm}`,
|
||||||
|
(b.placements || []).length,
|
||||||
|
Number((b.usedAreaM2 || 0).toFixed(3)),
|
||||||
|
Number((b.boardAreaM2 || 0).toFixed(3)),
|
||||||
|
Number((b.wasteAreaM2 || 0).toFixed(3)),
|
||||||
|
Number((b.wastePercent || 0).toFixed(1))
|
||||||
|
]);
|
||||||
|
// Ligne de synthèse globale
|
||||||
|
planRows.push([
|
||||||
|
'TOTAL',
|
||||||
|
'',
|
||||||
|
cuttingPlan.totalPlacedPieces ?? '',
|
||||||
|
Number((cuttingPlan.totalUsedAreaM2 || 0).toFixed(3)),
|
||||||
|
Number((cuttingPlan.totalBoardAreaM2 || 0).toFixed(3)),
|
||||||
|
Number((cuttingPlan.totalWasteAreaM2 || 0).toFixed(3)),
|
||||||
|
Number((cuttingPlan.globalWastePercent || 0).toFixed(1))
|
||||||
|
]);
|
||||||
|
sheets.push({ name: 'Calepinage', columns: planColumns, rows: planRows });
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildXlsxBuffer(sheets);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import pool from '../db/index.js';
|
import pool from '../db/index.js';
|
||||||
import { generateCuttingList } from './cuttingEngine.js';
|
import { generateCuttingList } from './cuttingEngine.js';
|
||||||
import { PLINTH, CARCASS_FASTENERS } from '../../shared/manufacturing.js';
|
import { PLINTH, CARCASS_FASTENERS } from '../../shared/manufacturing.js';
|
||||||
|
import { LEGRABOX_BOM_RULES } from '../../shared/blumLegrabox.js';
|
||||||
|
|
||||||
const HAEFELE_PRODUCT_URLS = {
|
const HAEFELE_PRODUCT_URLS = {
|
||||||
hinge_blum_clip_top_blumotion_110: 'https://www.haefele.ch/fr/produkt/charni-re-corps-blum-clip-top-blumotion-110-pose-en-applique/34242663/?MasterSKU=P-00898218',
|
hinge_blum_clip_top_blumotion_110: 'https://www.haefele.ch/fr/produkt/charni-re-corps-blum-clip-top-blumotion-110-pose-en-applique/34242663/?MasterSKU=P-00898218',
|
||||||
@@ -61,6 +62,24 @@ async function priceById(table, id) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Système de tiroirs Blum avec specs_json (tip_on, load_kg, …) pour les règles BOM Legrabox. */
|
||||||
|
async function drawerSystemById(id) {
|
||||||
|
if (!id) return null;
|
||||||
|
const row = await fetchOne(
|
||||||
|
'SELECT id, code, name, price_chf, specs_json FROM blum_hardware WHERE id = ? AND active = 1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
unitPriceChf: toMoney(row.price_chf),
|
||||||
|
source: 'blum_hardware',
|
||||||
|
specsJson: row.specs_json || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function findHaefeleMaterialForModel(model) {
|
async function findHaefeleMaterialForModel(model) {
|
||||||
const m = String(model || '').trim();
|
const m = String(model || '').trim();
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
@@ -142,7 +161,7 @@ async function findHaefeleMaterialByUrl(url) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeCountsFromCuttingList(cuttingList) {
|
export function computeCountsFromCuttingList(cuttingList) {
|
||||||
const items = new Map();
|
const items = new Map();
|
||||||
const pieces = Array.isArray(cuttingList?.pieces) ? cuttingList.pieces : [];
|
const pieces = Array.isArray(cuttingList?.pieces) ? cuttingList.pieces : [];
|
||||||
|
|
||||||
@@ -256,10 +275,20 @@ function computeCountsFromCuttingList(cuttingList) {
|
|||||||
// so we do not count it here to avoid misleading totals.
|
// so we do not count it here to avoid misleading totals.
|
||||||
}
|
}
|
||||||
|
|
||||||
if (p?.elementType === 'tiroirs') {
|
if (p?.elementType === 'tiroirs' || p?.elementType === 'tiroirFacade') {
|
||||||
drawersCount += qty;
|
drawersCount += qty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Façades tiroir Legrabox — chevilles expansibles INSERTA (Ø10) selon perçages générés.
|
||||||
|
if (p?.elementType === 'tiroirFacade' && m.legraboxInserta?.holes?.length && qty > 0) {
|
||||||
|
addQty(items, 'legrabox_inserta_fixing', {
|
||||||
|
key: 'legrabox_inserta_fixing',
|
||||||
|
label: 'Fixation façade Legrabox INSERTA (cheville expansible Ø10)',
|
||||||
|
quantity: qty * m.legraboxInserta.holes.length,
|
||||||
|
unit: 'pc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Fixation "dos" (paroi arrière) — 4 côtés (gauche/droite + haut/bas).
|
// Fixation "dos" (paroi arrière) — 4 côtés (gauche/droite + haut/bas).
|
||||||
// Règle atelier (pour une longueur E = L ou H):
|
// Règle atelier (pour une longueur E = L ou H):
|
||||||
// - par côté: 1 pièce à 5cm de chaque extrémité (=> 2)
|
// - par côté: 1 pièce à 5cm de chaque extrémité (=> 2)
|
||||||
@@ -280,6 +309,30 @@ function computeCountsFromCuttingList(cuttingList) {
|
|||||||
meta: { model: 'Ixconnect RPC G 13/20' },
|
meta: { model: 'Ixconnect RPC G 13/20' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AXILO 78 : une platine/pied par centre, et un support de plinthe par pied avant.
|
||||||
|
if (p?.elementType === 'fond' && m.axiloFootPlates?.centers?.length && qty > 0) {
|
||||||
|
const centers = m.axiloFootPlates.centers;
|
||||||
|
const frontCount =
|
||||||
|
Number(m.axiloFootPlates.frontFootCount) ||
|
||||||
|
centers.filter((center) => center?.row === 'front').length;
|
||||||
|
addQty(items, 'axilo_78_press_fit_foot', {
|
||||||
|
key: 'axilo_78_press_fit_foot',
|
||||||
|
label: `Pied Häfele AXILO 78 à planter — réf. ${PLINTH.mountingPlate.articleNumber}`,
|
||||||
|
code: PLINTH.mountingPlate.articleNumber,
|
||||||
|
quantity: qty * centers.length,
|
||||||
|
unit: 'pc',
|
||||||
|
meta: { model: PLINTH.mountingPlate.articleNumber }
|
||||||
|
});
|
||||||
|
addQty(items, 'axilo_plinth_support', {
|
||||||
|
key: 'axilo_plinth_support',
|
||||||
|
label: `Support de plinthe Häfele AXILO — réf. ${PLINTH.plinthSupport.articleNumber}`,
|
||||||
|
code: PLINTH.plinthSupport.articleNumber,
|
||||||
|
quantity: qty * frontCount * PLINTH.plinthSupport.quantityPerFrontFoot,
|
||||||
|
unit: 'pc',
|
||||||
|
meta: { model: PLINTH.plinthSupport.articleNumber }
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { items: Array.from(items.values()), doorCount, drawersCount };
|
return { items: Array.from(items.values()), doorCount, drawersCount };
|
||||||
@@ -289,20 +342,6 @@ export async function generateHardwareList({ config, modules = [], colors = {} }
|
|||||||
const cuttingList = generateCuttingList(config, colors, modules);
|
const cuttingList = generateCuttingList(config, colors, modules);
|
||||||
const { items, doorCount } = computeCountsFromCuttingList(cuttingList);
|
const { items, doorCount } = computeCountsFromCuttingList(cuttingList);
|
||||||
|
|
||||||
// Add plinth clips (simple deterministic rule)
|
|
||||||
const widthMm = Number(config?.widthMm) || 0;
|
|
||||||
if (widthMm > 0) {
|
|
||||||
const clips = Math.max(0, Math.ceil((widthMm / 1000) * (Number(PLINTH.clipsPerMeter) || 0)));
|
|
||||||
if (clips > 0) {
|
|
||||||
items.push({
|
|
||||||
key: 'plinth_clips',
|
|
||||||
label: `Clips plinthe (${PLINTH.model})`,
|
|
||||||
quantity: clips,
|
|
||||||
unit: 'pc',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Selected variants pricing (if configured)
|
// Selected variants pricing (if configured)
|
||||||
const handleVariantId = config?.hardware?.handleVariantId;
|
const handleVariantId = config?.hardware?.handleVariantId;
|
||||||
const hingeVariantId = config?.hardware?.hingeVariantId;
|
const hingeVariantId = config?.hardware?.hingeVariantId;
|
||||||
@@ -311,7 +350,7 @@ export async function generateHardwareList({ config, modules = [], colors = {} }
|
|||||||
const [handleVariant, hingeVariant, drawerSystem] = await Promise.all([
|
const [handleVariant, hingeVariant, drawerSystem] = await Promise.all([
|
||||||
priceById('handle_variants', handleVariantId),
|
priceById('handle_variants', handleVariantId),
|
||||||
priceById('hinge_variants', hingeVariantId),
|
priceById('hinge_variants', hingeVariantId),
|
||||||
priceById('blum_hardware', drawerSystemId),
|
drawerSystemById(drawerSystemId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Add handles as an item when selected
|
// Add handles as an item when selected
|
||||||
@@ -389,9 +428,9 @@ export async function generateHardwareList({ config, modules = [], colors = {} }
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Drawer system: assume priced “per drawer”
|
// Drawer system: assume priced “per drawer”
|
||||||
if (drawerSystem) {
|
const configModules = Array.isArray(config?.modules) ? config.modules : [];
|
||||||
const totalDrawers = (Array.isArray(config?.modules) ? config.modules : []).reduce((s, m) => s + (Number(m?.drawers) || 0), 0);
|
const totalDrawers = configModules.reduce((s, m) => s + (Number(m?.drawers) || 0), 0);
|
||||||
if (totalDrawers > 0) {
|
if (drawerSystem && totalDrawers > 0) {
|
||||||
items.push({
|
items.push({
|
||||||
key: `drawer_system:${drawerSystem.id}`,
|
key: `drawer_system:${drawerSystem.id}`,
|
||||||
label: `Système tiroirs — ${drawerSystem.name}`,
|
label: `Système tiroirs — ${drawerSystem.name}`,
|
||||||
@@ -402,6 +441,49 @@ export async function generateHardwareList({ config, modules = [], colors = {} }
|
|||||||
code: drawerSystem.code,
|
code: drawerSystem.code,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Règles automatiques Blum Legrabox (BOM)
|
||||||
|
if (totalDrawers > 0) {
|
||||||
|
// 1) Stabilisation de façade — obligatoire si largeur_corps_mm >= 600 (par tiroir concerné).
|
||||||
|
const wideDrawers = configModules.reduce((s, m) => {
|
||||||
|
const n = Number(m?.drawers) || 0;
|
||||||
|
const w = Number(m?.widthMm) || 0;
|
||||||
|
return s + (n > 0 && w >= LEGRABOX_BOM_RULES.frontStabiliserMinBodyWidthMm ? n : 0);
|
||||||
|
}, 0);
|
||||||
|
if (wideDrawers > 0) {
|
||||||
|
items.push({
|
||||||
|
key: 'legrabox_front_stabiliser',
|
||||||
|
label: 'Stabilisation de façade Legrabox (largeur corps ≥ 600 mm)',
|
||||||
|
quantity: wideDrawers,
|
||||||
|
unit: 'pc',
|
||||||
|
meta: { model: 'ZST.550BA' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Unité de synchronisation — obligatoire avec la variante Tip-On Blumotion.
|
||||||
|
const openingVariant = String(config?.hardware?.drawerOpeningVariant || '').toLowerCase();
|
||||||
|
const specsTipOn = (() => {
|
||||||
|
try {
|
||||||
|
const specs = drawerSystem?.specsJson ? JSON.parse(drawerSystem.specsJson) : null;
|
||||||
|
return Boolean(specs?.tip_on);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const isTipOnBlumotion =
|
||||||
|
openingVariant === LEGRABOX_BOM_RULES.syncUnitVariant ||
|
||||||
|
openingVariant === 'tip-on blumotion' ||
|
||||||
|
specsTipOn;
|
||||||
|
if (isTipOnBlumotion) {
|
||||||
|
const drawerColumns = configModules.filter((m) => (Number(m?.drawers) || 0) > 0).length;
|
||||||
|
items.push({
|
||||||
|
key: 'legrabox_tipon_sync_unit',
|
||||||
|
label: 'Unité de synchronisation Tip-On Blumotion (set)',
|
||||||
|
quantity: Math.max(1, drawerColumns),
|
||||||
|
unit: 'set',
|
||||||
|
meta: { model: 'T60.000D' },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Price Häfele items (best-effort by name match)
|
// Price Häfele items (best-effort by name match)
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
buildFinishHabillageHoleSpecsMm,
|
buildFinishHabillageHoleSpecsMm,
|
||||||
buildDoorHingeCupHoleSpecsMm,
|
buildDoorHingeCupHoleSpecsMm,
|
||||||
buildDrawerReinforcementHoleSpecsMm,
|
buildDrawerReinforcementHoleSpecsMm,
|
||||||
buildDrawerRenfortPanelIxconnectSpecsMm
|
buildDrawerRenfortPanelIxconnectSpecsMm,
|
||||||
|
buildLegraboxRailHoleSpecsMm,
|
||||||
|
buildLegraboxFacadeInsertaSpecsMm,
|
||||||
|
buildAxiloFootHoleSpecsMm,
|
||||||
|
buildSystem32HoleSpecsMm
|
||||||
} from '../../shared/cncPanelHoles.js';
|
} from '../../shared/cncPanelHoles.js';
|
||||||
|
|
||||||
export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0) {
|
export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0) {
|
||||||
@@ -25,33 +29,8 @@ export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0
|
|||||||
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
|
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
|
||||||
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
|
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
|
||||||
|
|
||||||
if (m.system32?.positions?.length) {
|
for (const spec of buildSystem32HoleSpecsMm(piece, widthMm, heightMm)) {
|
||||||
const frontX = clampX(37);
|
pushHole(spec);
|
||||||
const backX = clampX(widthMm - 37);
|
|
||||||
const d = Number(m.system32.holeDiameter || 5);
|
|
||||||
const z = Number(m.system32.holeDepth || piece.thicknessMm || 12);
|
|
||||||
const throughHole = Boolean(m.system32.throughHole);
|
|
||||||
for (const pos of m.system32.positions) {
|
|
||||||
const y = clampY(pos);
|
|
||||||
pushHole({
|
|
||||||
xMm: frontX,
|
|
||||||
yMm: y,
|
|
||||||
diameterMm: d,
|
|
||||||
depthMm: z,
|
|
||||||
type: 'system32',
|
|
||||||
face: 'face',
|
|
||||||
toolMode: throughHole ? 'through' : 'blind'
|
|
||||||
});
|
|
||||||
pushHole({
|
|
||||||
xMm: backX,
|
|
||||||
yMm: y,
|
|
||||||
diameterMm: d,
|
|
||||||
depthMm: z,
|
|
||||||
type: 'system32',
|
|
||||||
face: 'face',
|
|
||||||
toolMode: throughHole ? 'through' : 'blind'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['dessus', 'fond'].includes(piece.elementType || '')) {
|
if (['dessus', 'fond'].includes(piece.elementType || '')) {
|
||||||
@@ -60,6 +39,11 @@ export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AXILO 78 Press-fit — quatre trous borgnes par platine, exclusivement en sous-face du fond bas.
|
||||||
|
for (const spec of buildAxiloFootHoleSpecsMm(piece, widthMm, heightMm)) {
|
||||||
|
pushHole({ ...spec, toolMode: 'blind' });
|
||||||
|
}
|
||||||
|
|
||||||
if (['cotes', 'panneauxInterieurs'].includes(piece.elementType || '')) {
|
if (['cotes', 'panneauxInterieurs'].includes(piece.elementType || '')) {
|
||||||
for (const spec of buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heightMm)) {
|
for (const spec of buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heightMm)) {
|
||||||
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
|
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
|
||||||
@@ -67,9 +51,18 @@ export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0
|
|||||||
for (const spec of buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm, partIndex)) {
|
for (const spec of buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm, partIndex)) {
|
||||||
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
|
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
|
||||||
}
|
}
|
||||||
|
for (const spec of buildLegraboxRailHoleSpecsMm(piece, widthMm, heightMm, partIndex)) {
|
||||||
|
pushHole({ ...spec, toolMode: 'blind' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['cotes', 'habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus'].includes(piece.elementType || '')) {
|
if (piece.elementType === 'tiroirFacade') {
|
||||||
|
for (const spec of buildLegraboxFacadeInsertaSpecsMm(piece, widthMm, heightMm)) {
|
||||||
|
pushHole({ ...spec, toolMode: 'blind' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['cotes', 'dessus', 'habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus'].includes(piece.elementType || '')) {
|
||||||
for (const spec of buildFinishHabillageHoleSpecsMm(piece, widthMm, heightMm)) {
|
for (const spec of buildFinishHabillageHoleSpecsMm(piece, widthMm, heightMm)) {
|
||||||
pushHole({ ...spec, toolMode: 'blind' });
|
pushHole({ ...spec, toolMode: 'blind' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,533 @@
|
|||||||
|
/**
|
||||||
|
* Leuba Hiag catalog crawler.
|
||||||
|
*
|
||||||
|
* The Leuba Hiag webshop (https://www.leubahiag.ch) is a Laravel/Bagisto shop whose
|
||||||
|
* product catalog is served by Algolia. The public catalog search page embeds a
|
||||||
|
* short-lived "secured" Algolia API key (guest scope) inside the page markup. We read
|
||||||
|
* that key live on every run (it expires), then page through the Algolia index and
|
||||||
|
* upsert each product into the shared `formex_materials` table under the `leubahiag`
|
||||||
|
* supplier.
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
* - Algolia exposes the product catalog but NOT prices. Customer-specific prices come
|
||||||
|
* from the authenticated `/fr/sales/prices-and-availability` endpoint: we log in with
|
||||||
|
* the account credentials (LEUBAHIAG_EMAIL/PASSWORD) and, after importing each line,
|
||||||
|
* fetch the per-SKU panel price and store it in price_chf.
|
||||||
|
* - Imported items are inactive by default so the catalog rows don't flood the
|
||||||
|
* configurator finish picker. Activate the wanted decors from the Matériaux admin page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import pool, { run } from '../db/index.js';
|
||||||
|
|
||||||
|
const BASE_URL = 'https://www.leubahiag.ch';
|
||||||
|
const SEARCH_URL = `${BASE_URL}/fr/catalog/search`;
|
||||||
|
const CRAWL_DELAY = parseInt(process.env.LEUBAHIAG_CRAWL_DELAY_MS || '300', 10);
|
||||||
|
const ALGOLIA_HITS_PER_PAGE = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curated catalog lines to import (per the supplier's agreed assortment).
|
||||||
|
* Each target maps to an Algolia query and/or brand facet filter. The `label` is used
|
||||||
|
* as the material `collection` so the configurator groups items by recognisable line.
|
||||||
|
*/
|
||||||
|
export const LEUBA_HIAG_TARGETS = [
|
||||||
|
{ key: 'pfleiderer-xpsm', label: 'Pfleiderer XPSM (Antifingerprint)', tier: 'Entrée de gamme', query: 'XPSM', brands: ['Pfleiderer'] },
|
||||||
|
{ key: 'swiss-krono', label: 'Swiss Krono', tier: 'Entrée de gamme', query: '', brands: ['Swiss Krono'] },
|
||||||
|
{ key: 'unilin-master-oak', label: 'Unilin Master Oak', tier: 'Milieu de gamme', query: 'Master Oak', brands: ['UNILIN'] },
|
||||||
|
{ key: 'shinnoki', label: 'Shinnoki', tier: 'Milieu de gamme', query: 'Shinnoki', brands: [] },
|
||||||
|
{ key: 'cleaf', label: 'Cleaf', tier: 'Haut de gamme', query: '', brands: ['CLEAF'] },
|
||||||
|
{ key: 'valchromat', label: 'Valchromat', tier: 'Haut de gamme', query: '', brands: ['Valchromat'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserHeaders(extra = {}) {
|
||||||
|
return {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'fr-CH,fr;q=0.9,en;q=0.5',
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function absoluteUrl(url) {
|
||||||
|
if (!url) return null;
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||||
|
return `${BASE_URL}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a "CHF 1'234.50" string into a number, or null. */
|
||||||
|
function parseCHF(text) {
|
||||||
|
if (typeof text !== 'string') return null;
|
||||||
|
const clean = text.replace(/CHF/gi, '').replace(/[\u00a0\u202f'’\s]/g, '').replace(',', '.');
|
||||||
|
const n = parseFloat(clean);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated Leuba Hiag (Laravel) session. Used to read customer-specific prices
|
||||||
|
* from the `prices-and-availability` endpoint (axios sends the XSRF-TOKEN cookie back
|
||||||
|
* as an X-XSRF-TOKEN header — we replicate that here).
|
||||||
|
*/
|
||||||
|
class LeubaHiagSession {
|
||||||
|
constructor() {
|
||||||
|
this.cookies = new Map();
|
||||||
|
this.loggedIn = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
captureCookies(response) {
|
||||||
|
let raw = [];
|
||||||
|
if (typeof response.headers.getSetCookie === 'function') {
|
||||||
|
raw = response.headers.getSetCookie();
|
||||||
|
} else {
|
||||||
|
const header = response.headers.get('set-cookie');
|
||||||
|
if (header) raw = header.split(/,\s*(?=[A-Za-z0-9_\-]+=)/);
|
||||||
|
}
|
||||||
|
for (const entry of raw) {
|
||||||
|
const [pair] = entry.split(';');
|
||||||
|
const eq = pair.indexOf('=');
|
||||||
|
if (eq > 0) this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieHeader() {
|
||||||
|
return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
xsrfToken() {
|
||||||
|
const raw = this.cookies.get('XSRF-TOKEN');
|
||||||
|
return raw ? decodeURIComponent(raw) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(emit = () => {}, credentials = {}) {
|
||||||
|
const email = credentials.username || credentials.email
|
||||||
|
|| process.env.LEUBAHIAG_EMAIL || process.env.LEUBAHIAG_USER;
|
||||||
|
const password = credentials.password || process.env.LEUBAHIAG_PASSWORD;
|
||||||
|
if (!email || !password) {
|
||||||
|
emit({ type: 'log', message: 'Identifiants Leuba Hiag absents : import sans prix.' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginUrl = `${BASE_URL}/fr/customer/auth/login`;
|
||||||
|
try {
|
||||||
|
const getRes = await fetch(loginUrl, { headers: browserHeaders(), redirect: 'follow' });
|
||||||
|
this.captureCookies(getRes);
|
||||||
|
const html = await getRes.text();
|
||||||
|
const token = (html.match(/name="_token"\s+value="([^"]+)"/) || [])[1];
|
||||||
|
if (!token) {
|
||||||
|
emit({ type: 'log', message: 'Jeton CSRF Leuba Hiag introuvable : import sans prix.' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set('_token', token);
|
||||||
|
body.set('email', email);
|
||||||
|
body.set('password', password);
|
||||||
|
|
||||||
|
// redirect:'manual' so we capture Set-Cookie from the post-login 302 (the
|
||||||
|
// LoggedIn/session cookies live on the redirect response, not the final page).
|
||||||
|
const postRes = await fetch(loginUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: browserHeaders({
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
Referer: loginUrl,
|
||||||
|
Origin: BASE_URL,
|
||||||
|
Cookie: this.cookieHeader(),
|
||||||
|
}),
|
||||||
|
body: body.toString(),
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
this.captureCookies(postRes);
|
||||||
|
|
||||||
|
// Confirm the session and refresh the XSRF-TOKEN from the authenticated catalog page.
|
||||||
|
const verifyRes = await fetch(`${BASE_URL}/fr/catalog/search`, {
|
||||||
|
headers: browserHeaders({ Cookie: this.cookieHeader() }),
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
this.captureCookies(verifyRes);
|
||||||
|
const verifyHtml = await verifyRes.text();
|
||||||
|
const isLoggedIn = /\\u0022isLoggedIn\\u0022:true/.test(verifyHtml)
|
||||||
|
|| this.cookies.get('LoggedIn') === '1';
|
||||||
|
|
||||||
|
this.loggedIn = isLoggedIn;
|
||||||
|
emit({
|
||||||
|
type: 'log',
|
||||||
|
message: this.loggedIn
|
||||||
|
? `Connecté à Leuba Hiag (${email}).`
|
||||||
|
: 'Connexion Leuba Hiag non confirmée : import sans prix.',
|
||||||
|
});
|
||||||
|
return this.loggedIn;
|
||||||
|
} catch (err) {
|
||||||
|
emit({ type: 'log', message: `Erreur de connexion Leuba Hiag : ${err.message}` });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST {skus} to the prices endpoint; returns the per-sku price map (or {}). */
|
||||||
|
async fetchPrices(skus) {
|
||||||
|
if (!this.loggedIn || skus.length === 0) return {};
|
||||||
|
const res = await fetch(`${BASE_URL}/fr/sales/prices-and-availability`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: browserHeaders({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json, text/plain, */*',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-XSRF-TOKEN': this.xsrfToken(),
|
||||||
|
Referer: `${BASE_URL}/fr/catalog/search`,
|
||||||
|
Origin: BASE_URL,
|
||||||
|
Cookie: this.cookieHeader(),
|
||||||
|
}),
|
||||||
|
body: JSON.stringify({ skus }),
|
||||||
|
});
|
||||||
|
this.captureCookies(res);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`prices-and-availability HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the live Algolia connection config (appId, index, secured apiKey) from the
|
||||||
|
* catalog search page. The key is embedded as `\u0022apiKey\u0022:\u0022...\u0022`
|
||||||
|
* inside the `window.globalSearchConfigBase` JSON blob.
|
||||||
|
*/
|
||||||
|
async function fetchAlgoliaConfig(emit = () => {}) {
|
||||||
|
const res = await fetch(SEARCH_URL, { headers: browserHeaders(), redirect: 'follow' });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Impossible de charger la page catalogue Leuba Hiag (HTTP ${res.status}).`);
|
||||||
|
}
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
const appId = (html.match(/\\u0022appId\\u0022:\\u0022([A-Za-z0-9]+)\\u0022/) || [])[1]
|
||||||
|
|| process.env.LEUBAHIAG_ALGOLIA_APP_ID;
|
||||||
|
const indexName = (html.match(/\\u0022indexName\\u0022:\\u0022([A-Za-z0-9_]+)\\u0022/) || [])[1]
|
||||||
|
|| process.env.LEUBAHIAG_ALGOLIA_INDEX;
|
||||||
|
const apiKey = (html.match(/\\u0022apiKey\\u0022:\\u0022([A-Za-z0-9+/=]+)\\u0022/) || [])[1];
|
||||||
|
|
||||||
|
if (!appId || !indexName || !apiKey) {
|
||||||
|
throw new Error('Clé Algolia introuvable sur la page catalogue Leuba Hiag (structure du site modifiée ?).');
|
||||||
|
}
|
||||||
|
|
||||||
|
emit({ type: 'log', message: `Catalogue Algolia détecté: index ${indexName} (app ${appId}).` });
|
||||||
|
return { appId, indexName, apiKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one Algolia query page. `facetFilters` is an array of arrays per Algolia syntax.
|
||||||
|
*/
|
||||||
|
async function queryAlgolia({ appId, indexName, apiKey }, { query = '', page = 0, hitsPerPage = ALGOLIA_HITS_PER_PAGE, facetFilters = [] } = {}) {
|
||||||
|
const url = `https://${appId}-dsn.algolia.net/1/indexes/${indexName}/query`;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('query', query);
|
||||||
|
params.set('hitsPerPage', String(hitsPerPage));
|
||||||
|
params.set('page', String(page));
|
||||||
|
if (facetFilters.length > 0) {
|
||||||
|
params.set('facetFilters', JSON.stringify(facetFilters));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Algolia-Application-Id': appId,
|
||||||
|
'X-Algolia-API-Key': apiKey,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ params: params.toString() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Requête Algolia échouée (HTTP ${res.status})${body ? `: ${body.slice(0, 160)}` : ''}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstString(value) {
|
||||||
|
if (Array.isArray(value)) return value.find((v) => typeof v === 'string') || null;
|
||||||
|
return typeof value === 'string' ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an Algolia product record to a `formex_materials` row.
|
||||||
|
* Dimension fields from the index: thickness (mm), length (mm), width (mm).
|
||||||
|
* @param {string} [collectionOverride] Force the `collection` (curated line label).
|
||||||
|
*/
|
||||||
|
function mapHit(hit, collectionOverride) {
|
||||||
|
const sku = String(hit.sku || hit.objectID || '').trim();
|
||||||
|
if (!sku) return null;
|
||||||
|
|
||||||
|
const categoryPath = Array.isArray(hit.categories_with_path) ? hit.categories_with_path : [];
|
||||||
|
const category = categoryPath.length > 0
|
||||||
|
? categoryPath[categoryPath.length - 1]
|
||||||
|
: (firstString(hit.categories?.level0) || null);
|
||||||
|
|
||||||
|
const collection = collectionOverride
|
||||||
|
|| firstString(hit.brand)
|
||||||
|
|| firstString(hit.categories?.level0)
|
||||||
|
|| 'Leuba Hiag';
|
||||||
|
|
||||||
|
return {
|
||||||
|
sku,
|
||||||
|
materialCode: `LH-${sku}`,
|
||||||
|
name: hit.name || hit.group_name || sku,
|
||||||
|
collection,
|
||||||
|
category,
|
||||||
|
width_mm: toNumber(hit.width),
|
||||||
|
height_mm: toNumber(hit.length),
|
||||||
|
thickness_mm: toNumber(hit.thickness),
|
||||||
|
image_url: absoluteUrl(hit.image_url),
|
||||||
|
formex_url: absoluteUrl(hit.url),
|
||||||
|
price_chf: null,
|
||||||
|
price_unit: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSupplier() {
|
||||||
|
const existing = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['leubahiag']);
|
||||||
|
if (existing.rows.length > 0) return existing.rows[0].id;
|
||||||
|
|
||||||
|
await run(
|
||||||
|
'INSERT INTO suppliers (code, name, email, type, active) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
['leubahiag', 'Leuba Hiag', '[email protected]', 'materials', 1]
|
||||||
|
);
|
||||||
|
const created = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['leubahiag']);
|
||||||
|
return created.rows[0]?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a material. Existing rows keep their `active` flag (so admin activations are
|
||||||
|
* preserved across re-syncs); new rows are inserted with the provided `active` value.
|
||||||
|
*/
|
||||||
|
async function upsertMaterial(material, supplierId, activeForNew) {
|
||||||
|
const existing = await pool.query(
|
||||||
|
'SELECT id FROM formex_materials WHERE formex_code = ?',
|
||||||
|
[material.materialCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
await run(
|
||||||
|
`UPDATE formex_materials
|
||||||
|
SET name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
|
||||||
|
image_url = ?, formex_url = ?,
|
||||||
|
supplier_id = COALESCE(?, supplier_id), last_synced_at = datetime('now')
|
||||||
|
WHERE formex_code = ?`,
|
||||||
|
[
|
||||||
|
material.name,
|
||||||
|
material.collection,
|
||||||
|
material.category,
|
||||||
|
material.width_mm,
|
||||||
|
material.height_mm,
|
||||||
|
material.thickness_mm,
|
||||||
|
material.image_url,
|
||||||
|
material.formex_url,
|
||||||
|
supplierId,
|
||||||
|
material.materialCode,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return { id: existing.rows[0].id, created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await run(
|
||||||
|
`INSERT INTO formex_materials
|
||||||
|
(formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
|
||||||
|
image_url, formex_url, price_chf, price_unit, supplier_id, active, last_synced_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||||
|
[
|
||||||
|
material.materialCode,
|
||||||
|
material.name,
|
||||||
|
material.collection,
|
||||||
|
material.category,
|
||||||
|
material.width_mm,
|
||||||
|
material.height_mm,
|
||||||
|
material.thickness_mm,
|
||||||
|
material.image_url,
|
||||||
|
material.formex_url,
|
||||||
|
material.price_chf ?? null,
|
||||||
|
material.price_unit ?? null,
|
||||||
|
supplierId,
|
||||||
|
activeForNew ? 1 : 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const created = await pool.query('SELECT id FROM formex_materials WHERE formex_code = ?', [material.materialCode]);
|
||||||
|
return { id: created.rows[0]?.id || null, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the price of an already-imported material by its SKU. */
|
||||||
|
async function updateMaterialPrice(sku, priceChf, priceUnit) {
|
||||||
|
await run(
|
||||||
|
`UPDATE formex_materials
|
||||||
|
SET price_chf = ?, price_unit = COALESCE(?, price_unit), last_synced_at = datetime('now')
|
||||||
|
WHERE formex_code = ?`,
|
||||||
|
[priceChf, priceUnit, `LH-${sku}`]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logSync(status, itemsCount, error = null) {
|
||||||
|
await run(
|
||||||
|
'INSERT INTO sync_logs (source, status, items_count, error) VALUES (?, ?, ?, ?)',
|
||||||
|
['leubahiag', status, itemsCount, error]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import all products matching one curated target (a single catalog line).
|
||||||
|
*/
|
||||||
|
async function importTarget(config, supplierId, target, activate, emit) {
|
||||||
|
const facetFilters = Array.isArray(target.brands) && target.brands.length > 0
|
||||||
|
? [target.brands.map((b) => `brand:${b}`)]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let synced = 0;
|
||||||
|
let created = 0;
|
||||||
|
let page = 0;
|
||||||
|
let nbPages = 1;
|
||||||
|
let nbHits = 0;
|
||||||
|
const skus = [];
|
||||||
|
|
||||||
|
do {
|
||||||
|
if (page > 0) await sleep(CRAWL_DELAY);
|
||||||
|
const data = await queryAlgolia(config, { query: target.query, page, facetFilters });
|
||||||
|
nbPages = data.nbPages || 1;
|
||||||
|
nbHits = data.nbHits || 0;
|
||||||
|
|
||||||
|
if (page === 0) {
|
||||||
|
emit({ type: 'log', message: `→ ${target.label} (${target.tier}) : ${nbHits} produit(s).` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = Array.isArray(data.hits) ? data.hits : [];
|
||||||
|
for (const hit of hits) {
|
||||||
|
const material = mapHit(hit, target.label);
|
||||||
|
if (!material) continue;
|
||||||
|
try {
|
||||||
|
const result = await upsertMaterial(material, supplierId, activate);
|
||||||
|
synced++;
|
||||||
|
if (result.created) created++;
|
||||||
|
skus.push(material.sku);
|
||||||
|
} catch (err) {
|
||||||
|
emit({ type: 'log', message: ` ✗ ${material.materialCode}: ${err.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page++;
|
||||||
|
} while (page < nbPages);
|
||||||
|
|
||||||
|
emit({ type: 'log', message: ` ✓ ${target.label} : ${synced} importé(s) (${created} nouveau(x)).` });
|
||||||
|
return { synced, created, skus };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and store customer prices for a list of SKUs (in batches).
|
||||||
|
* Returns the number of items priced.
|
||||||
|
*/
|
||||||
|
async function importPrices(session, skus, emit) {
|
||||||
|
const BATCH = 50;
|
||||||
|
let priced = 0;
|
||||||
|
for (let i = 0; i < skus.length; i += BATCH) {
|
||||||
|
const batch = skus.slice(i, i + BATCH);
|
||||||
|
try {
|
||||||
|
const data = await session.fetchPrices(batch);
|
||||||
|
for (const sku of batch) {
|
||||||
|
const info = data?.[sku];
|
||||||
|
if (!info) continue;
|
||||||
|
const tier = Array.isArray(info.tierprices) ? info.tierprices[0] : null;
|
||||||
|
const priceChf = parseCHF(tier?.price);
|
||||||
|
if (priceChf == null) continue;
|
||||||
|
await updateMaterialPrice(sku, priceChf, 'pièce');
|
||||||
|
priced++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
emit({ type: 'log', message: ` ✗ Prix lot ${i / BATCH + 1}: ${err.message}` });
|
||||||
|
}
|
||||||
|
emit({ type: 'log', message: ` Prix: ${priced}/${skus.length} récupérés…` });
|
||||||
|
if (i + BATCH < skus.length) await sleep(CRAWL_DELAY);
|
||||||
|
}
|
||||||
|
return priced;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronise the curated Leuba Hiag catalog lines via Algolia.
|
||||||
|
*
|
||||||
|
* @param {object} options
|
||||||
|
* @param {string[]} [options.targets] Subset of target keys to import (default: all).
|
||||||
|
* @param {boolean} [options.activate] Mark newly imported items active (default false).
|
||||||
|
* @param {boolean} [options.fetchPrices] Log in and import customer prices (default true).
|
||||||
|
* @param {object} [options.credentials] { username/email, password } (else env vars).
|
||||||
|
* @param {function} emit SSE-style event emitter.
|
||||||
|
*/
|
||||||
|
export async function syncLeubaHiag(options = {}, emit = () => {}) {
|
||||||
|
const { targets = null, activate = false, fetchPrices = true, credentials = {} } = options;
|
||||||
|
|
||||||
|
const selected = Array.isArray(targets) && targets.length > 0
|
||||||
|
? LEUBA_HIAG_TARGETS.filter((t) => targets.includes(t.key))
|
||||||
|
: LEUBA_HIAG_TARGETS;
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
let synced = 0;
|
||||||
|
let created = 0;
|
||||||
|
let priced = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selected.length === 0) {
|
||||||
|
throw new Error('Aucune gamme Leuba Hiag sélectionnée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await fetchAlgoliaConfig(emit);
|
||||||
|
const supplierId = await ensureSupplier();
|
||||||
|
|
||||||
|
// Log in up-front so prices can be fetched after each line is imported.
|
||||||
|
const session = new LeubaHiagSession();
|
||||||
|
if (fetchPrices) {
|
||||||
|
await session.login(emit, credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit({ type: 'log', message: `${selected.length} gamme(s) à importer.` });
|
||||||
|
emit({ type: 'progress', current: 0, total: selected.length, collection: 'Leuba Hiag' });
|
||||||
|
|
||||||
|
const allSkus = [];
|
||||||
|
for (let i = 0; i < selected.length; i++) {
|
||||||
|
const target = selected[i];
|
||||||
|
emit({ type: 'progress', current: i, total: selected.length, collection: target.label });
|
||||||
|
try {
|
||||||
|
const res = await importTarget(config, supplierId, target, activate, emit);
|
||||||
|
synced += res.synced;
|
||||||
|
created += res.created;
|
||||||
|
allSkus.push(...res.skus);
|
||||||
|
} catch (err) {
|
||||||
|
emit({ type: 'log', message: ` ✗ ${target.label}: ${err.message}` });
|
||||||
|
}
|
||||||
|
if (i < selected.length - 1) await sleep(CRAWL_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.loggedIn && allSkus.length > 0) {
|
||||||
|
emit({ type: 'log', message: `Récupération des prix client pour ${allSkus.length} article(s)…` });
|
||||||
|
priced = await importPrices(session, allSkus, emit);
|
||||||
|
emit({ type: 'log', message: `✓ ${priced} prix client enregistrés.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
|
await logSync('success', synced, null);
|
||||||
|
const result = {
|
||||||
|
success: true,
|
||||||
|
itemsCount: synced,
|
||||||
|
createdCount: created,
|
||||||
|
updatedCount: synced - created,
|
||||||
|
pricedCount: priced,
|
||||||
|
duration,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
emit({ type: 'progress', current: selected.length, total: selected.length, collection: 'Leuba Hiag' });
|
||||||
|
emit({ type: 'complete', result });
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
await logSync('error', synced, err.message);
|
||||||
|
emit({ type: 'error', error: err.message });
|
||||||
|
return { success: false, itemsCount: synced, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
+345
-58
@@ -11,12 +11,16 @@ import { GROOVE, getBackGroovePlanOrientation, BACK_GROOVE_PLAN_VERTICAL_TYPES }
|
|||||||
|
|
||||||
const MPR_ARTICLE_CODES = Object.freeze({
|
const MPR_ARTICLE_CODES = Object.freeze({
|
||||||
plintheBas: 'PL_BAS',
|
plintheBas: 'PL_BAS',
|
||||||
|
plintheGauche: 'PL_G',
|
||||||
|
plintheDroite: 'PL_D',
|
||||||
bandeauHaut: 'BH',
|
bandeauHaut: 'BH',
|
||||||
dessus: 'BO_U',
|
dessus: 'BO_U',
|
||||||
fond: 'BO_L',
|
fond: 'BO_L',
|
||||||
cotes: 'SE',
|
cotes: 'SE',
|
||||||
cotesDoublage: 'CD',
|
cotesDoublage: 'CD',
|
||||||
fileur: 'FI',
|
fileur: 'FI',
|
||||||
|
fileurCotes: 'FI_C',
|
||||||
|
fileurHaut: 'FI_H',
|
||||||
habillageFinitionGauche: 'FR_L_G',
|
habillageFinitionGauche: 'FR_L_G',
|
||||||
habillageFinitionDroite: 'FR_L_D',
|
habillageFinitionDroite: 'FR_L_D',
|
||||||
habillageFinitionDessus: 'FR_H',
|
habillageFinitionDessus: 'FR_H',
|
||||||
@@ -26,7 +30,10 @@ const MPR_ARTICLE_CODES = Object.freeze({
|
|||||||
panneauxInterieurs: 'ZA_O',
|
panneauxInterieurs: 'ZA_O',
|
||||||
rayonFixe: 'RF_X',
|
rayonFixe: 'RF_X',
|
||||||
etageres: 'EB_M',
|
etageres: 'EB_M',
|
||||||
renfortsTiroir: 'LABO'
|
renfortsTiroir: 'LABO',
|
||||||
|
tiroirFond: 'TI_BO',
|
||||||
|
tiroirDos: 'TI_RW',
|
||||||
|
tiroirFacade: 'TI_FR'
|
||||||
});
|
});
|
||||||
|
|
||||||
function fmtMm(n) {
|
function fmtMm(n) {
|
||||||
@@ -45,6 +52,13 @@ function escQuoted(s) {
|
|||||||
return String(s ?? '').replace(/"/g, "'");
|
return String(s ?? '').replace(/"/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Désignation « type » pour le bloc [001 (sans suffixe côté/index) — ex. SE_L → SE, ZA_O_2 → ZA. */
|
||||||
|
function baseDesignation(slug) {
|
||||||
|
const s = String(slug || '').trim();
|
||||||
|
const cut = s.indexOf('_');
|
||||||
|
return cut > 0 ? s.slice(0, cut) : s;
|
||||||
|
}
|
||||||
|
|
||||||
/** Scinde ligne « gauche+droit » qty 2 en deux fichiers SE_L / SE_R. */
|
/** Scinde ligne « gauche+droit » qty 2 en deux fichiers SE_L / SE_R. */
|
||||||
export function flattenPiecesForMprExport(pieces = []) {
|
export function flattenPiecesForMprExport(pieces = []) {
|
||||||
const out = [];
|
const out = [];
|
||||||
@@ -63,6 +77,14 @@ export function flattenPiecesForMprExport(pieces = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let baseSlug = MPR_ARTICLE_CODES[piece.elementType] || String(piece.elementType || '').toUpperCase() || 'PIECE';
|
let baseSlug = MPR_ARTICLE_CODES[piece.elementType] || String(piece.elementType || '').toUpperCase() || 'PIECE';
|
||||||
|
if (piece.horizontalSpanId != null) {
|
||||||
|
const spanId = String(piece.horizontalSpanId).replace(/[^\w-]+/g, '_');
|
||||||
|
const role = String(piece.horizontalSpanRole || 'panel').toUpperCase();
|
||||||
|
baseSlug = `HS_${spanId}_${role}_${baseSlug}`;
|
||||||
|
if (piece.horizontalSpanRole === 'door' && Number.isFinite(Number(piece.doorIndex))) {
|
||||||
|
baseSlug += `_${Number(piece.doorIndex) + 1}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (let qi = 0; qi < qty; qi++) {
|
for (let qi = 0; qi < qty; qi++) {
|
||||||
let slug = baseSlug;
|
let slug = baseSlug;
|
||||||
@@ -80,11 +102,14 @@ export function flattenPiecesForMprExport(pieces = []) {
|
|||||||
|
|
||||||
function mprAnglesForPiece(piece) {
|
function mprAnglesForPiece(piece) {
|
||||||
const et = piece.elementType || '';
|
const et = piece.elementType || '';
|
||||||
if (habillageEtPorte(et)) {
|
const widthMm = toSafe(piece.rawCutWidthMm, piece.widthMm);
|
||||||
|
const heightMm = toSafe(piece.rawCutHeightMm, piece.heightMm);
|
||||||
|
const vertical = heightMm > widthMm;
|
||||||
|
if (vertical) {
|
||||||
return {
|
return {
|
||||||
DRWIN: '270.0',
|
DRWIN: '270.0',
|
||||||
beschGedreht: '0',
|
beschGedreht: '0',
|
||||||
turnMsg: et === 'portes' ? 'DREHEN X-ACHSE' : 'DREHEN X-ACHSE'
|
turnMsg: 'DREHEN X-ACHSE'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (['dessus', 'fond'].includes(et)) {
|
if (['dessus', 'fond'].includes(et)) {
|
||||||
@@ -118,7 +143,7 @@ function kantenFlags(piece) {
|
|||||||
return { kavo: '1.0', kali: '0.0', kahi: '0.0', kare: '0.0' };
|
return { kavo: '1.0', kali: '0.0', kahi: '0.0', kare: '0.0' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function grooveSection(piece, _Lmm, _Bmm) {
|
function grooveSection(piece, Lmm, Bmm) {
|
||||||
const m = piece?.machining || {};
|
const m = piece?.machining || {};
|
||||||
const orient = piece?.elementType ? getBackGroovePlanOrientation(piece.elementType) : null;
|
const orient = piece?.elementType ? getBackGroovePlanOrientation(piece.elementType) : null;
|
||||||
if (!m.groove || !orient) return [];
|
if (!m.groove || !orient) return [];
|
||||||
@@ -127,26 +152,60 @@ function grooveSection(piece, _Lmm, _Bmm) {
|
|||||||
const gd = fmtMm(Number(m.groove.depth ?? GROOVE.back.depth));
|
const gd = fmtMm(Number(m.groove.depth ?? GROOVE.back.depth));
|
||||||
const abst = fmtMm(Number(m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge));
|
const abst = fmtMm(Number(m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge));
|
||||||
const vertical = BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType);
|
const vertical = BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType);
|
||||||
const win = vertical ? '180.0' : '270.0';
|
const axis = Number(abst);
|
||||||
|
const start = vertical
|
||||||
|
? [Math.max(0, Lmm - axis), 0]
|
||||||
|
: [0, Math.max(0, Bmm - axis)];
|
||||||
|
const end = vertical
|
||||||
|
? [Math.max(0, Lmm - axis), Bmm]
|
||||||
|
: [Lmm, Math.max(0, Bmm - axis)];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'<121 \\Block\\',
|
'<121 \\Block\\',
|
||||||
'MNM="Kantenbearbeitungen von oben"',
|
'MNM="Kantenbearbeitungen von oben"',
|
||||||
'DP="1"',
|
'DP="1"',
|
||||||
'KO="04"',
|
'KO="04"',
|
||||||
'<139 \\Komponente\\',
|
'<109 \\Nuten\\',
|
||||||
'IN="RSO/Makro/RSO_Nut_Flaeche.mpr"',
|
'MNM="Rainure dos"',
|
||||||
'MNM="Nut Flaeche rueckseite"',
|
`XA="${fmtMm(start[0])}"`,
|
||||||
'XA="L"',
|
`YA="${fmtMm(start[1])}"`,
|
||||||
'YA="B"',
|
`XE="${fmtMm(end[0])}"`,
|
||||||
'VA="LA L"',
|
`YE="${fmtMm(end[1])}"`,
|
||||||
`VA="BR ${gw}"`,
|
`NB="${gw}"`,
|
||||||
`VA="TI ${gd}"`,
|
'RK="NOWRK"',
|
||||||
`VA="ABST ${abst}"`,
|
'EM="MOD2"',
|
||||||
`VA="WIN ${win}"`,
|
'TV="0"',
|
||||||
'VA="ANF 0.0"',
|
'MV="GL"',
|
||||||
'VA="END 0.0"',
|
`TI="${gd}"`,
|
||||||
'VA="MAT MAT"',
|
'MN="GL"',
|
||||||
|
'KO="04"'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function doorHandleGrooveSection(piece, L, B) {
|
||||||
|
const groove = piece?.machining?.fullHeightHandleGroove;
|
||||||
|
if (piece?.elementType !== 'portes' || !groove) return [];
|
||||||
|
const widthMm = Math.max(1, Number(groove.grooveWidthMm) || 8);
|
||||||
|
const depthMm = Math.max(0.1, Number(groove.grooveDepthMm) || 3);
|
||||||
|
const offsetMm = Math.max(0, Number(groove.topBottomOffsetMm) || 0);
|
||||||
|
const hingeSide = piece?.doorPlanHints?.hingeSide === 'right' ? 'right' : 'left';
|
||||||
|
const xMm = hingeSide === 'right' ? widthMm / 2 : L - widthMm / 2;
|
||||||
|
const y1Mm = Math.min(B, offsetMm);
|
||||||
|
const y2Mm = Math.max(y1Mm, B - offsetMm);
|
||||||
|
return [
|
||||||
|
'<109 \\Nuten\\',
|
||||||
|
'MNM="Rainure poignée toute hauteur"',
|
||||||
|
`XA="${fmtMm(xMm)}"`,
|
||||||
|
`YA="${fmtMm(y1Mm)}"`,
|
||||||
|
`XE="${fmtMm(xMm)}"`,
|
||||||
|
`YE="${fmtMm(y2Mm)}"`,
|
||||||
|
`NB="${fmtMm(widthMm)}"`,
|
||||||
|
'RK="NOWRK"',
|
||||||
|
'EM="MOD2"',
|
||||||
|
'TV="0"',
|
||||||
|
'MV="GL"',
|
||||||
|
`TI="${fmtMm(depthMm)}"`,
|
||||||
|
'MN="GL"',
|
||||||
'KO="04"'
|
'KO="04"'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -158,15 +217,17 @@ function bohrVertLines(spec, thicknessMm) {
|
|||||||
const edgeHint = Boolean(spec.toolMode === 'edge' || spec.face === 'end');
|
const edgeHint = Boolean(spec.toolMode === 'edge' || spec.face === 'end');
|
||||||
const throughHint = Boolean(spec.toolMode === 'through');
|
const throughHint = Boolean(spec.toolMode === 'through');
|
||||||
const looksThrough = depthRaw >= thicknessMm - 0.5;
|
const looksThrough = depthRaw >= thicknessMm - 0.5;
|
||||||
|
const fromUnder = spec.face === 'back';
|
||||||
let bm = 'LS';
|
let bm = 'LS';
|
||||||
let ti;
|
let ti;
|
||||||
if (throughHint || looksThrough) {
|
if (throughHint || looksThrough) {
|
||||||
bm = 'LSL';
|
bm = fromUnder ? 'LSLU' : 'LSL';
|
||||||
ti = `${fmtMm(thicknessMm)} + 1`;
|
ti = `${fmtMm(thicknessMm)} + 1`;
|
||||||
} else if (edgeHint && depthRaw >= 38) {
|
} else if (edgeHint && depthRaw >= 38) {
|
||||||
bm = 'LSL';
|
bm = 'LSL';
|
||||||
ti = fmtMm(depthRaw);
|
ti = fmtMm(depthRaw);
|
||||||
} else {
|
} else {
|
||||||
|
bm = fromUnder ? 'LSU' : 'LS';
|
||||||
ti = fmtMm(depthRaw);
|
ti = fmtMm(depthRaw);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +253,59 @@ function bohrVertLines(spec, thicknessMm) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un trou de chant → lignes <103 BohrHoriz (perçage horizontal dans un chant).
|
||||||
|
* `end` désigne le chant d'entrée dans le repère pièce (origine bas-gauche) :
|
||||||
|
* left → depuis chant gauche (BM=XP), right → chant droit (BM=XM),
|
||||||
|
* bottom → chant avant Y=0 (BM=YP), top → chant arrière Y=B (BM=YM).
|
||||||
|
* ZA = position dans l'épaisseur (axe du trou) : `zMm` si fourni, sinon milieu (D/2).
|
||||||
|
*/
|
||||||
|
function bohrHorizLines(spec, L, B, D) {
|
||||||
|
const du = fmtMm(Math.round((Number(spec.diameterMm) || 8) * 10) / 10);
|
||||||
|
const ti = fmtMm(Number(spec.depthMm) || 25);
|
||||||
|
const za = fmtMm(Number.isFinite(Number(spec.zMm)) ? Number(spec.zMm) : D / 2);
|
||||||
|
|
||||||
|
let bm;
|
||||||
|
let xa;
|
||||||
|
let ya;
|
||||||
|
switch (spec.end) {
|
||||||
|
case 'right':
|
||||||
|
bm = 'XM';
|
||||||
|
xa = L;
|
||||||
|
ya = spec.yMm;
|
||||||
|
break;
|
||||||
|
case 'bottom':
|
||||||
|
bm = 'YP';
|
||||||
|
xa = spec.xMm;
|
||||||
|
ya = 0;
|
||||||
|
break;
|
||||||
|
case 'top':
|
||||||
|
bm = 'YM';
|
||||||
|
xa = spec.xMm;
|
||||||
|
ya = B;
|
||||||
|
break;
|
||||||
|
case 'left':
|
||||||
|
default:
|
||||||
|
bm = 'XP';
|
||||||
|
xa = 0;
|
||||||
|
ya = spec.yMm;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'<103 \\BohrHoriz\\',
|
||||||
|
`MNM="Bohrung ${du} mm"`,
|
||||||
|
`XA="${fmtMm(xa)}"`,
|
||||||
|
`YA="${fmtMm(ya)}"`,
|
||||||
|
`ZA="${za}"`,
|
||||||
|
`DU="${du}"`,
|
||||||
|
`TI="${ti}"`,
|
||||||
|
`BM="${bm}"`,
|
||||||
|
'AN="1"',
|
||||||
|
'KO="04"'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/** Supprime les doublons numériques (tolérance 0.02 mm — sans fusionner trous légitimes proches). */
|
/** Supprime les doublons numériques (tolérance 0.02 mm — sans fusionner trous légitimes proches). */
|
||||||
function dedupeHoleSpecs(specs, eps = 0.02) {
|
function dedupeHoleSpecs(specs, eps = 0.02) {
|
||||||
const out = [];
|
const out = [];
|
||||||
@@ -200,14 +314,134 @@ function dedupeHoleSpecs(specs, eps = 0.02) {
|
|||||||
(o) =>
|
(o) =>
|
||||||
Math.abs(o.xMm - s.xMm) < eps &&
|
Math.abs(o.xMm - s.xMm) < eps &&
|
||||||
Math.abs(o.yMm - s.yMm) < eps &&
|
Math.abs(o.yMm - s.yMm) < eps &&
|
||||||
Math.abs((o.diameterMm || 0) - (s.diameterMm || 0)) < eps
|
Math.abs((o.diameterMm || 0) - (s.diameterMm || 0)) < eps &&
|
||||||
|
(o.face || 'face') === (s.face || 'face') &&
|
||||||
|
(o.end || '') === (s.end || '')
|
||||||
);
|
);
|
||||||
if (!hit) out.push(s);
|
if (!hit) out.push(s);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSingleMprText({
|
/**
|
||||||
|
* La fraise à rainurer travaille depuis le dessus de la machine. Pour le
|
||||||
|
* panneau de dessus, on pose donc la face intérieure caisse vers le haut :
|
||||||
|
* rainure dos + assemblages caisse dessus, perçages d'habillage dessous.
|
||||||
|
*/
|
||||||
|
function orientFaceSpecsForMpr(piece, specs) {
|
||||||
|
if (piece?.elementType !== 'dessus' || !piece?.machining?.groove) return specs;
|
||||||
|
return specs.map((spec) => {
|
||||||
|
if (spec.face === 'face') return { ...spec, face: 'back' };
|
||||||
|
if (spec.face === 'back') return { ...spec, face: 'face' };
|
||||||
|
return spec;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Coupes d'onglet 45° (habillages de finition) :
|
||||||
|
* - tourillons perpendiculaires à la face d'onglet → <104 BohrUniv (perçage
|
||||||
|
* spatial : azimut C `CA` + inclinaison agrégat `WI`), sur le système 04.
|
||||||
|
* (<102 BohrVert exige un plan parallèle au plan supérieur.)
|
||||||
|
* - trait de scie 45° → <124 Nut_R (« Nuten im A-Winkel » : sciage avec lame
|
||||||
|
* inclinée WI, macro documenté du format MPR, équivalent Uni_Saeg_...W45
|
||||||
|
* du postprocesseur DXF HOMAG). Directement sur le plan supérieur (KO 04) :
|
||||||
|
* pas de plan incliné custom, donc affichage propre et génération NC
|
||||||
|
* possible avec l'agrégat scie pivotante.
|
||||||
|
* Géométrie (coordonnées pièce) : la pièce occupe Z=0..D. La scie démarre donc
|
||||||
|
* explicitement sur la face supérieure ZA=D, puis descend à 45° vers le chant
|
||||||
|
* extérieur. Les trajectoires sont parcourues de sorte que leur normale gauche
|
||||||
|
* pointe vers la chute : l'axe de scie est parallèle aux tourillons et le plan
|
||||||
|
* de lame leur est perpendiculaire. Aucun plan S auxiliaire n'est nécessaire.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const MITER_SAW_KERF_MM = 4;
|
||||||
|
|
||||||
|
function buildMiterSections({ miterSpecs, L, B, D }) {
|
||||||
|
const empty = { levelLines: [], opLines: [] };
|
||||||
|
if (!miterSpecs.length) return empty;
|
||||||
|
|
||||||
|
const kerfCenter = MITER_SAW_KERF_MM / 2;
|
||||||
|
|
||||||
|
// Trajectoires globales sur KO=04. Pour chaque chant, le côté gauche du
|
||||||
|
// parcours est l'extérieur de la pièce (la chute).
|
||||||
|
const SIDES = {
|
||||||
|
left: {
|
||||||
|
label: 'gauche',
|
||||||
|
ca: 0,
|
||||||
|
entry: (sp) => [D / 2, sp.yMm],
|
||||||
|
sawPath: [[D - kerfCenter, 0], [D - kerfCenter, B]]
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
label: 'droite',
|
||||||
|
ca: 180,
|
||||||
|
entry: (sp) => [L - D / 2, sp.yMm],
|
||||||
|
sawPath: [[L - D + kerfCenter, B], [L - D + kerfCenter, 0]]
|
||||||
|
},
|
||||||
|
top: {
|
||||||
|
label: 'haut',
|
||||||
|
ca: 270,
|
||||||
|
entry: (sp) => [sp.xMm, B - D / 2],
|
||||||
|
sawPath: [[0, B - D + kerfCenter], [L, B - D + kerfCenter]]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const opLines = [];
|
||||||
|
|
||||||
|
for (const [side, def] of Object.entries(SIDES)) {
|
||||||
|
const specs = miterSpecs.filter((sp) => sp.end === side);
|
||||||
|
if (!specs.length) continue;
|
||||||
|
|
||||||
|
/* Tourillons : perçage spatial sur le système 04 (repère pièce). */
|
||||||
|
for (const sp of specs) {
|
||||||
|
const [xa, ya] = def.entry(sp);
|
||||||
|
const du = fmtMm(Number(sp.diameterMm) || 8);
|
||||||
|
opLines.push(
|
||||||
|
'<104 \\BohrUniv\\',
|
||||||
|
`MNM="Tourillon onglet ${def.label} ${du} mm"`,
|
||||||
|
`XA="${fmtMm(xa)}"`,
|
||||||
|
`YA="${fmtMm(ya)}"`,
|
||||||
|
`ZA="${fmtMm(D / 2)}"`,
|
||||||
|
`CA="${def.ca}"`,
|
||||||
|
'WI="45"',
|
||||||
|
// woodWOP compte TI depuis le point XA/YA/ZA le long de l'axe incliné :
|
||||||
|
// avec l'entrée au centre de la face d'onglet, la profondeur affichée
|
||||||
|
// vaut le double de la profondeur utile → on émet la moitié.
|
||||||
|
`TI="${fmtMm((Number(sp.depthMm) || 15) / 2)}"`,
|
||||||
|
`DU="${du}"`,
|
||||||
|
'AN="1"',
|
||||||
|
'F_="STANDARD"',
|
||||||
|
'KO="04"'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trait de scie 45° incliné (Nut_R / Nuten im A-Winkel). */
|
||||||
|
const [start, end] = def.sawPath;
|
||||||
|
opLines.push(
|
||||||
|
'<124 \\Nut_R\\',
|
||||||
|
`MNM="Coupe onglet 45 ${def.label}"`,
|
||||||
|
`XA="${fmtMm(start[0])}"`,
|
||||||
|
`YA="${fmtMm(start[1])}"`,
|
||||||
|
`XE="${fmtMm(end[0])}"`,
|
||||||
|
`YE="${fmtMm(end[1])}"`,
|
||||||
|
// KO=04 a son origine en Z=0 sous la pièce ; la face supérieure est Z=D.
|
||||||
|
`ZA="${fmtMm(D)}"`,
|
||||||
|
'WI="45"',
|
||||||
|
`NB="${fmtMm(MITER_SAW_KERF_MM)}"`,
|
||||||
|
'RK="NOWRK"',
|
||||||
|
'EM="MOD2"',
|
||||||
|
'TV="0"',
|
||||||
|
'MV="GL"',
|
||||||
|
`TI="${fmtMm(D * Math.SQRT2 + 2)}"`,
|
||||||
|
'MN="GL"',
|
||||||
|
'F_="STANDARD"',
|
||||||
|
'KO="04"'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { levelLines: [], opLines };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSingleMprText({
|
||||||
piece,
|
piece,
|
||||||
slug,
|
slug,
|
||||||
qtyIndex,
|
qtyIndex,
|
||||||
@@ -226,43 +460,57 @@ function buildSingleMprText({
|
|||||||
|
|
||||||
const posLabel = `${groupIndex}.${seqGlobal}`;
|
const posLabel = `${groupIndex}.${seqGlobal}`;
|
||||||
const { specs, thicknessMm } = collectHoleSpecsForPiece(piece, L, B, qtyIndex);
|
const { specs, thicknessMm } = collectHoleSpecsForPiece(piece, L, B, qtyIndex);
|
||||||
const unique = dedupeHoleSpecs(specs);
|
const unique = orientFaceSpecsForMpr(piece, dedupeHoleSpecs(specs));
|
||||||
|
|
||||||
/* Bloc Bohrungen */
|
// Répartition des perçages par destination d'usinage (repère pièce, origine bas-gauche) :
|
||||||
const holeLines = [];
|
// - onglet 45° (habillages) → plans inclinés dédiés (buildMiterSections),
|
||||||
for (const sp of unique) {
|
// - chant (face='end') → <103 BohrHoriz, regroupés par côté d'entrée,
|
||||||
holeLines.push(...bohrVertLines(sp, thicknessMm));
|
// - face opposée (face='back') → <102 BohrVert avec cycle WoodWOP « von unten »,
|
||||||
}
|
// - grande face (défaut) → <102 BohrVert « von oben ».
|
||||||
|
const miterSpecs = unique.filter((sp) => sp.type === 'tourillon_miter' && sp.end);
|
||||||
const dp = Math.max(1, Math.min(98, holeLines.filter((ln) => ln.startsWith('<102')).length));
|
const rest = unique.filter((sp) => !miterSpecs.includes(sp));
|
||||||
|
const edgeSpecs = rest.filter((sp) => sp.face === 'end');
|
||||||
|
const backSpecs = rest.filter((sp) => sp.face === 'back');
|
||||||
|
const topSpecs = rest.filter((sp) => sp.face !== 'end' && sp.face !== 'back');
|
||||||
|
|
||||||
const matKm = escQuoted(
|
const matKm = escQuoted(
|
||||||
piece.finishId != null && piece.finishId !== '' ? piece.finishId : defaultMaterialArticle || 'APEX'
|
piece.finishId != null && piece.finishId !== '' ? piece.finishId : defaultMaterialArticle || 'APEX'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Les opérations restent exprimées dans le repère pièce KO=04. DRWIN tourne
|
||||||
|
// uniquement la présentation/pose machine (pièces hautes couchées en X).
|
||||||
|
const miter = buildMiterSections({ miterSpecs, L, B, D });
|
||||||
|
|
||||||
|
const bez = escQuoted(baseDesignation(slug));
|
||||||
|
const hasBack = backSpecs.length > 0;
|
||||||
|
|
||||||
const lines = [];
|
const lines = [];
|
||||||
|
|
||||||
lines.push('[H', 'VERSION="4.0 Alpha"', 'FW="1400"', 'FM="1"', 'ZS="40"', 'OP="1"');
|
lines.push('[H', 'VERSION="4.0 Alpha"', 'FW="1400"', 'FM="1"', 'ZS="40"', 'OP="1"');
|
||||||
|
// Une pièce plus haute que large est couchée à 270° pour correspondre à
|
||||||
|
// l'orientation machine; les formules DW conservent son repère d'usinage.
|
||||||
lines.push('[001', 'MAT="2"', 'KM="Materialart"', `DRWIN="${angles.DRWIN}"`, 'KM="Drehwinkel 0,90,180,270"');
|
lines.push('[001', 'MAT="2"', 'KM="Materialart"', `DRWIN="${angles.DRWIN}"`, 'KM="Drehwinkel 0,90,180,270"');
|
||||||
lines.push(`AUFT="${escQuoted(orderReference)}"`, `KM="AUFTRAG: ${escQuoted(orderReference)}"`);
|
lines.push('AUFT="0"', `KM="AUFTRAG: ${escQuoted(orderReference)}"`);
|
||||||
lines.push(`STKL="${escQuoted(stackListKm)}"`, `KM="STUECKLISTE: ${escQuoted(stackListKm)}"`);
|
lines.push('STKL="0"', `KM="STÜCKLISTE: ${escQuoted(stackListKm)}"`);
|
||||||
lines.push(`POS="${escQuoted(posLabel)}"`, `KM="POSITION: ${escQuoted(posLabel)}"`);
|
lines.push(`POS="${escQuoted(posLabel)}"`, `KM="POSITION: ${escQuoted(posLabel)}"`);
|
||||||
lines.push('BEZ="0"', `KM="Bezeichnung: ${escQuoted(slug)}"`);
|
lines.push('BEZ="0"', `KM="Bezeichnung: ${bez}"`);
|
||||||
lines.push(`L="${fmtMm(L)}"`, 'KM="Laenge in X"', `B="${fmtMm(B)}"`, 'KM="Breite in Y"', `D="${fmtMm(D)}"`, 'KM="Dicke in Z"');
|
lines.push(`L="${fmtMm(L)}"`, 'KM="Länge in X"', `B="${fmtMm(B)}"`, 'KM="Breite in Y"', `D="${fmtMm(D)}"`, 'KM="Dicke in Z"');
|
||||||
lines.push('ZF="2.0"', 'KM="Zugabe Fraesung"', 'ZB="3.0"', 'KM="Zugabe Bohrung"');
|
lines.push('ZF="2.0"', 'KM="Zugabe für DurchgangsFräsung"', 'ZB="3.0"', 'KM="Zugabe für DurchgangsBohrung"');
|
||||||
lines.push('RITZEN="1.0"', 'KM="Vorritztiefe mm"');
|
lines.push('RITZEN="1.0"', 'KM="Vorritztiefe in mm - 0=nicht Vorritzen"');
|
||||||
lines.push(`KAVODI="${kant.kavo}"`, 'KM="Kantendicke vorne"', `KALIDI="${kant.kali}"`, 'KM="Kantendicke links"');
|
lines.push(`KAVODI="${kant.kavo}"`, 'KM="Kantendicke vorne"', `KALIDI="${kant.kali}"`, 'KM="Kantendicke links"');
|
||||||
lines.push(`KAHIDI="${kant.kahi}"`, 'KM="Kantendicke hinten"', `KAREDI="${kant.kare}"`, 'KM="Kantendicke rechts"');
|
lines.push(`KAHIDI="${kant.kahi}"`, 'KM="Kantendicke hinten"', `KAREDI="${kant.kare}"`, 'KM="Kantendicke rechts"');
|
||||||
lines.push('FORMAT="0"', 'KM="Formatieren 0 oder 1"');
|
lines.push('FORMAT="0"', 'KM="Formatieren 0 oder 1"');
|
||||||
lines.push('ZUGLI="0.0"', 'ZUGRE="0.0"', 'ZUGVO="0.0"', 'ZUGHI="0.0"');
|
lines.push('ZUGLI="0.0"', 'KM="Zugabe für Gehrung links"');
|
||||||
lines.push('DW="IF DRWIN=0 OR DRWIN=90 OR DRWIN=180 THEN DRWIN ELSE 270"');
|
lines.push('ZUGRE="0.0"', 'KM="Zugabe für Gehrung rechts"');
|
||||||
lines.push('ld="IF DW=90 OR DW=270 THEN B ELSE L"', 'bd="IF DW=90 OR DW=270 THEN L ELSE B"');
|
lines.push('ZUGVO="0.0"', 'KM="Zugabe für Gehrung vorne"');
|
||||||
lines.push(
|
lines.push('ZUGHI="0.0"', 'KM="Zugabe für Gehrung hinten"');
|
||||||
'offxL="IF DW=180 THEN ZUGRE ELSE ZUGLI"',
|
lines.push('DW="IF DRWIN=0 OR DRWIN=90 OR DRWIN=180 THEN DRWIN ELSE 270"', 'KM="DISABLED Drehwinkel korrigiert bei Fehleingabe"');
|
||||||
'offxB="IF DW=90 THEN ZUGHI ELSE ZUGVO"',
|
lines.push('ld="IF DW=90 OR DW=270 THEN B ELSE L"', 'KM="DISABLED Länge in X (gedreht)"');
|
||||||
'offyL="IF DW=180 THEN ZUGHI ELSE ZUGVO"',
|
lines.push('bd="IF DW=90 OR DW=270 THEN L ELSE B"', 'KM="DISABLED Breite in Y (gedreht)"');
|
||||||
'offyB="IF DW=90 THEN ZUGLI ELSE ZUGRE"'
|
lines.push('offxL="IF DW=180 THEN ZUGRE ELSE ZUGLI"', 'KM="DISABLED Offset in X (0° oder 180°)"');
|
||||||
);
|
lines.push('offxB="IF DW=90 THEN ZUGHI ELSE ZUGVO"', 'KM="DISABLED Offset in X (90° oder 270°)"');
|
||||||
|
lines.push('offyL="IF DW=180 THEN ZUGHI ELSE ZUGVO"', 'KM="DISABLED Offset in Y (0° oder 180°)"');
|
||||||
|
lines.push('offyB="IF DW=90 THEN ZUGLI ELSE ZUGRE"', 'KM="DISABLED Offset in Y (90° oder 270°)"');
|
||||||
lines.push(
|
lines.push(
|
||||||
'[K',
|
'[K',
|
||||||
'<00 \\Koordinatensystem\\',
|
'<00 \\Koordinatensystem\\',
|
||||||
@@ -310,9 +558,12 @@ function buildSingleMprText({
|
|||||||
'KI="90"',
|
'KI="90"',
|
||||||
'D2="0"',
|
'D2="0"',
|
||||||
'MI="0"',
|
'MI="0"',
|
||||||
|
...miter.levelLines,
|
||||||
'<100 \\WerkStck\\',
|
'<100 \\WerkStck\\',
|
||||||
'LA="IF DW=90 OR DW=270 THEN B ELSE L"',
|
// WerkStck uses the piece-local dimensions. KO=04 applies DRWIN to the
|
||||||
'BR="IF DW=90 OR DW=270 THEN L ELSE B"',
|
// stock and operations together; swapping here would rotate the stock twice.
|
||||||
|
`LA="${fmtMm(L)}"`,
|
||||||
|
`BR="${fmtMm(B)}"`,
|
||||||
'DI="D"',
|
'DI="D"',
|
||||||
'FNX="IF DW=90 OR DW=270 THEN offxB ELSE offxL"',
|
'FNX="IF DW=90 OR DW=270 THEN offxB ELSE offxL"',
|
||||||
'FNY="IF DW=90 OR DW=270 THEN offyB ELSE offyL"',
|
'FNY="IF DW=90 OR DW=270 THEN offyB ELSE offyL"',
|
||||||
@@ -320,20 +571,25 @@ function buildSingleMprText({
|
|||||||
'AY="IF DW=90 OR DW=270 THEN ZUGLI+ZUGRE ELSE ZUGVO+ZUGHI"',
|
'AY="IF DW=90 OR DW=270 THEN ZUGLI+ZUGRE ELSE ZUGVO+ZUGHI"',
|
||||||
'<101 \\Kommentar\\',
|
'<101 \\Kommentar\\',
|
||||||
`KM="AUFTRAG : ${escQuoted(orderReference)}"`,
|
`KM="AUFTRAG : ${escQuoted(orderReference)}"`,
|
||||||
`KM="STUECKLISTE : ${escQuoted(stackListKm)}"`,
|
`KM="STÜCKLISTE : ${escQuoted(stackListKm)}"`,
|
||||||
`KM="POSITION : ${escQuoted(posLabel)}"`,
|
`KM="POSITION : ${escQuoted(posLabel)}"`,
|
||||||
`KM="Bezeichnung: ${escQuoted(slug)}"`,
|
`KM="Bezeichnung: ${escQuoted(slug)}"`,
|
||||||
'KM=""',
|
'KM=""',
|
||||||
`KM="${escQuoted(angles.turnMsg)}"`,
|
`KM="${escQuoted(angles.turnMsg)}"`,
|
||||||
'KM=""',
|
'KM=""',
|
||||||
'KM="Teileinfo"',
|
'KM="Teileinfo"',
|
||||||
`KM="Groesse: ${fmtMm(L)} x ${fmtMm(B)}"`,
|
`KM="Größe: ${fmtMm(L)} x ${fmtMm(B)}"`,
|
||||||
`KM="Staerke: ${fmtMm(D)}"`,
|
`KM="Stärke: ${fmtMm(D)}"`,
|
||||||
`KM=""`,
|
`KM=""`,
|
||||||
`KM="Material: ${matKm}"`,
|
`KM="Material: ${matKm}"`,
|
||||||
'KM="Belag IN: "',
|
'KM="Belag IN: "',
|
||||||
'KM="Belag AU: "',
|
'KM="Belag AU: "',
|
||||||
'KM=""',
|
'KM=""'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Les usinages de sous-face utilisent directement BM=LSU/LSLU et ne
|
||||||
|
// nécessitent donc aucun retournement de la pièce.
|
||||||
|
lines.push(
|
||||||
'<139 \\Komponente\\',
|
'<139 \\Komponente\\',
|
||||||
'MNM="Beschriftung"',
|
'MNM="Beschriftung"',
|
||||||
'IN="RSO/Makro/RSO_Beschriftung.mpr"',
|
'IN="RSO/Makro/RSO_Beschriftung.mpr"',
|
||||||
@@ -345,7 +601,10 @@ function buildSingleMprText({
|
|||||||
'VA="KAREDI KAREDI"',
|
'VA="KAREDI KAREDI"',
|
||||||
`VA="GEDREHT ${angles.beschGedreht}"`,
|
`VA="GEDREHT ${angles.beschGedreht}"`,
|
||||||
'VA="NCSTOPP 0"',
|
'VA="NCSTOPP 0"',
|
||||||
'KO="04"',
|
'KO="04"'
|
||||||
|
);
|
||||||
|
// Macro de formatage (RSO_Format) — exécutée seulement si FORMAT=1 (??="FORMAT").
|
||||||
|
lines.push(
|
||||||
'<139 \\Komponente\\',
|
'<139 \\Komponente\\',
|
||||||
'MNM="Formatierung"',
|
'MNM="Formatierung"',
|
||||||
'IN="RSO/Makro/RSO_Format.mpr"',
|
'IN="RSO/Makro/RSO_Format.mpr"',
|
||||||
@@ -363,15 +622,41 @@ function buildSingleMprText({
|
|||||||
'VA="KAFRMAX 2.0"',
|
'VA="KAFRMAX 2.0"',
|
||||||
'VA="MAT MAT"',
|
'VA="MAT MAT"',
|
||||||
'??="FORMAT"',
|
'??="FORMAT"',
|
||||||
'KO="04"',
|
|
||||||
'<121 \\Block\\',
|
|
||||||
'MNM="Bearbeitungen von oben "',
|
|
||||||
`DP="${dp}"`,
|
|
||||||
'KO="04"'
|
'KO="04"'
|
||||||
);
|
);
|
||||||
|
|
||||||
lines.push(...holeLines);
|
// Bloc « Bearbeitungen von oben » — perçages verticaux sur la grande face.
|
||||||
|
if (topSpecs.length) {
|
||||||
|
lines.push('<121 \\Block\\', 'MNM="Bearbeitungen von oben "', `DP="${topSpecs.length}"`, 'KO="04"');
|
||||||
|
for (const sp of topSpecs) lines.push(...bohrVertLines(sp, thicknessMm));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bloc « Bearbeitungen von unten » — BM=LSU/LSLU indique réellement à
|
||||||
|
// WoodWOP que l'outil entre par la sous-face.
|
||||||
|
if (hasBack) {
|
||||||
|
lines.push('<121 \\Block\\', 'MNM="Bearbeitungen von unten "', `DP="${backSpecs.length}"`, 'KO="04"');
|
||||||
|
for (const sp of backSpecs) {
|
||||||
|
lines.push(...bohrVertLines(sp, thicknessMm));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocs de perçage sur chant — un bloc par côté d'entrée.
|
||||||
|
const edgeBlocks = [
|
||||||
|
['left', 'Bearbeitungen von links '],
|
||||||
|
['right', 'Bearbeitungen von rechts '],
|
||||||
|
['bottom', 'Bearbeitungen von vorne '],
|
||||||
|
['top', 'Bearbeitungen von hinten ']
|
||||||
|
];
|
||||||
|
for (const [end, label] of edgeBlocks) {
|
||||||
|
const group = edgeSpecs.filter((sp) => (sp.end || 'left') === end);
|
||||||
|
if (!group.length) continue;
|
||||||
|
lines.push('<121 \\Block\\', `MNM="${label}"`, `DP="${group.length}"`, 'KO="04"');
|
||||||
|
for (const sp of group) lines.push(...bohrHorizLines(sp, L, B, D));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(...miter.opLines);
|
||||||
lines.push(...grooveSection(piece, L, B));
|
lines.push(...grooveSection(piece, L, B));
|
||||||
|
lines.push(...doorHandleGrooveSection(piece, L, B));
|
||||||
lines.push('!');
|
lines.push('!');
|
||||||
return lines.join('\r\n');
|
return lines.join('\r\n');
|
||||||
}
|
}
|
||||||
@@ -407,10 +692,12 @@ function buildMprFileRecords(entries, mprOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function packMprRecords(records, zipBaseName) {
|
async function packMprRecords(records, zipBaseName) {
|
||||||
|
// Encodage Windows-1252 (Latin-1) : format attendu par woodWOP pour les
|
||||||
|
// commentaires KM allemands (ä ö ü ß °), comme dans les fichiers fabricant.
|
||||||
if (records.length === 1) {
|
if (records.length === 1) {
|
||||||
const only = records[0];
|
const only = records[0];
|
||||||
return {
|
return {
|
||||||
buffer: Buffer.from(only.content, 'utf8'),
|
buffer: Buffer.from(only.content, 'latin1'),
|
||||||
fileName: only.fileName,
|
fileName: only.fileName,
|
||||||
mimeType: 'application/octet-stream',
|
mimeType: 'application/octet-stream',
|
||||||
fileCount: 1
|
fileCount: 1
|
||||||
@@ -419,7 +706,7 @@ async function packMprRecords(records, zipBaseName) {
|
|||||||
|
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
for (const rec of records) {
|
for (const rec of records) {
|
||||||
zip.file(rec.fileName, rec.content);
|
zip.file(rec.fileName, Buffer.from(rec.content, 'latin1'));
|
||||||
}
|
}
|
||||||
const safeBase = safeZipFileName(zipBaseName || 'piece-mpr');
|
const safeBase = safeZipFileName(zipBaseName || 'piece-mpr');
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
import {
|
import {
|
||||||
PANEL, GROOVE, SYSTEM32, SYSTEM32_SEPARATORS, HINGE, CONNECTORS,
|
PANEL, GROOVE, SYSTEM32, SYSTEM32_SEPARATORS, HINGE, CONNECTORS,
|
||||||
getHingeCountForDoor, getHingeCupPositions, getDoorHingeSide, HANDLE,
|
getHingeCountForDoor, getHingeCupPositions, getDoorHingeSide, HANDLE,
|
||||||
EDGE_BANDING, REINFORCEMENT, REINFORCEMENT_MACHINING, PLINTH, SHELF_MACHINING
|
EDGE_BANDING, REINFORCEMENT, REINFORCEMENT_MACHINING, PLINTH, SHELF_MACHINING,
|
||||||
|
resolveHorizontalSpan, validateHorizontalSpans, getSeparatorVerticalSegments,
|
||||||
|
hasSideHabillage, hasSidePlinthReturn, getAxiloPlinthReturnLengthMm,
|
||||||
|
getAxiloPlinthFrontSideDeductionMm
|
||||||
} from '../../shared/manufacturing.js';
|
} from '../../shared/manufacturing.js';
|
||||||
|
|
||||||
// Constants for drawing
|
// Constants for drawing
|
||||||
@@ -36,6 +39,8 @@ const SHARED_STYLES = `
|
|||||||
.renfort { fill: #c9a96e; stroke: #333; stroke-width: 0.5; opacity: 0.7; }
|
.renfort { fill: #c9a96e; stroke: #333; stroke-width: 0.5; opacity: 0.7; }
|
||||||
.detail-label { font-family: Arial, sans-serif; font-size: 7px; fill: #666; }
|
.detail-label { font-family: Arial, sans-serif; font-size: 7px; fill: #666; }
|
||||||
.fh-groove { fill: none; stroke: #0d9488; stroke-width: 1; stroke-dasharray: 3, 2; }
|
.fh-groove { fill: none; stroke: #0d9488; stroke-width: 1; stroke-dasharray: 3, 2; }
|
||||||
|
.horizontal-span { fill: none; stroke: #b45309; stroke-width: 2; }
|
||||||
|
.horizontal-span-label { font-family: Arial, sans-serif; font-size: 8px; font-weight: bold; fill: #92400e; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
@@ -140,6 +145,19 @@ function generateDimensionLine(x, y, length, label, direction = 'horizontal') {
|
|||||||
export function generateAssemblyPlan(config, modules = []) {
|
export function generateAssemblyPlan(config, modules = []) {
|
||||||
const { widthMm, heightMm, plinthHeightMm, doorType } = config;
|
const { widthMm, heightMm, plinthHeightMm, doorType } = config;
|
||||||
const bodyHeight = heightMm - plinthHeightMm;
|
const bodyHeight = heightMm - plinthHeightMm;
|
||||||
|
const planModules = modules.length > 0 ? modules : (Array.isArray(config.modules) ? config.modules : []);
|
||||||
|
const spanConfig = { ...config, modules: planModules };
|
||||||
|
const spanIssues =
|
||||||
|
Array.isArray(config.horizontalSpans) && config.horizontalSpans.length
|
||||||
|
? validateHorizontalSpans(spanConfig)
|
||||||
|
: [];
|
||||||
|
const invalidSpanIds = new Set(
|
||||||
|
spanIssues.map((issue) => issue.spanId).filter((id) => id != null).map(String)
|
||||||
|
);
|
||||||
|
const resolvedSpans = (Array.isArray(config.horizontalSpans) ? config.horizontalSpans : [])
|
||||||
|
.filter((span) => span?.id != null && !invalidSpanIds.has(String(span.id)))
|
||||||
|
.map((span) => resolveHorizontalSpan(span, spanConfig))
|
||||||
|
.filter(Boolean);
|
||||||
const S = DRAWING_SCALE;
|
const S = DRAWING_SCALE;
|
||||||
|
|
||||||
const svgWidth = widthMm * S + MARGIN * 2;
|
const svgWidth = widthMm * S + MARGIN * 2;
|
||||||
@@ -154,19 +172,49 @@ export function generateAssemblyPlan(config, modules = []) {
|
|||||||
const oX = MARGIN;
|
const oX = MARGIN;
|
||||||
const oY = MARGIN;
|
const oY = MARGIN;
|
||||||
|
|
||||||
// Plinth
|
const habLeft = hasSideHabillage(config, 'left');
|
||||||
|
const habRight = hasSideHabillage(config, 'right');
|
||||||
|
const returnLeft = hasSidePlinthReturn(config, 'left');
|
||||||
|
const returnRight = hasSidePlinthReturn(config, 'right');
|
||||||
|
const habT = PANEL.structure * S;
|
||||||
|
const isNiche = config?.installationType === 'niche';
|
||||||
|
const leftDeductMm = isNiche ? 0 : getAxiloPlinthFrontSideDeductionMm(config, 'left');
|
||||||
|
const rightDeductMm = isNiche ? 0 : getAxiloPlinthFrontSideDeductionMm(config, 'right');
|
||||||
|
const sideSetbackMm = PLINTH.frontSetbackMm;
|
||||||
|
|
||||||
|
// Plinth — shortened where side cladding or a recessed plinth return runs to the floor
|
||||||
const plinthY = oY + bodyHeight * S;
|
const plinthY = oY + bodyHeight * S;
|
||||||
svg += `<rect x="${oX}" y="${plinthY}" width="${widthMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
|
const plinthX = oX + leftDeductMm * S;
|
||||||
|
const plinthW = (widthMm - leftDeductMm - rightDeductMm) * S;
|
||||||
|
svg += `<rect x="${plinthX}" y="${plinthY}" width="${plinthW}" height="${plinthHeightMm * S}" class="plinth" />`;
|
||||||
|
|
||||||
// Body
|
// Body
|
||||||
svg += `<rect x="${oX}" y="${oY}" width="${widthMm * S}" height="${bodyHeight * S}" class="body" />`;
|
svg += `<rect x="${oX}" y="${oY}" width="${widthMm * S}" height="${bodyHeight * S}" class="body" />`;
|
||||||
|
if (habLeft) {
|
||||||
|
svg += `<rect x="${oX}" y="${oY}" width="${habT}" height="${heightMm * S}" class="body" />`;
|
||||||
|
} else if (returnLeft) {
|
||||||
|
svg += `<rect x="${oX + sideSetbackMm * S}" y="${plinthY}" width="${habT}" height="${plinthHeightMm * S}" class="plinth" />`;
|
||||||
|
}
|
||||||
|
if (habRight) {
|
||||||
|
svg += `<rect x="${oX + widthMm * S - habT}" y="${oY}" width="${habT}" height="${heightMm * S}" class="body" />`;
|
||||||
|
} else if (returnRight) {
|
||||||
|
svg += `<rect x="${oX + widthMm * S - (sideSetbackMm + PANEL.structure) * S}" y="${plinthY}" width="${habT}" height="${plinthHeightMm * S}" class="plinth" />`;
|
||||||
|
}
|
||||||
|
|
||||||
// Module separators
|
// Module separators
|
||||||
if (modules.length > 0) {
|
if (planModules.length > 0) {
|
||||||
let currentX = oX;
|
let currentX = oX;
|
||||||
for (let i = 0; i < modules.length - 1; i++) {
|
for (let i = 0; i < planModules.length - 1; i++) {
|
||||||
currentX += modules[i].widthMm * S;
|
currentX += planModules[i].widthMm * S;
|
||||||
|
if (resolvedSpans.length === 0) {
|
||||||
svg += `<line x1="${currentX}" y1="${oY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
|
svg += `<line x1="${currentX}" y1="${oY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
|
||||||
|
} else {
|
||||||
|
for (const segment of getSeparatorVerticalSegments(spanConfig, i)) {
|
||||||
|
const y1 = plinthY - segment.topMm * S;
|
||||||
|
const y2 = plinthY - segment.bottomMm * S;
|
||||||
|
svg += `<line x1="${currentX}" y1="${y1}" x2="${currentX}" y2="${y2}" class="separator" />`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,9 +230,9 @@ export function generateAssemblyPlan(config, modules = []) {
|
|||||||
svg += `<rect x="${oX + i * panelWidth * 0.95 + 5}" y="${oY + 5}" width="${panelWidth * 0.9}" height="${(bodyHeight - 10) * S}" class="door" />`;
|
svg += `<rect x="${oX + i * panelWidth * 0.95 + 5}" y="${oY + 5}" width="${panelWidth * 0.9}" height="${(bodyHeight - 10) * S}" class="door" />`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const blockCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
|
const blockCount = planModules.length > 0 ? planModules.length : (config.blocks?.length || 2);
|
||||||
let currentX = oX;
|
let currentX = oX;
|
||||||
const items = modules.length > 0 ? modules : (config.blocks || []);
|
const items = planModules.length > 0 ? planModules : (config.blocks || []);
|
||||||
const hw = config.hardware || {};
|
const hw = config.hardware || {};
|
||||||
const fullHeightHandle = hw.handleType === 'full_height';
|
const fullHeightHandle = hw.handleType === 'full_height';
|
||||||
const widthDeductionMm = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0;
|
const widthDeductionMm = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0;
|
||||||
@@ -193,24 +241,116 @@ export function generateAssemblyPlan(config, modules = []) {
|
|||||||
|
|
||||||
for (let i = 0; i < blockCount; i++) {
|
for (let i = 0; i < blockCount; i++) {
|
||||||
const bw = items[i]?.widthMm || (widthMm / blockCount);
|
const bw = items[i]?.widthMm || (widthMm / blockCount);
|
||||||
const doorWmm = Math.max(100, Math.floor(widthMm / blockCount) - 4 - widthDeductionMm);
|
|
||||||
const modulePx = bw * S;
|
const modulePx = bw * S;
|
||||||
|
const doorWmm = Math.max(
|
||||||
|
100,
|
||||||
|
Math.floor(resolvedSpans.length === 0 ? widthMm / blockCount : bw) -
|
||||||
|
4 -
|
||||||
|
widthDeductionMm
|
||||||
|
);
|
||||||
const doorWPx = doorWmm * S;
|
const doorWPx = doorWmm * S;
|
||||||
const doorX = currentX + (modulePx - doorWPx) / 2;
|
const doorX = currentX + (modulePx - doorWPx) / 2;
|
||||||
const doorY = oY + gapMm * S;
|
|
||||||
|
|
||||||
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWPx}" height="${doorHmm * S}" class="door" />`;
|
|
||||||
|
|
||||||
const hingeSide = getDoorHingeSide(i, blockCount, hw);
|
const hingeSide = getDoorHingeSide(i, blockCount, hw);
|
||||||
|
|
||||||
|
if (resolvedSpans.length === 0 || planModules.length === 0) {
|
||||||
|
const doorY = oY + gapMm * S;
|
||||||
|
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWPx}" height="${doorHmm * S}" class="door" />`;
|
||||||
svg += drawHingeMarkers(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
|
svg += drawHingeMarkers(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
|
||||||
if (fullHeightHandle) {
|
if (fullHeightHandle) {
|
||||||
svg += drawFullHeightHandleGrooveElevation(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
|
svg += drawFullHeightHandleGrooveElevation(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const module = planModules[i] || {};
|
||||||
|
let heights = Array.isArray(module.blockHeightsMm)
|
||||||
|
? module.blockHeightsMm.map(Number).filter((height) => Number.isFinite(height) && height > 0)
|
||||||
|
: [];
|
||||||
|
if (!heights.length) {
|
||||||
|
const stacked = module.stacked === true ||
|
||||||
|
(module.stacked == null && config.stackEnabled === true);
|
||||||
|
const bottom = Number(module.stackBottomHeightMm ?? config.stackBottomHeightMm);
|
||||||
|
heights = stacked && bottom > 0 && bottom < bodyHeight
|
||||||
|
? [bottom, bodyHeight - bottom]
|
||||||
|
: [bodyHeight];
|
||||||
|
}
|
||||||
|
const blockDoors = Array.isArray(module.blockDoors) ? module.blockDoors : null;
|
||||||
|
let bottomMm = 0;
|
||||||
|
heights.forEach((height, blockIndex) => {
|
||||||
|
const topMm = bottomMm + height;
|
||||||
|
const covered = resolvedSpans.some((span) =>
|
||||||
|
span.moduleIndexes.includes(i) &&
|
||||||
|
bottomMm >= span.bottomMm - 0.5 &&
|
||||||
|
topMm <= span.topMm + 0.5
|
||||||
|
);
|
||||||
|
const hasDoor =
|
||||||
|
blockDoors && typeof blockDoors[blockIndex] === 'boolean'
|
||||||
|
? blockDoors[blockIndex]
|
||||||
|
: true;
|
||||||
|
if (hasDoor && !covered) {
|
||||||
|
const segmentDoorHeightMm = Math.max(80, height - 4);
|
||||||
|
const doorY = plinthY - topMm * S + gapMm * S;
|
||||||
|
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWPx}" height="${segmentDoorHeightMm * S}" class="door" />`;
|
||||||
|
svg += drawHingeMarkers(
|
||||||
|
doorX, doorY, doorWmm, segmentDoorHeightMm, S, hingeSide
|
||||||
|
);
|
||||||
|
if (fullHeightHandle) {
|
||||||
|
svg += drawFullHeightHandleGrooveElevation(
|
||||||
|
doorX, doorY, doorWmm, segmentDoorHeightMm, S, hingeSide
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bottomMm = topMm;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
currentX += modulePx;
|
currentX += modulePx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const span of resolvedSpans) {
|
||||||
|
const x1 = oX + span.leftMm * S;
|
||||||
|
const x2 = oX + span.rightMm * S;
|
||||||
|
const yBottom = plinthY - span.bottomMm * S;
|
||||||
|
const yTop = plinthY - span.topMm * S;
|
||||||
|
svg += `<line x1="${x1}" y1="${yBottom}" x2="${x2}" y2="${yBottom}" class="horizontal-span" />`;
|
||||||
|
svg += `<line x1="${x1}" y1="${yTop}" x2="${x2}" y2="${yTop}" class="horizontal-span" />`;
|
||||||
|
svg += `<text x="${(x1 + x2) / 2}" y="${(yBottom + yTop) / 2}" class="horizontal-span-label" text-anchor="middle">Portée horizontale — colonnes ${span.moduleIndexes.map((index) => index + 1).join('–')}</text>`;
|
||||||
|
|
||||||
|
const doorMode = span.span?.doorMode || 'none';
|
||||||
|
if (doorType !== 'sliding' && doorMode !== 'none') {
|
||||||
|
const hw = config.hardware || {};
|
||||||
|
const fullHeightHandle = hw.handleType === 'full_height';
|
||||||
|
const widthDeductionMm = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0;
|
||||||
|
const doorHeightMm = Math.max(120, span.topMm - span.bottomMm - 4);
|
||||||
|
const doorY = yTop + 2 * S;
|
||||||
|
const defs = doorMode === 'single'
|
||||||
|
? [{ leftMm: span.leftMm, widthMm: span.widthMm }]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
leftMm: span.leftMm,
|
||||||
|
widthMm: span.widthMm / 2,
|
||||||
|
hingeSide: 'left'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
leftMm: span.leftMm + span.widthMm / 2,
|
||||||
|
widthMm: span.widthMm / 2,
|
||||||
|
hingeSide: 'right'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
defs.forEach((door, doorIndex) => {
|
||||||
|
const doorWidthMm = Math.max(100, Math.floor(door.widthMm) - 4 - widthDeductionMm);
|
||||||
|
const doorX = oX + door.leftMm * S + (door.widthMm - doorWidthMm) * S / 2;
|
||||||
|
const hingeSide = door.hingeSide || getDoorHingeSide(doorIndex, defs.length, hw);
|
||||||
|
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWidthMm * S}" height="${doorHeightMm * S}" class="door" data-horizontal-span-door="${doorMode}" data-hinge-side="${hingeSide}" />`;
|
||||||
|
svg += drawHingeMarkers(doorX, doorY, doorWidthMm, doorHeightMm, S, hingeSide);
|
||||||
|
if (fullHeightHandle) {
|
||||||
|
svg += drawFullHeightHandleGrooveElevation(
|
||||||
|
doorX, doorY, doorWidthMm, doorHeightMm, S, hingeSide
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reinforcements (Forteresse 16 mm):
|
// Reinforcements (Forteresse 16 mm):
|
||||||
// width = armoire width - 2×19 (entre côtés),
|
// width = armoire width - 2×19 (entre côtés),
|
||||||
// one at top + one at bottom (offset 19 mm),
|
// one at top + one at bottom (offset 19 mm),
|
||||||
@@ -257,12 +397,23 @@ export function generateSidePlan(config) {
|
|||||||
const oX = MARGIN;
|
const oX = MARGIN;
|
||||||
const oY = MARGIN;
|
const oY = MARGIN;
|
||||||
|
|
||||||
// Plinth
|
const habLeft = hasSideHabillage(config, 'left');
|
||||||
const plinthY = oY + bodyHeight * S;
|
const returnLeft = hasSidePlinthReturn(config, 'left');
|
||||||
svg += `<rect x="${oX}" y="${plinthY}" width="${depthMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
|
|
||||||
|
|
||||||
// Body
|
const plinthY = oY + bodyHeight * S;
|
||||||
|
const plinthSetbackMm = PLINTH.frontSetbackMm;
|
||||||
|
if (habLeft) {
|
||||||
|
// Habillage gauche jusqu'au sol : pas de plinthe visible de ce côté.
|
||||||
|
svg += `<rect x="${oX}" y="${oY}" width="${depthMm * S}" height="${heightMm * S}" class="body" />`;
|
||||||
|
} else {
|
||||||
|
if (returnLeft) {
|
||||||
|
const returnLenMm = getAxiloPlinthReturnLengthMm(depthMm);
|
||||||
|
svg += `<rect x="${oX + plinthSetbackMm * S}" y="${plinthY}" width="${returnLenMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
|
||||||
|
} else {
|
||||||
|
svg += `<rect x="${oX + plinthSetbackMm * S}" y="${plinthY}" width="${PLINTH.panelThicknessMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
|
||||||
|
}
|
||||||
svg += `<rect x="${oX}" y="${oY}" width="${depthMm * S}" height="${bodyHeight * S}" class="body" />`;
|
svg += `<rect x="${oX}" y="${oY}" width="${depthMm * S}" height="${bodyHeight * S}" class="body" />`;
|
||||||
|
}
|
||||||
|
|
||||||
// Back panel (8mm) shown as a thin rectangle at the back
|
// Back panel (8mm) shown as a thin rectangle at the back
|
||||||
const backX = oX + (depthMm - PANEL.back) * S;
|
const backX = oX + (depthMm - PANEL.back) * S;
|
||||||
|
|||||||
+374
-20
@@ -4,13 +4,50 @@
|
|||||||
* Specs APEX 4.0 importées de shared/manufacturing.js
|
* Specs APEX 4.0 importées de shared/manufacturing.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PANEL, DRAWERS, NICHE, getHingeCountForDoor } from '../../shared/manufacturing.js';
|
import {
|
||||||
|
PANEL,
|
||||||
|
DRAWERS,
|
||||||
|
NICHE,
|
||||||
|
HANDLE,
|
||||||
|
PLINTH,
|
||||||
|
getAxiloPlinthPanelHeightMm,
|
||||||
|
hasSidePlinthReturn,
|
||||||
|
getAxiloPlinthReturnLengthMm,
|
||||||
|
getHingeCountForDoor,
|
||||||
|
resolveHorizontalSpan,
|
||||||
|
validateHorizontalSpans,
|
||||||
|
usesBlockConstruction,
|
||||||
|
MAX_PANEL_DIMENSION_MM
|
||||||
|
} from '../../shared/manufacturing.js';
|
||||||
|
|
||||||
function numberOr(value, fallback) {
|
function numberOr(value, fallback) {
|
||||||
const n = typeof value === 'number' ? value : Number(value);
|
const n = typeof value === 'number' ? value : Number(value);
|
||||||
return Number.isFinite(n) ? n : fallback;
|
return Number.isFinite(n) ? n : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvedHorizontalSpansForConfig(config) {
|
||||||
|
if (!Array.isArray(config?.horizontalSpans) || config.horizontalSpans.length === 0) return [];
|
||||||
|
const invalidIds = new Set(
|
||||||
|
validateHorizontalSpans(config)
|
||||||
|
.map((issue) => issue?.spanId)
|
||||||
|
.filter((id) => id != null)
|
||||||
|
.map(String)
|
||||||
|
);
|
||||||
|
return config.horizontalSpans
|
||||||
|
.filter((span) => span?.id != null && !invalidIds.has(String(span.id)))
|
||||||
|
.map((span) => resolveHorizontalSpan(span, config))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spanCoversBlock(resolvedSpans, moduleIndex, bottomMm, topMm) {
|
||||||
|
const eps = 0.5;
|
||||||
|
return resolvedSpans.some((span) =>
|
||||||
|
span.moduleIndexes.includes(moduleIndex) &&
|
||||||
|
bottomMm >= span.bottomMm - eps &&
|
||||||
|
topMm <= span.topMm + eps
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcule la surface utile des éléments visibles (portes, fileurs, bandeau, plinthe, côtés doublage).
|
* Calcule la surface utile des éléments visibles (portes, fileurs, bandeau, plinthe, côtés doublage).
|
||||||
* Épaisseur panneaux 19 mm (référence APEX 4.0).
|
* Épaisseur panneaux 19 mm (référence APEX 4.0).
|
||||||
@@ -26,14 +63,70 @@ function calculateVisibleSurface(config, panelThicknessMm = PANEL.structure) {
|
|||||||
if (doorType === 'sliding' && slidingPanelsCount) {
|
if (doorType === 'sliding' && slidingPanelsCount) {
|
||||||
const panelWidth = widthMm / slidingPanelsCount;
|
const panelWidth = widthMm / slidingPanelsCount;
|
||||||
surface += slidingPanelsCount * (panelWidth * (heightMm - plinthHeightMm)) * 2; // deux faces par vantail
|
surface += slidingPanelsCount * (panelWidth * (heightMm - plinthHeightMm)) * 2; // deux faces par vantail
|
||||||
|
} else if (Array.isArray(config.modules) && config.modules.length > 0) {
|
||||||
|
// 1 porte par block AVEC porte (mod.blockDoors); block sans porte = niche ouverte, pas de façade.
|
||||||
|
const bodyMm = heightMm - plinthHeightMm;
|
||||||
|
const resolvedSpans = resolvedHorizontalSpansForConfig(config);
|
||||||
|
for (let moduleIndex = 0; moduleIndex < config.modules.length; moduleIndex++) {
|
||||||
|
const m = config.modules[moduleIndex];
|
||||||
|
const colW = Number(m?.widthMm) || widthMm / config.modules.length;
|
||||||
|
const arr = Array.isArray(m?.blockHeightsMm)
|
||||||
|
? m.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0)
|
||||||
|
: null;
|
||||||
|
let heights;
|
||||||
|
if (arr && arr.length > 0) {
|
||||||
|
heights = arr;
|
||||||
|
} else {
|
||||||
|
const stacked = typeof m?.stacked === 'boolean' ? m.stacked : config.stackEnabled === true;
|
||||||
|
heights = stacked && bodyMm >= 600
|
||||||
|
? [Number(m?.stackBottomHeightMm ?? config.stackBottomHeightMm) || 1000]
|
||||||
|
.flatMap((b) => [b, bodyMm - b])
|
||||||
|
: [bodyMm];
|
||||||
|
}
|
||||||
|
const doors = Array.isArray(m?.blockDoors) ? m.blockDoors : null;
|
||||||
|
let blockBottomMm = 0;
|
||||||
|
heights.forEach((h, k) => {
|
||||||
|
const blockTopMm = blockBottomMm + h;
|
||||||
|
const covered = spanCoversBlock(resolvedSpans, moduleIndex, blockBottomMm, blockTopMm);
|
||||||
|
blockBottomMm = blockTopMm;
|
||||||
|
const hasDoor = doors && typeof doors[k] === 'boolean' ? doors[k] : true;
|
||||||
|
if (hasDoor && !covered) surface += colW * h * 2; // deux faces
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const fullHeightDeduction =
|
||||||
|
config.hardware?.handleType === 'full_height' ? HANDLE.fullHeightWidthDeductionMm : 0;
|
||||||
|
for (const span of resolvedSpans) {
|
||||||
|
const doorMode = span.span?.doorMode || 'none';
|
||||||
|
if (doorMode === 'none') continue;
|
||||||
|
const doorHeightMm = Math.max(120, span.topMm - span.bottomMm - 4);
|
||||||
|
if (doorMode === 'single') {
|
||||||
|
const doorWidthMm = Math.max(100, span.widthMm - 4 - fullHeightDeduction);
|
||||||
|
surface += doorWidthMm * doorHeightMm * 2;
|
||||||
|
} else if (doorMode === 'perColumn') {
|
||||||
|
for (let leafIndex = 0; leafIndex < 2; leafIndex++) {
|
||||||
|
const doorWidthMm = Math.max(
|
||||||
|
100,
|
||||||
|
Math.floor(span.widthMm / 2) - 4 - fullHeightDeduction
|
||||||
|
);
|
||||||
|
surface += doorWidthMm * doorHeightMm * 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const doorsCount = blocks.length <= 1 ? Math.min(2, Math.ceil((blocks[0]?.widthMm || widthMm) / 600)) : blocks.length * 2;
|
const doorsCount = blocks.length <= 1 ? Math.min(2, Math.ceil((blocks[0]?.widthMm || widthMm) / 600)) : blocks.length * 2;
|
||||||
const doorWidth = widthMm / Math.max(1, doorsCount);
|
const doorWidth = widthMm / Math.max(1, doorsCount);
|
||||||
surface += doorsCount * (doorWidth * (heightMm - plinthHeightMm)) * 2;
|
surface += doorsCount * (doorWidth * (heightMm - plinthHeightMm)) * 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plinthe (face avant)
|
// Plinthe (face avant + retours latéraux s'il n'y a pas d'habillage)
|
||||||
surface += widthMm * plinthHeightMm;
|
const plinthH = getAxiloPlinthPanelHeightMm(plinthHeightMm);
|
||||||
|
surface += widthMm * plinthH;
|
||||||
|
if (hasSidePlinthReturn(config, 'left')) {
|
||||||
|
surface += getAxiloPlinthReturnLengthMm(depthMm) * plinthH;
|
||||||
|
}
|
||||||
|
if (hasSidePlinthReturn(config, 'right')) {
|
||||||
|
surface += getAxiloPlinthReturnLengthMm(depthMm) * plinthH;
|
||||||
|
}
|
||||||
|
|
||||||
// Bandeau et fileurs si niche
|
// Bandeau et fileurs si niche
|
||||||
if (isNiche) {
|
if (isNiche) {
|
||||||
@@ -188,18 +281,69 @@ function calculateModuleCosts(modules, moduleCatalog) {
|
|||||||
* @param {Object} config - Config with door and drawer counts
|
* @param {Object} config - Config with door and drawer counts
|
||||||
* @returns {Object} - Hardware costs breakdown
|
* @returns {Object} - Hardware costs breakdown
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Hauteurs (mm) des portes battantes: 1 porte par block AVEC porte (mod.blockDoors),
|
||||||
|
* par colonne. Colonne empilée sans flags portes → 1 porte par block.
|
||||||
|
*/
|
||||||
|
function hingedDoorHeightsMm(config) {
|
||||||
|
const bodyMm = (Number(config.heightMm) || 2400) - (Number(config.plinthHeightMm) || 100);
|
||||||
|
const mods = Array.isArray(config.modules) && config.modules.length > 0 ? config.modules : null;
|
||||||
|
const cols = mods ?? Array.from({ length: config.blocks?.length || 2 }, () => null);
|
||||||
|
const resolvedSpans = mods ? resolvedHorizontalSpansForConfig(config) : [];
|
||||||
|
const out = [];
|
||||||
|
for (let moduleIndex = 0; moduleIndex < cols.length; moduleIndex++) {
|
||||||
|
const m = cols[moduleIndex];
|
||||||
|
const arr = Array.isArray(m?.blockHeightsMm)
|
||||||
|
? m.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0)
|
||||||
|
: null;
|
||||||
|
let heights;
|
||||||
|
if (arr && arr.length > 0) {
|
||||||
|
heights = arr;
|
||||||
|
} else {
|
||||||
|
const stacked = typeof m?.stacked === 'boolean' ? m.stacked : config.stackEnabled === true;
|
||||||
|
if (stacked && bodyMm >= 600) {
|
||||||
|
const bottom = Number(m?.stackBottomHeightMm ?? config.stackBottomHeightMm) || 1000;
|
||||||
|
heights = [bottom, bodyMm - bottom];
|
||||||
|
} else {
|
||||||
|
heights = [bodyMm];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const doors = Array.isArray(m?.blockDoors) ? m.blockDoors : null;
|
||||||
|
let blockBottomMm = 0;
|
||||||
|
heights.forEach((h, k) => {
|
||||||
|
const blockTopMm = blockBottomMm + h;
|
||||||
|
const covered = spanCoversBlock(resolvedSpans, moduleIndex, blockBottomMm, blockTopMm);
|
||||||
|
blockBottomMm = blockTopMm;
|
||||||
|
const hasDoor = doors && typeof doors[k] === 'boolean' ? doors[k] : true;
|
||||||
|
if (hasDoor && !covered) out.push(Math.max(80, h - 4));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const span of resolvedSpans) {
|
||||||
|
const doorMode = span.span?.doorMode || 'none';
|
||||||
|
const count =
|
||||||
|
doorMode === 'single' ? 1 :
|
||||||
|
doorMode === 'perColumn' ? 2 : 0;
|
||||||
|
const heightMm = Math.max(120, span.topMm - span.bottomMm - 4);
|
||||||
|
for (let index = 0; index < count; index++) out.push(heightMm);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function calculateHardwareCosts(hardware, hardwareData, config) {
|
function calculateHardwareCosts(hardware, hardwareData, config) {
|
||||||
const { handles = [], hinges = [], blum = [] } = hardwareData;
|
const { handles = [], hinges = [], blum = [] } = hardwareData;
|
||||||
const breakdown = [];
|
const breakdown = [];
|
||||||
let totalChf = 0;
|
let totalChf = 0;
|
||||||
|
|
||||||
// Handle costs - one per door
|
const hingedDoorHeights = config.doorType === 'sliding' ? [] : hingedDoorHeightsMm(config);
|
||||||
|
|
||||||
|
// Handle costs - one per door (blocks sans porte exclus)
|
||||||
if (hardware.handleVariantId) {
|
if (hardware.handleVariantId) {
|
||||||
const handle = handles.find(h => h.id === hardware.handleVariantId);
|
const handle = handles.find(h => h.id === hardware.handleVariantId);
|
||||||
if (handle) {
|
if (handle) {
|
||||||
const doorCount = config.doorType === 'sliding'
|
const doorCount = config.doorType === 'sliding'
|
||||||
? (config.slidingPanelsCount || 2)
|
? (config.slidingPanelsCount || 2)
|
||||||
: (config.modules?.length || config.blocks?.length || 2);
|
: hingedDoorHeights.length;
|
||||||
|
if (doorCount > 0) {
|
||||||
const cost = handle.price_chf * doorCount;
|
const cost = handle.price_chf * doorCount;
|
||||||
totalChf += cost;
|
totalChf += cost;
|
||||||
breakdown.push({
|
breakdown.push({
|
||||||
@@ -211,15 +355,17 @@ function calculateHardwareCosts(hardware, hardwareData, config) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Hinge costs - dynamic quantity from APEX 4.0 matrix
|
// Hinge costs - dynamic quantity from APEX 4.0 matrix, par porte (hauteur du block)
|
||||||
if (hardware.hingeVariantId && config.doorType !== 'sliding') {
|
if (hardware.hingeVariantId && config.doorType !== 'sliding') {
|
||||||
const hinge = hinges.find(h => h.id === hardware.hingeVariantId);
|
const hinge = hinges.find(h => h.id === hardware.hingeVariantId);
|
||||||
if (hinge) {
|
if (hinge) {
|
||||||
const doorCount = config.modules?.length || config.blocks?.length || 2;
|
const quantity = hingedDoorHeights.reduce(
|
||||||
const doorHeightMm = (config.heightMm || 2400) - (config.plinthHeightMm || 100) - 4;
|
(sum, doorHeightMm) => sum + getHingeCountForDoor({ heightMm: doorHeightMm }),
|
||||||
const hingesPerDoor = getHingeCountForDoor({ heightMm: doorHeightMm });
|
0
|
||||||
const quantity = doorCount * hingesPerDoor;
|
);
|
||||||
|
if (quantity > 0) {
|
||||||
const cost = hinge.price_chf * quantity;
|
const cost = hinge.price_chf * quantity;
|
||||||
totalChf += cost;
|
totalChf += cost;
|
||||||
breakdown.push({
|
breakdown.push({
|
||||||
@@ -231,6 +377,7 @@ function calculateHardwareCosts(hardware, hardwareData, config) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Drawer system costs - one per drawer
|
// Drawer system costs - one per drawer
|
||||||
if (hardware.drawerSystemId) {
|
if (hardware.drawerSystemId) {
|
||||||
@@ -265,6 +412,24 @@ function calculateHardwareCosts(hardware, hardwareData, config) {
|
|||||||
*/
|
*/
|
||||||
export function calculatePrice(config, data) {
|
export function calculatePrice(config, data) {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
const horizontalSpanIssues =
|
||||||
|
Array.isArray(config?.horizontalSpans) && config.horizontalSpans.length
|
||||||
|
? validateHorizontalSpans(config)
|
||||||
|
: [];
|
||||||
|
horizontalSpanIssues.forEach((issue) => errors.push({
|
||||||
|
field: issue.field,
|
||||||
|
message: issue.message
|
||||||
|
}));
|
||||||
|
const invalidHorizontalSpanIds = new Set(
|
||||||
|
horizontalSpanIssues
|
||||||
|
.map((issue) => issue.spanId)
|
||||||
|
.filter((id) => id != null)
|
||||||
|
.map(String)
|
||||||
|
);
|
||||||
|
const resolvedHorizontalSpans = (Array.isArray(config?.horizontalSpans) ? config.horizontalSpans : [])
|
||||||
|
.filter((span) => span?.id != null && !invalidHorizontalSpanIds.has(String(span.id)))
|
||||||
|
.map((span) => resolveHorizontalSpan(span, config))
|
||||||
|
.filter(Boolean);
|
||||||
const settingsArr = data.settings || [];
|
const settingsArr = data.settings || [];
|
||||||
const settings = {};
|
const settings = {};
|
||||||
for (const r of settingsArr) {
|
for (const r of settingsArr) {
|
||||||
@@ -274,7 +439,7 @@ export function calculatePrice(config, data) {
|
|||||||
}
|
}
|
||||||
const kitFeeChf = numberOr(settings.kit_preparation_fee_chf, 350);
|
const kitFeeChf = numberOr(settings.kit_preparation_fee_chf, 350);
|
||||||
const plinthMin = numberOr(settings.plinth_height_min_mm, 60);
|
const plinthMin = numberOr(settings.plinth_height_min_mm, 60);
|
||||||
const plinthMax = numberOr(settings.plinth_height_max_mm, 160);
|
const plinthMax = numberOr(settings.plinth_height_max_mm, 200);
|
||||||
const slidingMinWidth = numberOr(settings.sliding_doors_min_width_mm, 1200);
|
const slidingMinWidth = numberOr(settings.sliding_doors_min_width_mm, 1200);
|
||||||
const allowSlidingDoors = String(settings.allow_sliding_doors ?? '0') === '1';
|
const allowSlidingDoors = String(settings.allow_sliding_doors ?? '0') === '1';
|
||||||
const standardHandleAxisMm = numberOr(settings.standard_handle_axis_mm, 1000);
|
const standardHandleAxisMm = numberOr(settings.standard_handle_axis_mm, 1000);
|
||||||
@@ -287,14 +452,154 @@ export function calculatePrice(config, data) {
|
|||||||
doorType, slidingPanelsCount, finishId, serviceOptionIds = [],
|
doorType, slidingPanelsCount, finishId, serviceOptionIds = [],
|
||||||
columnMode: columnModeRaw
|
columnMode: columnModeRaw
|
||||||
} = config;
|
} = config;
|
||||||
const columnMode = columnModeRaw === 'block' ? 'block' : 'separations';
|
// Règle: dos → caisson block ; sinon côté ou rayonnage → block ; sinon séparations.
|
||||||
|
// Legacy: `mod.hasBack` absent → `config.hasBack` global; les deux absents → columnMode envoyé.
|
||||||
|
const legacyBackDefault = typeof config.hasBack === 'boolean'
|
||||||
|
? config.hasBack
|
||||||
|
: columnModeRaw === 'block';
|
||||||
|
// Empilage vertical PAR COLONNE — modèle N blocks: mod.blockHeightsMm / mod.blockBacks
|
||||||
|
// (legacy 2 blocks: mod.stacked + mod.stackBottomHeightMm; défauts config.stackEnabled / config.stackBottomHeightMm).
|
||||||
|
const STACK_MIN_MM = 300;
|
||||||
|
const STACK_MAX_MM = MAX_PANEL_DIMENSION_MM; // panneau max → au-delà, empilage obligatoire
|
||||||
|
const bodyHeightForStackMm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 100);
|
||||||
|
const minBlocksRequired = Math.max(1, Math.ceil(bodyHeightForStackMm / STACK_MAX_MM));
|
||||||
|
const maxBlocksAllowed = Math.max(1, Math.floor(bodyHeightForStackMm / STACK_MIN_MM));
|
||||||
|
|
||||||
|
/** Hauteurs de blocks d'une colonne (bas → haut) telles qu'envoyées (sans normalisation silencieuse). */
|
||||||
|
const columnBlockHeights = (modules || []).map((m) => {
|
||||||
|
const arr = Array.isArray(m?.blockHeightsMm)
|
||||||
|
? m.blockHeightsMm.map(Number).filter((v) => Number.isFinite(v) && v > 0)
|
||||||
|
: null;
|
||||||
|
if (arr && arr.length > 0) return arr;
|
||||||
|
const stacked = typeof m?.stacked === 'boolean' ? m.stacked : config.stackEnabled === true;
|
||||||
|
if (stacked && bodyHeightForStackMm >= STACK_MIN_MM * 2) {
|
||||||
|
const bottom = Number(m?.stackBottomHeightMm ?? config.stackBottomHeightMm) || 1000;
|
||||||
|
return [bottom, bodyHeightForStackMm - bottom];
|
||||||
|
}
|
||||||
|
return [bodyHeightForStackMm];
|
||||||
|
});
|
||||||
|
const columnBlockCounts = columnBlockHeights.map((hs) => hs.length);
|
||||||
|
const anyStacked = columnBlockCounts.some((n) => n >= 2) ||
|
||||||
|
((modules || []).length === 0 && config.stackEnabled === true);
|
||||||
|
|
||||||
|
if (anyStacked && bodyHeightForStackMm < STACK_MIN_MM * 2) {
|
||||||
|
errors.push({
|
||||||
|
field: 'stackEnabled',
|
||||||
|
message: `Empilage vertical impossible: hauteur de caisse insuffisante (min. ${STACK_MIN_MM * 2} mm hors plinthe).`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
columnBlockHeights.forEach((hs, i) => {
|
||||||
|
if (hs.length < minBlocksRequired) {
|
||||||
|
errors.push({
|
||||||
|
field: 'blockHeightsMm',
|
||||||
|
message: `Colonne ${i + 1}: hauteur de caisse ${bodyHeightForStackMm} mm > panneau max ${STACK_MAX_MM} mm — au moins ${minBlocksRequired} blocks empilés obligatoires.`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hs.length > maxBlocksAllowed) {
|
||||||
|
errors.push({
|
||||||
|
field: 'blockHeightsMm',
|
||||||
|
message: `Colonne ${i + 1}: trop de blocks (max. ${maxBlocksAllowed} pour ${bodyHeightForStackMm} mm, min. ${STACK_MIN_MM} mm par block).`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hs.length >= 2) {
|
||||||
|
const sum = hs.reduce((a, b) => a + b, 0);
|
||||||
|
if (hs.some((h) => h < STACK_MIN_MM || h > STACK_MAX_MM)) {
|
||||||
|
errors.push({
|
||||||
|
field: 'blockHeightsMm',
|
||||||
|
message: `Colonne ${i + 1}: hauteur de block invalide (entre ${STACK_MIN_MM} et ${STACK_MAX_MM} mm par block).`
|
||||||
|
});
|
||||||
|
} else if (Math.abs(sum - bodyHeightForStackMm) > 2) {
|
||||||
|
errors.push({
|
||||||
|
field: 'blockHeightsMm',
|
||||||
|
message: `Colonne ${i + 1}: la somme des blocks (${sum} mm) doit correspondre à la hauteur de caisse (${bodyHeightForStackMm} mm).`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const stackEnabled = anyStacked && bodyHeightForStackMm >= STACK_MIN_MM * 2;
|
||||||
|
|
||||||
|
const columnBackFlags = (modules || []).map((m) =>
|
||||||
|
typeof m?.hasBack === 'boolean' ? m.hasBack : legacyBackDefault
|
||||||
|
);
|
||||||
|
/** Dos par block d'une colonne (bas → haut). */
|
||||||
|
const columnBlockBacks = (modules || []).map((m, i) => {
|
||||||
|
const n = columnBlockCounts[i];
|
||||||
|
const arr = Array.isArray(m?.blockBacks) ? m.blockBacks : null;
|
||||||
|
return Array.from({ length: n }, (_, k) => {
|
||||||
|
if (arr && typeof arr[k] === 'boolean') return arr[k];
|
||||||
|
if (k === n - 1 && n >= 2 && typeof m?.hasBackTop === 'boolean') return m.hasBackTop;
|
||||||
|
return columnBackFlags[i];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Zone du dessus (dernier block, ou zone unique).
|
||||||
|
const columnBackTopFlags = columnBlockBacks.map((backs, i) =>
|
||||||
|
backs.length > 0 ? backs[backs.length - 1] : columnBackFlags[i]
|
||||||
|
);
|
||||||
|
let blockJointCount = 0;
|
||||||
|
for (let i = 0; i + 1 < columnBlockBacks.length; i++) {
|
||||||
|
const leftMod = (modules || [])[i];
|
||||||
|
const rightMod = (modules || [])[i + 1];
|
||||||
|
const leftBlock = usesBlockConstruction(leftMod, (columnBlockBacks[i] || []).some(Boolean));
|
||||||
|
const rightBlock = usesBlockConstruction(rightMod, (columnBlockBacks[i + 1] || []).some(Boolean));
|
||||||
|
if (!leftBlock || !rightBlock) continue;
|
||||||
|
const n = Math.min(columnBlockBacks[i].length, columnBlockBacks[i + 1].length);
|
||||||
|
blockJointCount += n;
|
||||||
|
}
|
||||||
|
const allColumnsBack =
|
||||||
|
columnBlockBacks.length > 0 &&
|
||||||
|
columnBlockBacks.every((backs) => backs.every(Boolean));
|
||||||
|
const anyColumnBack =
|
||||||
|
columnBlockBacks.some((backs) => backs.some(Boolean)) ||
|
||||||
|
(columnBlockBacks.length === 0 && legacyBackDefault);
|
||||||
|
const columnMode = allColumnsBack ? 'block' : (anyColumnBack ? 'mixte' : 'separations');
|
||||||
|
|
||||||
|
// Habillages obligatoires (installation libre, configs récentes avec flags dos explicites):
|
||||||
|
// - colonne d'extrémité empilée → habillage de ce côté seulement
|
||||||
|
// - une zone du dessus (niveau haut, ou zone unique) sans dos → dessus de doublage obligatoire
|
||||||
|
const hasExplicitBackFlags =
|
||||||
|
typeof config.hasBack === 'boolean' || (modules || []).some((m) => typeof m?.hasBack === 'boolean');
|
||||||
|
if (installationType === 'free' && (modules || []).length > 0 && hasExplicitBackFlags) {
|
||||||
|
const extOn = config.exteriorFinishesEnabled !== false;
|
||||||
|
const sideDefaults = config.sideFinishesEnabled !== false;
|
||||||
|
const sideL = config.sideFinishLeftEnabled ?? sideDefaults;
|
||||||
|
const sideR = config.sideFinishRightEnabled ?? sideDefaults;
|
||||||
|
const topOn = config.topFinishEnabled !== false;
|
||||||
|
const leftOuterStacked = columnBlockCounts[0] >= 2;
|
||||||
|
const rightOuterStacked = columnBlockCounts[columnBlockCounts.length - 1] >= 2;
|
||||||
|
if (leftOuterStacked && (!extOn || !sideL)) {
|
||||||
|
errors.push({
|
||||||
|
field: 'sideFinishLeftEnabled',
|
||||||
|
message: 'Colonne gauche empilée (plusieurs blocks en hauteur) : habillage gauche obligatoire.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rightOuterStacked && (!extOn || !sideR)) {
|
||||||
|
errors.push({
|
||||||
|
field: 'sideFinishRightEnabled',
|
||||||
|
message: 'Colonne droite empilée (plusieurs blocks en hauteur) : habillage droit obligatoire.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (columnBackTopFlags.some((b) => !b) && (!extOn || !topOn)) {
|
||||||
|
errors.push({
|
||||||
|
field: 'topFinishEnabled',
|
||||||
|
message: 'Une zone du dessus (niveau haut) sans dos : dessus de doublage obligatoire.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Determine if using modules (V2) or blocks (legacy)
|
// Determine if using modules (V2) or blocks (legacy)
|
||||||
const useModules = modules && modules.length > 0;
|
const useModules = modules && modules.length > 0;
|
||||||
|
|
||||||
// Plinthe 60–160 mm
|
// Plinthe (bornes paramétrables, défaut 60–200 mm)
|
||||||
if (plinthHeightMm < plinthMin || plinthHeightMm > plinthMax) {
|
if (plinthHeightMm < plinthMin || plinthHeightMm > plinthMax) {
|
||||||
errors.push({ field: 'plinthHeightMm', message: `La hauteur de plinthe doit être entre ${plinthMin} et ${plinthMax} mm.` });
|
errors.push({ field: 'plinthHeightMm', message: `La hauteur de plinthe doit être entre ${plinthMin} et ${plinthMax} mm.` });
|
||||||
|
} else if (!PLINTH.targetHeightsMm.includes(Number(plinthHeightMm))) {
|
||||||
|
errors.push({
|
||||||
|
field: 'plinthHeightMm',
|
||||||
|
message: `Hauteur AXILO 78 invalide. Valeurs admises : ${PLINTH.targetHeightsMm.join(', ')} mm.`
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Portes coulissantes : largeur totale >= 1200 mm
|
// Portes coulissantes : largeur totale >= 1200 mm
|
||||||
@@ -431,18 +736,19 @@ export function calculatePrice(config, data) {
|
|||||||
|
|
||||||
// Renforts Forteresse — one pair (haut+bas) per panneau vertical de structure
|
// Renforts Forteresse — one pair (haut+bas) per panneau vertical de structure
|
||||||
// Séparations: 2 côtés + (n−1) séparateurs = n+1 panneaux verticaux
|
// Séparations: 2 côtés + (n−1) séparateurs = n+1 panneaux verticaux
|
||||||
// Block (x caisses): chaque caisse a 2 montants → 2n panneaux verticaux (plus de bois qu’en séparations)
|
// Chaque jonction caisson↔caisson (dos ou rayonnage adjacents) ajoute 1 montant (double montant)
|
||||||
const columnCount = useModules
|
const columnCount = useModules
|
||||||
? Math.max(1, modules.length)
|
? Math.max(1, modules.length)
|
||||||
: Math.max(1, blocks.length || 1);
|
: Math.max(1, blocks.length || 1);
|
||||||
const verticalStructurePanelCount = columnMode === 'block'
|
const legacyBlockJoints = !useModules && columnModeRaw === 'block'
|
||||||
? 2 * columnCount
|
? Math.max(0, columnCount - 1)
|
||||||
: 2 + Math.max(0, columnCount - 1);
|
: blockJointCount;
|
||||||
|
const verticalStructurePanelCount = 2 + Math.max(0, columnCount - 1) + legacyBlockJoints;
|
||||||
const reinforcementCount = verticalStructurePanelCount * 2;
|
const reinforcementCount = verticalStructurePanelCount * 2;
|
||||||
|
|
||||||
// Surcoût panneaux 19 mm (structure): en mode block, (n−1) montants de plus qu’en séparations
|
// Surcoût panneaux 19 mm (structure): 1 montant supplémentaire par jonction caisson↔caisson
|
||||||
const bodyHeightMm = (heightMm || 0) - (plinthHeightMm || 100);
|
const bodyHeightMm = (heightMm || 0) - (plinthHeightMm || 100);
|
||||||
const extraBlockVerticalPanels = columnMode === 'block' && columnCount > 1 ? columnCount - 1 : 0;
|
const extraBlockVerticalPanels = legacyBlockJoints;
|
||||||
const extraBlockStructureSurfaceM2 = extraBlockVerticalPanels > 0
|
const extraBlockStructureSurfaceM2 = extraBlockVerticalPanels > 0
|
||||||
? (extraBlockVerticalPanels * depthMm * bodyHeightMm) / 1_000_000
|
? (extraBlockVerticalPanels * depthMm * bodyHeightMm) / 1_000_000
|
||||||
: 0;
|
: 0;
|
||||||
@@ -451,6 +757,51 @@ export function calculatePrice(config, data) {
|
|||||||
: 0;
|
: 0;
|
||||||
const blockExtraStructureChf = Math.round(extraBlockStructureSurfaceM2 * structureUnitPriceM2 * 100) / 100;
|
const blockExtraStructureChf = Math.round(extraBlockStructureSurfaceM2 * structureUnitPriceM2 * 100) / 100;
|
||||||
|
|
||||||
|
// Empilage vertical PAR COLONNE: chaque jonction d'une colonne empilée ajoute 2 panneaux
|
||||||
|
// horizontaux (dessus du block inférieur + fond du block supérieur) à sa largeur intérieure.
|
||||||
|
let stackExtraStructureSurfaceM2 = 0;
|
||||||
|
if (stackEnabled) {
|
||||||
|
if (useModules) {
|
||||||
|
(modules || []).forEach((m, i) => {
|
||||||
|
const colW = Math.max(0, (Number(m?.widthMm) || widthMm / Math.max(1, modules.length)) - 2 * PANEL.structure);
|
||||||
|
if (resolvedHorizontalSpans.length === 0) {
|
||||||
|
const joints = Math.max(0, columnBlockCounts[i] - 1);
|
||||||
|
stackExtraStructureSurfaceM2 += (joints * 2 * colW * depthMm) / 1_000_000;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const boundaries = [0];
|
||||||
|
columnBlockHeights[i].forEach((height) => boundaries.push(boundaries[boundaries.length - 1] + height));
|
||||||
|
for (let boundaryIndex = 1; boundaryIndex < boundaries.length - 1; boundaryIndex++) {
|
||||||
|
const boundaryMm = boundaries[boundaryIndex];
|
||||||
|
const spanAtBottom = resolvedHorizontalSpans.some((span) =>
|
||||||
|
span.moduleIndexes.includes(i) && Math.abs(span.bottomMm - boundaryMm) <= 0.5
|
||||||
|
);
|
||||||
|
const spanAtTop = resolvedHorizontalSpans.some((span) =>
|
||||||
|
span.moduleIndexes.includes(i) && Math.abs(span.topMm - boundaryMm) <= 0.5
|
||||||
|
);
|
||||||
|
const retainedFaces = 2 - Number(spanAtBottom) - Number(spanAtTop);
|
||||||
|
stackExtraStructureSurfaceM2 += (retainedFaces * colW * depthMm) / 1_000_000;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
stackExtraStructureSurfaceM2 = (2 * Math.max(0, widthMm - 2 * PANEL.structure) * depthMm) / 1_000_000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resolvedHorizontalSpans.length > 0) {
|
||||||
|
for (const span of resolvedHorizontalSpans) {
|
||||||
|
const spanPanelWidthMm = Math.max(0, span.widthMm - 2 * PANEL.structure);
|
||||||
|
stackExtraStructureSurfaceM2 += (2 * spanPanelWidthMm * depthMm) / 1_000_000;
|
||||||
|
if (span.span?.hasBack) {
|
||||||
|
// Back panels are 8 mm; price their surface proportionally to the 19 mm structure rate.
|
||||||
|
stackExtraStructureSurfaceM2 +=
|
||||||
|
(spanPanelWidthMm * Math.max(0, span.topMm - span.bottomMm - 2 * PANEL.structure)) /
|
||||||
|
1_000_000 *
|
||||||
|
(PANEL.back / PANEL.structure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stackExtraStructureChf = Math.round(stackExtraStructureSurfaceM2 * structureUnitPriceM2 * 100) / 100;
|
||||||
|
|
||||||
// Each reinforcement is 100mm × innerDepth × 16mm — price as fraction of a panel
|
// Each reinforcement is 100mm × innerDepth × 16mm — price as fraction of a panel
|
||||||
const reinforcementSurfaceM2 = (PANEL.reinforcementWidth * (depthMm - PANEL.structure - 20)) / 1_000_000 * reinforcementCount;
|
const reinforcementSurfaceM2 = (PANEL.reinforcementWidth * (depthMm - PANEL.structure - 20)) / 1_000_000 * reinforcementCount;
|
||||||
// Use 50% of panel price per m² for 16mm material (thinner, cheaper)
|
// Use 50% of panel price per m² for 16mm material (thinner, cheaper)
|
||||||
@@ -464,7 +815,7 @@ export function calculatePrice(config, data) {
|
|||||||
if (opt) optionsChf += Number(opt.price_chf) || 0;
|
if (opt) optionsChf += Number(opt.price_chf) || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf + reinforcementChf + blockExtraStructureChf;
|
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf + reinforcementChf + blockExtraStructureChf + stackExtraStructureChf;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalChf: Math.round(totalChf * 100) / 100,
|
totalChf: Math.round(totalChf * 100) / 100,
|
||||||
@@ -479,6 +830,9 @@ export function calculatePrice(config, data) {
|
|||||||
columnMode,
|
columnMode,
|
||||||
verticalStructurePanelCount,
|
verticalStructurePanelCount,
|
||||||
blockExtraStructureChf,
|
blockExtraStructureChf,
|
||||||
|
stackExtraStructureChf,
|
||||||
|
stackEnabled,
|
||||||
|
stackedColumnCount: columnBlockCounts.filter((n) => n >= 2).length,
|
||||||
optionsChf,
|
optionsChf,
|
||||||
hardwareChf,
|
hardwareChf,
|
||||||
hardwareBreakdown,
|
hardwareBreakdown,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,368 @@
|
|||||||
|
/**
|
||||||
|
* APEX 4.0 — Blum LEGRABOX pure
|
||||||
|
* Données d'intégration : débit panneaux 16 mm, perçage CNC caisson (coulisses),
|
||||||
|
* perçage façades (INSERTA/EXPANDO), norme suisse SMS, génération de commande (BOM)
|
||||||
|
* et accessoires intérieurs.
|
||||||
|
*
|
||||||
|
* Source : dossier « INTÉGRATION DATA - APEX 4.0 » (modules usinage CNC / SMS / BOM /
|
||||||
|
* accessoires) + catalogue technique Blum. Toutes les cotes en mm.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Épaisseur de référence stricte des fonds et parois arrière de tiroir. */
|
||||||
|
export const LEGRABOX_PANEL_THICKNESS_MM = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Systèmes de hauteur Legrabox (N/M/K/C/F).
|
||||||
|
* - sideHeightMm : hauteur de côté (profil métallique) — « hauteur_cote_mm »
|
||||||
|
* - nicheHeightMm : hauteur de niche / encombrement façade min — « hauteur_des_niches_mm »
|
||||||
|
* - backHeightMm : hauteur C de la paroi arrière 16 mm — « hauteur_C_mm_selon_systeme »
|
||||||
|
* - hch : code hauteur commercial (S/M/L/XL/XXL)
|
||||||
|
* - smsFromSixths : norme suisse SMS — disponible à partir de n/6 (1/6 = 127 mm)
|
||||||
|
*/
|
||||||
|
export const LEGRABOX_SYSTEMS = Object.freeze({
|
||||||
|
N: Object.freeze({ key: 'N', hch: 'S', sideHeightMm: 66, nicheHeightMm: 80, backHeightMm: 39, smsFromSixths: 1 }),
|
||||||
|
M: Object.freeze({ key: 'M', hch: 'M', sideHeightMm: 90, nicheHeightMm: 106, backHeightMm: 63, smsFromSixths: 1 }),
|
||||||
|
K: Object.freeze({ key: 'K', hch: 'L', sideHeightMm: 128, nicheHeightMm: 144, backHeightMm: 101, smsFromSixths: 2 }),
|
||||||
|
C: Object.freeze({ key: 'C', hch: 'XL', sideHeightMm: 177, nicheHeightMm: 193, backHeightMm: 148, smsFromSixths: 2 }),
|
||||||
|
F: Object.freeze({ key: 'F', hch: 'XXL', sideHeightMm: 240, nicheHeightMm: 257, backHeightMm: 212, smsFromSixths: 3 })
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Hauteurs de côté sélectionnables (remplace l'ancien 60 mm par 66 mm — système N). */
|
||||||
|
export const LEGRABOX_SIDE_HEIGHTS_MM = Object.freeze([66, 90, 128, 177, 240]);
|
||||||
|
|
||||||
|
/** Norme suisse SMS — division en sixièmes. */
|
||||||
|
export const SMS = Object.freeze({
|
||||||
|
sixthMm: 127,
|
||||||
|
description: 'Système de mesure suisse (Schweizer Mass-System) — division en sixièmes (1/6 = 127 mm)'
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @returns {object|null} système Legrabox pour une hauteur de côté (66/90/128/177/240). */
|
||||||
|
export function getLegraboxSystemForSideHeight(sideHeightMm) {
|
||||||
|
const h = Number(sideHeightMm);
|
||||||
|
for (const sys of Object.values(LEGRABOX_SYSTEMS)) {
|
||||||
|
if (sys.sideHeightMm === h) return sys;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hauteur de niche (encombrement façade min) pour une hauteur de côté.
|
||||||
|
* Hauteur inconnue → valeur inchangée (compat anciennes configs).
|
||||||
|
*/
|
||||||
|
export function legraboxNicheHeightMm(sideHeightMm) {
|
||||||
|
const sys = getLegraboxSystemForSideHeight(sideHeightMm);
|
||||||
|
return sys ? sys.nicheHeightMm : Math.max(0, Number(sideHeightMm) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Débit panneaux — épaisseur 16 mm ─────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fond de tiroir 16 mm.
|
||||||
|
* longueur A = NL − 10 · largeur B = X − 35 (X = largeur libre du corps).
|
||||||
|
* Usinage spécial : feuillure/renfort 38 × 8 mm à gauche et à droite.
|
||||||
|
* @param {number} nominalLengthMm — longueur nominale NL de la coulisse
|
||||||
|
* @param {number} internalWidthMm — largeur libre du corps (X)
|
||||||
|
*/
|
||||||
|
export function legraboxBottomPanelMm(nominalLengthMm, internalWidthMm) {
|
||||||
|
const nl = Math.max(0, Number(nominalLengthMm) || 0);
|
||||||
|
const x = Math.max(0, Number(internalWidthMm) || 0);
|
||||||
|
if (nl <= 10 || x <= 35) return null;
|
||||||
|
return {
|
||||||
|
lengthMm: nl - 10,
|
||||||
|
widthMm: x - 35,
|
||||||
|
thicknessMm: LEGRABOX_PANEL_THICKNESS_MM,
|
||||||
|
rebate: {
|
||||||
|
widthMm: 38,
|
||||||
|
depthMm: 8,
|
||||||
|
sides: ['left', 'right'],
|
||||||
|
note: 'Fond feuilluré/renforcé à gauche et à droite : 38 × 8 mm'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paroi arrière (dos de tiroir) 16 mm.
|
||||||
|
* largeur B = X − 38 · hauteur C selon système (N 39 / M 63 / K 101 / C 148 / F 212).
|
||||||
|
* @param {number} internalWidthMm — largeur libre du corps (X)
|
||||||
|
* @param {string} systemKey — 'N' | 'M' | 'K' | 'C' | 'F'
|
||||||
|
*/
|
||||||
|
export function legraboxBackPanelMm(internalWidthMm, systemKey) {
|
||||||
|
const x = Math.max(0, Number(internalWidthMm) || 0);
|
||||||
|
const sys = LEGRABOX_SYSTEMS[systemKey];
|
||||||
|
if (!sys || x <= 38) return null;
|
||||||
|
return {
|
||||||
|
widthMm: x - 38,
|
||||||
|
heightMm: sys.backHeightMm,
|
||||||
|
thicknessMm: LEGRABOX_PANEL_THICKNESS_MM
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Perçage CNC — coulisses sur caisson (Système 32) ────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Positions de perçage cumulées (axe X) en mm depuis le bord AVANT du montant,
|
||||||
|
* pour le vissage des coulisses, selon capacité de charge.
|
||||||
|
* Perçage pilote Système 32 : Ø5 × 13 mm (vis à tête plate recommandées par Blum).
|
||||||
|
*/
|
||||||
|
export const LEGRABOX_RAIL_DRILLING = Object.freeze({
|
||||||
|
compatibleSideHeightsMm: LEGRABOX_SIDE_HEIGHTS_MM,
|
||||||
|
holeDiameterMm: 5,
|
||||||
|
holeDepthMm: 13,
|
||||||
|
/**
|
||||||
|
* Axe vertical de la ligne de vissage du profil de corps, au-dessus du bas
|
||||||
|
* de la niche tiroir. Hypothèse atelier (catalogue Blum) — à confirmer au gabarit ZML.3710.
|
||||||
|
*/
|
||||||
|
screwLineAboveNicheBottomMm: 9.5,
|
||||||
|
byLoadKg: Object.freeze({
|
||||||
|
40: Object.freeze([37, 69, 197, 229, 261, 293, 357]),
|
||||||
|
70: Object.freeze([37, 69, 261, 293, 357])
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Positions X (depuis le chant avant du panneau vertical) pour une capacité donnée,
|
||||||
|
* bornées à la profondeur utile du panneau.
|
||||||
|
* @param {40|70} loadKg
|
||||||
|
* @param {number} [maxXMm] — profondeur maximum perçable (ex. profondeur panneau − retrait)
|
||||||
|
*/
|
||||||
|
export function getLegraboxRailHolePositionsMm(loadKg = 40, maxXMm = Infinity) {
|
||||||
|
const base = LEGRABOX_RAIL_DRILLING.byLoadKg[Number(loadKg) === 70 ? 70 : 40];
|
||||||
|
const max = Number.isFinite(Number(maxXMm)) ? Number(maxXMm) : Infinity;
|
||||||
|
return base.filter((x) => x <= max);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Perçage CNC — façades (INSERTA / cheville expansible) ────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matrice de perçage façade — fixation INSERTA (cheville expansible Ø10, prof. > 12 mm).
|
||||||
|
* Cotes catalogue conservées telles quelles (validation programmeur CNC) :
|
||||||
|
* - minFrontHeightMm (A) : encombrement min de la façade
|
||||||
|
* - coteC / coteD : positions verticales des 2 perçages du support, depuis le bas façade
|
||||||
|
* - coteB : rangées supplémentaires (façades hautes C / F), depuis le bas façade
|
||||||
|
* - coteE : retrait horizontal de l'axe depuis la référence côté tiroir
|
||||||
|
* - coteG : cote catalogue complémentaire (référence gabarit)
|
||||||
|
*/
|
||||||
|
export const LEGRABOX_FACADE_INSERTA = Object.freeze({
|
||||||
|
toolNote: 'Cheville expansible Ø 10 mm, profondeur de perçage > 12 mm',
|
||||||
|
holeDiameterMm: 10,
|
||||||
|
holeDepthMm: 13,
|
||||||
|
bySystem: Object.freeze({
|
||||||
|
N: Object.freeze({ minFrontHeightMm: 80, coteC: 45.5, coteD: 16, coteE: 23.4, coteG: 14 }),
|
||||||
|
M: Object.freeze({ minFrontHeightMm: 106, coteC: 51, coteD: 32, coteE: 23.4, coteG: 14 }),
|
||||||
|
K: Object.freeze({ minFrontHeightMm: 144, coteC: 51, coteD: 32, coteE: 23.4, coteG: 14 }),
|
||||||
|
C: Object.freeze({ minFrontHeightMm: 193, coteB: Object.freeze([147, 179]), coteC: 51, coteD: 32, coteE: 24.4, coteG: 14 }),
|
||||||
|
F: Object.freeze({ minFrontHeightMm: 257, coteB: Object.freeze([179, 211, 243]), coteC: 51, coteD: 32, coteE: 24.4, coteG: 14 })
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Positions XY des perçages INSERTA sur une façade (repère : origine bas-gauche façade).
|
||||||
|
* Interprétation atelier : 2 perçages verticaux par côté (y = coteD bas, y = coteC haut,
|
||||||
|
* depuis le bas façade) + rangées coteB pour C/F. Axe horizontal à coteE de la
|
||||||
|
* référence côté de tiroir (bord de la largeur libre X), soit
|
||||||
|
* x = (largeur façade − X)/2 + coteE depuis chaque bord de façade.
|
||||||
|
* @param {string} systemKey — 'N'…'F'
|
||||||
|
* @param {number} facadeWidthMm
|
||||||
|
* @param {number} facadeHeightMm
|
||||||
|
* @param {number} internalWidthMm — largeur libre du corps X
|
||||||
|
* @returns {{ xMm:number, yMm:number, role:string }[]}
|
||||||
|
*/
|
||||||
|
export function buildLegraboxFacadeInsertaHolePositionsMm(systemKey, facadeWidthMm, facadeHeightMm, internalWidthMm) {
|
||||||
|
const spec = LEGRABOX_FACADE_INSERTA.bySystem[systemKey];
|
||||||
|
const W = Math.max(0, Number(facadeWidthMm) || 0);
|
||||||
|
const H = Math.max(0, Number(facadeHeightMm) || 0);
|
||||||
|
const X = Math.max(0, Number(internalWidthMm) || 0);
|
||||||
|
if (!spec || W <= 0 || H <= 0 || X <= 0 || W <= X - 2 * spec.coteE) return [];
|
||||||
|
|
||||||
|
const inset = Math.max(spec.coteG, (W - X) / 2 + spec.coteE);
|
||||||
|
const xs = [
|
||||||
|
{ xMm: Math.round(inset * 10) / 10, side: 'gauche' },
|
||||||
|
{ xMm: Math.round((W - inset) * 10) / 10, side: 'droite' }
|
||||||
|
];
|
||||||
|
const ys = [
|
||||||
|
{ yMm: spec.coteD, role: 'bas' },
|
||||||
|
{ yMm: spec.coteC, role: 'haut' },
|
||||||
|
...(Array.isArray(spec.coteB) ? spec.coteB.map((b, i) => ({ yMm: b, role: `rangee_B${i + 1}` })) : [])
|
||||||
|
].filter((p) => p.yMm > 0 && p.yMm < H);
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (const { xMm, side } of xs) {
|
||||||
|
for (const { yMm, role } of ys) {
|
||||||
|
out.push({ xMm, yMm, role: `${side}_${role}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Montage / ajustement ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const LEGRABOX_ASSEMBLY = Object.freeze({
|
||||||
|
montageFacade: 'Sans outils',
|
||||||
|
demontageFacade: 'Tournevis',
|
||||||
|
reglageHauteur: "Depuis l'intérieur (devant)",
|
||||||
|
reglageInclinaison: 'Depuis le bas (devant)',
|
||||||
|
technologiesOuvertureFermeture: Object.freeze([
|
||||||
|
'Blumotion (Fermeture automatique avec amortissement)',
|
||||||
|
'Tip-on Blumotion (Ouverture automatique avec amortissement intégré)',
|
||||||
|
'Tip-on (Système d\u2019ouverture mécanique)'
|
||||||
|
])
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 5. Génération de commande (BOM) ─────────────────────────────────────
|
||||||
|
|
||||||
|
export const LEGRABOX_ORDER_OPTIONS = Object.freeze({
|
||||||
|
couleursChassis: Object.freeze([
|
||||||
|
'blanc soie',
|
||||||
|
'argent polaire',
|
||||||
|
'gris orion',
|
||||||
|
'noir terra',
|
||||||
|
'acier inoxydable (anti-empreintes digitales)'
|
||||||
|
]),
|
||||||
|
typesDeTiroir: Object.freeze([
|
||||||
|
'Type de façade du tiroir (Standard)',
|
||||||
|
'Type de tiroir intérieur (Anglais)'
|
||||||
|
]),
|
||||||
|
variantesOuvertureFermeture: Object.freeze(['Blumotion', 'Tip-On Blumotion', 'Tip-On']),
|
||||||
|
fixationsFacadeDisponibles: Object.freeze(['Vis (Douille d\u2019écartement)', 'Expan-do T']),
|
||||||
|
etatDeLivraison: Object.freeze(['F', 'C', 'S'])
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Règles métier strictes (regles_automatiques_requises). */
|
||||||
|
export const LEGRABOX_BOM_RULES = Object.freeze({
|
||||||
|
/** Ajouter « Stabilisation de façade » si largeur_corps_mm >= 600. */
|
||||||
|
frontStabiliserMinBodyWidthMm: 600,
|
||||||
|
/** Ajouter « Unité de synchronisation » si variante Tip-On Blumotion. */
|
||||||
|
syncUnitVariant: 'tip_on_blumotion'
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Longueurs nominales disponibles (mm) par système et capacité de charge.
|
||||||
|
* N absent du dossier BOM — longueurs issues du catalogue Blum (40 kg uniquement).
|
||||||
|
* Exception : K (hauteur L) indisponible en acier inoxydable.
|
||||||
|
*/
|
||||||
|
export const LEGRABOX_NOMINAL_LENGTHS_MM = Object.freeze({
|
||||||
|
N: Object.freeze({ 40: Object.freeze([400, 450, 500, 550]), 70: Object.freeze([]) }),
|
||||||
|
M: Object.freeze({ 40: Object.freeze([270, 300, 350, 400, 450, 500, 550, 600]), 70: Object.freeze([450, 500, 550, 600, 650]) }),
|
||||||
|
K: Object.freeze({ 40: Object.freeze([300, 350, 400, 450, 500, 550, 600]), 70: Object.freeze([450, 500, 550, 600]) }),
|
||||||
|
C: Object.freeze({ 40: Object.freeze([270, 300, 350, 400, 450, 500, 550, 600]), 70: Object.freeze([450, 500, 550, 600, 650]) }),
|
||||||
|
F: Object.freeze({ 40: Object.freeze([400, 450, 500, 550, 600]), 70: Object.freeze([450, 500, 550, 600, 650]) })
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Couleur châssis indisponible : hauteur K (L) en acier inoxydable. */
|
||||||
|
export function isLegraboxChassisColorAvailable(systemKey, chassisColor) {
|
||||||
|
const isInox = String(chassisColor || '').toLowerCase().includes('inox');
|
||||||
|
return !(systemKey === 'K' && isInox);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plus grande longueur nominale disponible pour un système / capacité, bornée par la
|
||||||
|
* profondeur utile (profondeur intérieure min = NL + 3 mm gérée par l'appelant).
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
export function getLegraboxNominalLengthMm(systemKey, availableDepthMm, loadKg = 40) {
|
||||||
|
const table = LEGRABOX_NOMINAL_LENGTHS_MM[systemKey];
|
||||||
|
if (!table) return null;
|
||||||
|
const list = table[Number(loadKg) === 70 ? 70 : 40] || [];
|
||||||
|
const depth = Math.max(0, Number(availableDepthMm) || 0);
|
||||||
|
let best = null;
|
||||||
|
for (const nl of list) {
|
||||||
|
if (nl <= depth && (best == null || nl > best)) best = nl;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Accessoires et subdivisions intérieures (up-sell) ────────────────
|
||||||
|
|
||||||
|
export const LEGRABOX_INTERIOR_ACCESSORIES = Object.freeze({
|
||||||
|
garnituresUniversellesPlastique: Object.freeze({
|
||||||
|
label: 'Garniture universelle plastique',
|
||||||
|
nominalLengthCompatibleMm: 500,
|
||||||
|
couleurs: Object.freeze(['Anthracite pearl', 'Blanc grainé']),
|
||||||
|
modelesParHauteur: Object.freeze({
|
||||||
|
h40: Object.freeze({
|
||||||
|
hauteurMm: 40,
|
||||||
|
largeursCorpsCompatiblesMm: Object.freeze([600, 800, 900]),
|
||||||
|
note: 'Particulièrement adapté sous les champs d\u2019induction'
|
||||||
|
}),
|
||||||
|
h52: Object.freeze({
|
||||||
|
hauteurMm: 52,
|
||||||
|
largeursCorpsCompatiblesMm: Object.freeze([500, 600, 800, 900])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
assortimentsPorteCouverts: Object.freeze({
|
||||||
|
label: 'Assortiment porte-couverts modulaire',
|
||||||
|
typeFixation: 'Autocollant avec insert en feutre',
|
||||||
|
couleurs: Object.freeze(['Lie de vin', 'Anthracite']),
|
||||||
|
configurations: Object.freeze([
|
||||||
|
Object.freeze({ pieces: 50, composition: '12 couteaux, 12 fourchettes, 12 cuillères, 12 cuillères à café, 2 grandes pièces' }),
|
||||||
|
Object.freeze({ pieces: 70, composition: '12 couteaux, 12 fourchettes, 12 cuillères, 12 cuillères à café, 12 fourchettes de cuisine, 6 petites pièces, 4 grandes pièces' }),
|
||||||
|
Object.freeze({ pieces: 90, composition: '24 couteaux, 24 fourchettes, 12 cuillères, 12 cuillères à café, 12 fourchettes de cuisine, 6 grandes pièces' })
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
garnituresReglablesBoisInox: Object.freeze({
|
||||||
|
label: 'Garniture réglable hêtre / acier inox',
|
||||||
|
finition: 'Hêtre / acier inox',
|
||||||
|
nominalLengthCompatibleMm: 500,
|
||||||
|
profondeurReelleMm: 478,
|
||||||
|
modeles: Object.freeze({
|
||||||
|
bacEtroit: Object.freeze({ largeurCorpsCibleMm: [300, 400], largeurReelleAjustableMm: [200, 300] }),
|
||||||
|
bacLarge: Object.freeze({ largeurCorpsCibleMm: [450, 700], largeurReelleAjustableMm: [350, 600] })
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
rangeCouvertsBoisHetre: Object.freeze({
|
||||||
|
label: 'Range-couverts réglable hêtre (laqué)',
|
||||||
|
finition: 'Hêtre (laqué)',
|
||||||
|
nominalLengthsCompatiblesMm: Object.freeze([500, 550, 600, 650]),
|
||||||
|
/** Profondeur réelle = longueur nominale − 28 mm */
|
||||||
|
profondeurReelleDeductionMm: 28,
|
||||||
|
modeles: Object.freeze({
|
||||||
|
bacEtroit: Object.freeze({ largeurCorpsCibleMm: [300, 400], largeurReelleAjustableMm: [200, 300] }),
|
||||||
|
bacLarge: Object.freeze({ largeurCorpsCibleMm: [450, 700], largeurReelleAjustableMm: [350, 600] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggestions d'accessoires intérieurs compatibles avec la géométrie du tiroir.
|
||||||
|
* @param {{ bodyWidthMm:number, nominalLengthMm:number }} params — largeur de corps (KB) et NL
|
||||||
|
* @returns {{ key:string, label:string, detail:string }[]}
|
||||||
|
*/
|
||||||
|
export function getLegraboxAccessorySuggestions({ bodyWidthMm, nominalLengthMm }) {
|
||||||
|
const kb = Math.max(0, Number(bodyWidthMm) || 0);
|
||||||
|
const nl = Math.max(0, Number(nominalLengthMm) || 0);
|
||||||
|
const out = [];
|
||||||
|
const A = LEGRABOX_INTERIOR_ACCESSORIES;
|
||||||
|
|
||||||
|
if (nl === A.garnituresUniversellesPlastique.nominalLengthCompatibleMm) {
|
||||||
|
for (const [k, m] of Object.entries(A.garnituresUniversellesPlastique.modelesParHauteur)) {
|
||||||
|
if (m.largeursCorpsCompatiblesMm.includes(kb)) {
|
||||||
|
out.push({
|
||||||
|
key: `garniture_plastique_${k}`,
|
||||||
|
label: `${A.garnituresUniversellesPlastique.label} h${m.hauteurMm} mm`,
|
||||||
|
detail: m.note || `Largeur corps ${kb} mm, NL ${nl} mm`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inRange = ([min, max]) => kb >= min && kb <= max;
|
||||||
|
if (nl === A.garnituresReglablesBoisInox.nominalLengthCompatibleMm) {
|
||||||
|
if (inRange(A.garnituresReglablesBoisInox.modeles.bacEtroit.largeurCorpsCibleMm)) {
|
||||||
|
out.push({ key: 'garniture_bois_inox_etroit', label: `${A.garnituresReglablesBoisInox.label} — bac étroit`, detail: `Largeur réelle ajustable 200–300 mm` });
|
||||||
|
}
|
||||||
|
if (inRange(A.garnituresReglablesBoisInox.modeles.bacLarge.largeurCorpsCibleMm)) {
|
||||||
|
out.push({ key: 'garniture_bois_inox_large', label: `${A.garnituresReglablesBoisInox.label} — bac large`, detail: `Largeur réelle ajustable 350–600 mm` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (A.rangeCouvertsBoisHetre.nominalLengthsCompatiblesMm.includes(nl)) {
|
||||||
|
const prof = nl - A.rangeCouvertsBoisHetre.profondeurReelleDeductionMm;
|
||||||
|
if (inRange(A.rangeCouvertsBoisHetre.modeles.bacEtroit.largeurCorpsCibleMm)) {
|
||||||
|
out.push({ key: 'range_couverts_hetre_etroit', label: `${A.rangeCouvertsBoisHetre.label} — bac étroit`, detail: `Profondeur réelle ${prof} mm` });
|
||||||
|
}
|
||||||
|
if (inRange(A.rangeCouvertsBoisHetre.modeles.bacLarge.largeurCorpsCibleMm)) {
|
||||||
|
out.push({ key: 'range_couverts_hetre_large', label: `${A.rangeCouvertsBoisHetre.label} — bac large`, detail: `Profondeur réelle ${prof} mm` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
+268
-62
@@ -12,12 +12,19 @@ import {
|
|||||||
HINGE,
|
HINGE,
|
||||||
tourillonFaceDrillDepthMm,
|
tourillonFaceDrillDepthMm,
|
||||||
DRAWER_REINFORCEMENT,
|
DRAWER_REINFORCEMENT,
|
||||||
DRAWER_RENFORT_PANEL_IXCONNECT
|
DRAWER_RENFORT_PANEL_IXCONNECT,
|
||||||
|
getBackGrooveInnerEndInsetFromBackEdgeMm
|
||||||
} from './manufacturing.js';
|
} from './manufacturing.js';
|
||||||
|
|
||||||
/** Perçage ferrure en face — Ø8 mm (même diamètre que tourillon / Ixconnect face) */
|
/** Perçage ferrure en face — Ø8 mm (même diamètre que tourillon / Ixconnect face) */
|
||||||
export const CARCASS_FERRURE_FACE_DIAMETER_MM = 8;
|
export const CARCASS_FERRURE_FACE_DIAMETER_MM = 8;
|
||||||
|
|
||||||
|
/** Axe X du trou Inbus sur le panneau qui porte le long perçage Ixconnect en chant. */
|
||||||
|
function ixconnectInbusXFromEndMm({ endSide, widthMm, clampX }) {
|
||||||
|
const offset = Number(CONNECTORS.ixconnect.lockScrewInbus?.offsetFromAssemblyEdgeMm) || 25;
|
||||||
|
return clampX(endSide === 'left' ? offset : Number(widthMm) - offset);
|
||||||
|
}
|
||||||
|
|
||||||
/** Montants / séparateurs : rangées Ixconnect jonction dessus·fond — retrait depuis bords haut/bas (mm, origine Y = bas pièce en G-code). */
|
/** Montants / séparateurs : rangées Ixconnect jonction dessus·fond — retrait depuis bords haut/bas (mm, origine Y = bas pièce en G-code). */
|
||||||
export const IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM = 23;
|
export const IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM = 23;
|
||||||
|
|
||||||
@@ -32,6 +39,98 @@ export function mapJunctionXAcrossWidthMm(widthMm, xmm) {
|
|||||||
return clamped;
|
return clamped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Machining X is stored in finished-panel millimetres (origin = left). Saw blanks
|
||||||
|
* (`rawCutWidthMm`) are longer; that extra sits on +X, so right-edge features
|
||||||
|
* (montants, AXILO) look too far from the right until they are re-anchored.
|
||||||
|
* Features within `edgeBandMm` of the finished right edge keep the same inset
|
||||||
|
* on the workpiece; everything else stays left-origin.
|
||||||
|
*/
|
||||||
|
export function mapFinishedXOntoWorkpieceMm(xMm, finishedWidthMm, workpieceWidthMm, edgeBandMm = 120) {
|
||||||
|
const x = Number(xMm) || 0;
|
||||||
|
const fw = Math.max(0, Number(finishedWidthMm) || 0);
|
||||||
|
const ww = Math.max(0, Number(workpieceWidthMm) || 0);
|
||||||
|
if (ww < 0.5) return x;
|
||||||
|
if (fw < 0.5 || Math.abs(ww - fw) < 0.05) return Math.max(0, Math.min(ww, x));
|
||||||
|
const distRight = fw - x;
|
||||||
|
const distLeft = x;
|
||||||
|
if (distRight <= edgeBandMm && distRight <= distLeft + 0.05) {
|
||||||
|
return Math.max(0, Math.min(ww, ww - distRight));
|
||||||
|
}
|
||||||
|
return Math.max(0, Math.min(ww, x));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AXILO 78 Press-fit holes on the underside of a bottom panel. */
|
||||||
|
export function buildAxiloFootHoleSpecsMm(piece, widthMm, heightMm) {
|
||||||
|
const axilo = piece?.machining?.axiloFootPlates;
|
||||||
|
if ((piece?.elementType || '') !== 'fond' || !axilo?.centers?.length) return [];
|
||||||
|
|
||||||
|
const finishedW = Number(piece?.widthMm) > 0 ? Number(piece.widthMm) : widthMm;
|
||||||
|
const diameterMm = Number(axilo.holeDiameterMm) || 10;
|
||||||
|
const depthMm = Number(axilo.holeDepthMm) || 12;
|
||||||
|
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
|
||||||
|
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
for (const center of axilo.centers) {
|
||||||
|
for (const offset of axilo.holeOffsetsFromCenterMm || []) {
|
||||||
|
out.push({
|
||||||
|
xMm: clampX(
|
||||||
|
mapFinishedXOntoWorkpieceMm(
|
||||||
|
Number(center.xMm) + Number(offset.x || 0),
|
||||||
|
finishedW,
|
||||||
|
widthMm
|
||||||
|
)
|
||||||
|
),
|
||||||
|
yMm: clampY(Number(center.yMm) + Number(offset.y || 0)),
|
||||||
|
diameterMm,
|
||||||
|
depthMm,
|
||||||
|
type: 'axilo_press_fit',
|
||||||
|
face: 'back',
|
||||||
|
row: center.row
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** System 32 rows; the rear row clears the back-groove zone when present. */
|
||||||
|
export function buildSystem32HoleSpecsMm(piece, widthMm, heightMm) {
|
||||||
|
const system32 = piece?.machining?.system32;
|
||||||
|
if (!system32?.positions?.length) return [];
|
||||||
|
|
||||||
|
const finishedW = Number(piece?.widthMm) > 0 ? Number(piece.widthMm) : widthMm;
|
||||||
|
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
|
||||||
|
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
|
||||||
|
const mapX = (v) => clampX(mapFinishedXOntoWorkpieceMm(v, finishedW, widthMm));
|
||||||
|
const backClearanceMm = piece?.machining?.groove
|
||||||
|
? getBackGrooveInnerEndInsetFromBackEdgeMm()
|
||||||
|
: 0;
|
||||||
|
const xRows = [
|
||||||
|
mapX(37),
|
||||||
|
mapX(Math.max(0, finishedW - 37 - backClearanceMm))
|
||||||
|
];
|
||||||
|
const diameterMm = Number(system32.holeDiameter) || 5;
|
||||||
|
const depthMm = Number(system32.holeDepth || piece?.thicknessMm) || 12;
|
||||||
|
const toolMode = system32.throughHole ? 'through' : 'blind';
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
for (const pos of system32.positions) {
|
||||||
|
for (const xMm of xRows) {
|
||||||
|
out.push({
|
||||||
|
xMm,
|
||||||
|
yMm: clampY(pos),
|
||||||
|
diameterMm,
|
||||||
|
depthMm,
|
||||||
|
type: 'system32',
|
||||||
|
face: 'face',
|
||||||
|
toolMode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Même logique que `getCarcassDowelStationsForPlan` dans cuttingEngine.js */
|
/** Même logique que `getCarcassDowelStationsForPlan` dans cuttingEngine.js */
|
||||||
export function getCarcassDowelStationsForPlan(cf, edgeLengthMm) {
|
export function getCarcassDowelStationsForPlan(cf, edgeLengthMm) {
|
||||||
const L = Math.max(1, Number(edgeLengthMm) || 0);
|
const L = Math.max(1, Number(edgeLengthMm) || 0);
|
||||||
@@ -50,14 +149,22 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
|
|||||||
const out = [];
|
const out = [];
|
||||||
const m = piece?.machining || {};
|
const m = piece?.machining || {};
|
||||||
const thicknessMm = Math.max(1, Number(piece?.thicknessMm) || 19);
|
const thicknessMm = Math.max(1, Number(piece?.thicknessMm) || 19);
|
||||||
if (!['dessus', 'fond'].includes(piece.elementType || '')) return out;
|
const et = piece.elementType || '';
|
||||||
|
if (!['dessus', 'fond'].includes(et)) return out;
|
||||||
|
|
||||||
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
|
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
|
||||||
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
|
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
|
||||||
/** Largeur « caisse » (finie) pour repères jonctions — peut être < widthMm (brut CNC). */
|
/** Largeur « caisse » (finie) pour repères jonctions — peut être < widthMm (brut CNC). */
|
||||||
const refW =
|
const refW =
|
||||||
Number(piece?.widthMm) > 0 ? Math.min(widthMm, Number(piece.widthMm)) : widthMm;
|
Number(piece?.widthMm) > 0 ? Math.min(widthMm, Number(piece.widthMm)) : widthMm;
|
||||||
|
const mapX = (xmm) => clampX(mapFinishedXOntoWorkpieceMm(xmm, refW, widthMm));
|
||||||
const edgeTolMm = 0.5;
|
const edgeTolMm = 0.5;
|
||||||
|
/**
|
||||||
|
* Face d’entrée en épaisseur pour jonctions caisse (côtés / séparateurs) :
|
||||||
|
* — dessus : sous-face (intérieur caisse) → FACE=back (MPR von unten / 3D fromBottom)
|
||||||
|
* — fond : face supérieure (intérieur caisse) → FACE=face
|
||||||
|
*/
|
||||||
|
const carcassFace = et === 'dessus' ? 'back' : 'face';
|
||||||
|
|
||||||
const cf = m.carcassFasteners;
|
const cf = m.carcassFasteners;
|
||||||
if (cf && (cf.dowelStations?.length || cf.positionsAlongJointMm?.length)) {
|
if (cf && (cf.dowelStations?.length || cf.positionsAlongJointMm?.length)) {
|
||||||
@@ -69,11 +176,15 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
|
|||||||
const zFerr = Number(cf.ferrureDepthInThicknessMm ?? CARCASS_FASTENERS.ferrureDepthInThicknessMm);
|
const zFerr = Number(cf.ferrureDepthInThicknessMm ?? CARCASS_FASTENERS.ferrureDepthInThicknessMm);
|
||||||
const zFerrEdge = Number(cf.ferrureDepthAlongLengthMm ?? 46);
|
const zFerrEdge = Number(cf.ferrureDepthAlongLengthMm ?? 46);
|
||||||
|
|
||||||
for (const xmm of jax) {
|
for (let junctionIndex = 0; junctionIndex < jax.length; junctionIndex++) {
|
||||||
|
const xmm = jax[junctionIndex];
|
||||||
const rawX = clampX(xmm);
|
const rawX = clampX(xmm);
|
||||||
const isEnd = rawX <= edgeTolMm || rawX >= refW - edgeTolMm;
|
const explicitFaceMode = cf.junctionFaceModes?.[junctionIndex];
|
||||||
|
const isEnd = explicitFaceMode
|
||||||
|
? explicitFaceMode === 'end'
|
||||||
|
: rawX <= edgeTolMm || rawX >= refW - edgeTolMm;
|
||||||
const endSide = rawX <= refW / 2 ? 'left' : 'right';
|
const endSide = rawX <= refW / 2 ? 'left' : 'right';
|
||||||
const x = clampX(mapJunctionXAcrossWidthMm(widthMm, xmm));
|
const x = mapX(mapJunctionXAcrossWidthMm(refW, xmm));
|
||||||
for (const st of stations) {
|
for (const st of stations) {
|
||||||
const along = st.holesAlongMm || [];
|
const along = st.holesAlongMm || [];
|
||||||
for (let hi = 0; hi < along.length; hi++) {
|
for (let hi = 0; hi < along.length; hi++) {
|
||||||
@@ -87,7 +198,7 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
|
|||||||
diameterMm: dTour,
|
diameterMm: dTour,
|
||||||
depthMm: zTour,
|
depthMm: zTour,
|
||||||
type: 'tourillon',
|
type: 'tourillon',
|
||||||
face: isEnd ? 'end' : 'face',
|
face: isEnd ? 'end' : carcassFace,
|
||||||
...(isEnd ? { end: endSide } : {})
|
...(isEnd ? { end: endSide } : {})
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -98,32 +209,25 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
|
|||||||
diameterMm: CARCASS_FERRURE_FACE_DIAMETER_MM,
|
diameterMm: CARCASS_FERRURE_FACE_DIAMETER_MM,
|
||||||
depthMm: isEnd ? zFerrEdge : zFerr,
|
depthMm: isEnd ? zFerrEdge : zFerr,
|
||||||
type: 'ixconnect',
|
type: 'ixconnect',
|
||||||
face: isEnd ? 'end' : 'face',
|
face: isEnd ? 'end' : carcassFace,
|
||||||
...(isEnd ? { end: endSide } : {})
|
...(isEnd ? { end: endSide } : {})
|
||||||
});
|
});
|
||||||
/* Trou Inbus : même règle que connecteur T–IX–T (un par Ixconnect ferrure). Sans ceci, les jeux côté rainure
|
/* Le Ø6 d'accès appartient au même panneau que le long Ø8 percé en chant. */
|
||||||
* (souvent issus de ferrures caisse seules) n’ont pas de trou violet Inbus en plan / G-code / 3D. */
|
if (isEnd) {
|
||||||
{
|
|
||||||
const lock = CONNECTORS.ixconnect.lockScrewInbus;
|
const lock = CONNECTORS.ixconnect.lockScrewInbus;
|
||||||
const dLock = Number(lock?.diameter) || 6;
|
const dLock = Number(lock?.diameter) || 6;
|
||||||
const zLock = Number(lock?.depth) || 9;
|
const zLock = Number(lock?.depth) || 9;
|
||||||
const epsMm = 0.1;
|
|
||||||
let xInbus = clampX(x);
|
|
||||||
if (isEnd) {
|
|
||||||
const depthAlongMm = Math.min(zFerrEdge, widthMm * 0.48, 80);
|
|
||||||
xInbus =
|
|
||||||
endSide === 'left'
|
|
||||||
? depthAlongMm / 2 + epsMm
|
|
||||||
: widthMm - depthAlongMm / 2 - epsMm;
|
|
||||||
xInbus = clampX(xInbus);
|
|
||||||
}
|
|
||||||
out.push({
|
out.push({
|
||||||
xMm: xInbus,
|
xMm: ixconnectInbusXFromEndMm({
|
||||||
|
endSide,
|
||||||
|
widthMm,
|
||||||
|
clampX
|
||||||
|
}),
|
||||||
yMm,
|
yMm,
|
||||||
diameterMm: dLock,
|
diameterMm: dLock,
|
||||||
depthMm: zLock,
|
depthMm: zLock,
|
||||||
type: 'ixconnect_inbus',
|
type: 'ixconnect_inbus',
|
||||||
face: 'face'
|
face: carcassFace
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,7 +238,14 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
|
|||||||
|
|
||||||
const ch = m.connectorHoles;
|
const ch = m.connectorHoles;
|
||||||
const joints = ch?.ixconnectJointsAlongDepth;
|
const joints = ch?.ixconnectJointsAlongDepth;
|
||||||
if (joints?.length) {
|
/* Quand les ferrures caisse (carcassFasteners) sont déjà posées, elles portent le trio
|
||||||
|
* T–IX–T + Inbus le long de chaque jonction. Réémettre ixconnectJointsAlongDepth
|
||||||
|
* (motif bord avant 20/50/80) doublonne le jeu avant — d’où deux trous au centre Ixconnect. */
|
||||||
|
const carcassOwnsHorizontalJoints =
|
||||||
|
Array.isArray(cf?.dowelStations) && cf.dowelStations.length > 0
|
||||||
|
? true
|
||||||
|
: Array.isArray(cf?.positionsAlongJointMm) && cf.positionsAlongJointMm.length > 0;
|
||||||
|
if (joints?.length && !carcassOwnsHorizontalJoints) {
|
||||||
const dTour = Number(ch.tourillonDiameter || CONNECTORS.tourillonDiameter || 8);
|
const dTour = Number(ch.tourillonDiameter || CONNECTORS.tourillonDiameter || 8);
|
||||||
const zTour = tourillonFaceDrillDepthMm(thicknessMm);
|
const zTour = tourillonFaceDrillDepthMm(thicknessMm);
|
||||||
const dFace = Number(ch.faceHole?.diameter || CONNECTORS.ixconnect.faceHole.diameter || 8);
|
const dFace = Number(ch.faceHole?.diameter || CONNECTORS.ixconnect.faceHole.diameter || 8);
|
||||||
@@ -148,14 +259,15 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
|
|||||||
* clampX(axe droit) recule sous refW−ε et la jonction droite n’est plus « extrémité » → un seul Inbus. */
|
* clampX(axe droit) recule sous refW−ε et la jonction droite n’est plus « extrémité » → un seul Inbus. */
|
||||||
const axisNominal = Number(j.axisAcrossWidthMm) || 0;
|
const axisNominal = Number(j.axisAcrossWidthMm) || 0;
|
||||||
const rawAcross = clampX(axisNominal);
|
const rawAcross = clampX(axisNominal);
|
||||||
const isEnd =
|
const isEnd = j.junctionFace
|
||||||
axisNominal <= edgeTolMm || axisNominal >= refW - edgeTolMm;
|
? j.junctionFace === 'end'
|
||||||
|
: axisNominal <= edgeTolMm || axisNominal >= refW - edgeTolMm;
|
||||||
const endSide = rawAcross <= refW / 2 ? 'left' : 'right';
|
const endSide = rawAcross <= refW / 2 ? 'left' : 'right';
|
||||||
const x = isEnd
|
const x = isEnd
|
||||||
? rawAcross <= edgeTolMm
|
? rawAcross <= edgeTolMm
|
||||||
? 0
|
? 0
|
||||||
: refW
|
: widthMm
|
||||||
: clampX(mapJunctionXAcrossWidthMm(widthMm, j.axisAcrossWidthMm));
|
: mapX(mapJunctionXAcrossWidthMm(refW, j.axisAcrossWidthMm));
|
||||||
const seq = [
|
const seq = [
|
||||||
{ axis: j.tourillon1Axis, d: dTour, z: isEnd ? zTourEdge : zTour, type: 'tourillon' },
|
{ axis: j.tourillon1Axis, d: dTour, z: isEnd ? zTourEdge : zTour, type: 'tourillon' },
|
||||||
{ axis: j.ixconnectAxis, d: isEnd ? dEdge : dFace, z: isEnd ? zEdge : zFace, type: 'ixconnect' },
|
{ axis: j.ixconnectAxis, d: isEnd ? dEdge : dFace, z: isEnd ? zEdge : zFace, type: 'ixconnect' },
|
||||||
@@ -170,37 +282,27 @@ export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
|
|||||||
diameterMm: h.d,
|
diameterMm: h.d,
|
||||||
depthMm: h.z,
|
depthMm: h.z,
|
||||||
type: h.type,
|
type: h.type,
|
||||||
face: isEnd ? 'end' : 'face',
|
face: isEnd ? 'end' : carcassFace,
|
||||||
...(isEnd ? { end: endSide } : {})
|
...(isEnd ? { end: endSide } : {})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Trou de serrage Inbus : un par Ixconnect — même (Y) que l’axe Ixconnect ; X aligné sur le centre du
|
/* Le Ø6 d'accès appartient au même panneau que le long Ø8 percé en chant. */
|
||||||
* perçage tel qu’affiché en 3D (même formule que Cnc3DViewer pour FACE=end : min(prof chant, 48 % largeur, 80 mm) / 2).
|
if (isEnd) {
|
||||||
* Sur jonction face : X = x du trio. Sur extrémité (montant) : entrée toujours face grande (perpendiculaire),
|
|
||||||
* mais X ne peut pas rester 0/refW sinon le cylindre est sur le bord au lieu du centre du violet sur chant. */
|
|
||||||
{
|
|
||||||
const lock = CONNECTORS.ixconnect.lockScrewInbus;
|
const lock = CONNECTORS.ixconnect.lockScrewInbus;
|
||||||
const dLock = Number(lock?.diameter) || 6;
|
const dLock = Number(lock?.diameter) || 6;
|
||||||
const zLock = Number(lock?.depth) || 9;
|
const zLock = Number(lock?.depth) || 9;
|
||||||
const yIx = clampY(Number(j.ixconnectAxis));
|
|
||||||
const epsMm = 0.1;
|
|
||||||
let xInbus = clampX(x);
|
|
||||||
if (isEnd) {
|
|
||||||
const depthAlongMm = Math.min(zEdge, widthMm * 0.48, 80);
|
|
||||||
xInbus =
|
|
||||||
endSide === 'left'
|
|
||||||
? depthAlongMm / 2 + epsMm
|
|
||||||
: widthMm - depthAlongMm / 2 - epsMm;
|
|
||||||
xInbus = clampX(xInbus);
|
|
||||||
}
|
|
||||||
out.push({
|
out.push({
|
||||||
xMm: xInbus,
|
xMm: ixconnectInbusXFromEndMm({
|
||||||
yMm: yIx,
|
endSide,
|
||||||
|
widthMm,
|
||||||
|
clampX
|
||||||
|
}),
|
||||||
|
yMm: clampY(Number(j.ixconnectAxis)),
|
||||||
diameterMm: dLock,
|
diameterMm: dLock,
|
||||||
depthMm: zLock,
|
depthMm: zLock,
|
||||||
type: 'ixconnect_inbus',
|
type: 'ixconnect_inbus',
|
||||||
face: 'face'
|
face: carcassFace
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,6 +471,81 @@ export function buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coulisses Legrabox — perçages de vissage sur montants / séparateurs.
|
||||||
|
* Repère plan pièce : X depuis chant AVANT (origine bas-gauche), Y depuis bas panneau.
|
||||||
|
* `machining.drawerSlideDrilling` : { holeDiameterMm, holeDepthMm, positionsXFromFrontMm,
|
||||||
|
* cotes: { leftInnerFace: { railAxesYMm }, rightInnerFace: { railAxesYMm } },
|
||||||
|
* separators: [{ faceTowardLowerModule: { railAxesYMm }, faceTowardHigherModule: { railAxesYMm } }] }
|
||||||
|
* @param {number} [partIndex] — cotes: 0 = montant gauche, 1 = droit ; séparateurs: index
|
||||||
|
*/
|
||||||
|
export function buildLegraboxRailHoleSpecsMm(piece, widthMm, heightMm, partIndex = 0) {
|
||||||
|
const et = piece?.elementType || '';
|
||||||
|
const m = piece?.machining?.drawerSlideDrilling;
|
||||||
|
if (!m?.positionsXFromFrontMm?.length) return [];
|
||||||
|
const d = Number(m.holeDiameterMm) || 5;
|
||||||
|
const z = Number(m.holeDepthMm) || 13;
|
||||||
|
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
|
||||||
|
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
const pushFace = (axesYMm, faceKey, tag) => {
|
||||||
|
if (!Array.isArray(axesYMm) || !axesYMm.length) return;
|
||||||
|
for (const y of axesYMm) {
|
||||||
|
for (const x of m.positionsXFromFrontMm) {
|
||||||
|
out.push({
|
||||||
|
xMm: clampX(x),
|
||||||
|
yMm: clampY(y),
|
||||||
|
diameterMm: d,
|
||||||
|
depthMm: z,
|
||||||
|
type: `legrabox_rail${tag ? `_${tag}` : ''}`,
|
||||||
|
face: faceKey
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (et === 'cotes' && m.cotes) {
|
||||||
|
const L = m.cotes.leftInnerFace;
|
||||||
|
const R = m.cotes.rightInnerFace;
|
||||||
|
const hasL = Array.isArray(L?.railAxesYMm) && L.railAxesYMm.length > 0;
|
||||||
|
const hasR = Array.isArray(R?.railAxesYMm) && R.railAxesYMm.length > 0;
|
||||||
|
const face = hasL && hasR ? (partIndex === 0 ? L : R) : hasL ? L : hasR ? R : null;
|
||||||
|
pushFace(face?.railAxesYMm, 'face');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (et === 'panneauxInterieurs' && Array.isArray(m.separators)) {
|
||||||
|
const sep = m.separators[partIndex];
|
||||||
|
if (!sep) return [];
|
||||||
|
pushFace(sep.faceTowardLowerModule?.railAxesYMm, 'face', 'sep_lower');
|
||||||
|
pushFace(sep.faceTowardHigherModule?.railAxesYMm, 'back', 'sep_higher');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Façades tiroir Legrabox — fixation INSERTA (cheville expansible Ø10, prof. 13 mm).
|
||||||
|
* Repère plan façade : origine bas-gauche, X/Y en mm (face arrière de la façade).
|
||||||
|
*/
|
||||||
|
export function buildLegraboxFacadeInsertaSpecsMm(piece, widthMm, heightMm) {
|
||||||
|
if ((piece?.elementType || '') !== 'tiroirFacade') return [];
|
||||||
|
const ins = piece?.machining?.legraboxInserta;
|
||||||
|
if (!ins?.holes?.length) return [];
|
||||||
|
const d = Number(ins.holeDiameterMm) || 10;
|
||||||
|
const z = Number(ins.holeDepthMm) || 13;
|
||||||
|
return ins.holes.map((h) => ({
|
||||||
|
xMm: Math.max(0, Math.min(widthMm, Number(h.xMm) || 0)),
|
||||||
|
yMm: Math.max(0, Math.min(heightMm, Number(h.yMm) || 0)),
|
||||||
|
diameterMm: d,
|
||||||
|
depthMm: z,
|
||||||
|
type: 'legrabox_inserta',
|
||||||
|
face: 'face'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDrawerReinforcementHoleSpecsMm(piece, widthMm, heightMm) {
|
export function buildDrawerReinforcementHoleSpecsMm(piece, widthMm, heightMm) {
|
||||||
if ((piece?.elementType || '') !== 'renfortsTiroir') return [];
|
if ((piece?.elementType || '') !== 'renfortsTiroir') return [];
|
||||||
const ix = piece?.machining?.drawerReinforcement?.ixconnectFixing;
|
const ix = piece?.machining?.drawerReinforcement?.ixconnectFixing;
|
||||||
@@ -402,6 +579,19 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
|
|||||||
|
|
||||||
const H = Math.max(1, Number(heightMm) || 1);
|
const H = Math.max(1, Number(heightMm) || 1);
|
||||||
const W = Math.max(1, Number(widthMm) || 1);
|
const W = Math.max(1, Number(widthMm) || 1);
|
||||||
|
const finishedW =
|
||||||
|
Number(piece?.widthMm) > 0 ? Math.min(W, Number(piece.widthMm)) : W;
|
||||||
|
const backClearanceMm =
|
||||||
|
et === 'cotes' && piece?.machining?.groove
|
||||||
|
? getBackGrooveInnerEndInsetFromBackEdgeMm()
|
||||||
|
: 0;
|
||||||
|
const backReferenceMm = Math.max(0, finishedW - backClearanceMm);
|
||||||
|
const mapAxisX = (axisMm) =>
|
||||||
|
mapFinishedXOntoWorkpieceMm(
|
||||||
|
mapJunctionXAcrossWidthMm(finishedW, axisMm),
|
||||||
|
finishedW,
|
||||||
|
W
|
||||||
|
);
|
||||||
const inset = IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM;
|
const inset = IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM;
|
||||||
if (H < 2 * inset + 5) return out;
|
if (H < 2 * inset + 5) return out;
|
||||||
|
|
||||||
@@ -420,9 +610,9 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
|
|||||||
const t2 = Number(p.tourillon2Axis);
|
const t2 = Number(p.tourillon2Axis);
|
||||||
const ix = Number(p.ixconnectAxis);
|
const ix = Number(p.ixconnectAxis);
|
||||||
const mirrorBack =
|
const mirrorBack =
|
||||||
W >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM && t2 > t1;
|
finishedW >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM && t2 > t1;
|
||||||
|
|
||||||
if (et === 'panneauxInterieurs') {
|
if (et === 'panneauxInterieurs' || (et === 'cotes' && ch.verticalJointMode === 'edge')) {
|
||||||
const seqFront = [
|
const seqFront = [
|
||||||
{ axis: t1, d: dTour, z: zTourEdge, type: 'tourillon' },
|
{ axis: t1, d: dTour, z: zTourEdge, type: 'tourillon' },
|
||||||
{ axis: ix, d: dEdge, z: zIxEdge, type: 'ixconnect' },
|
{ axis: ix, d: dEdge, z: zIxEdge, type: 'ixconnect' },
|
||||||
@@ -430,16 +620,21 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
|
|||||||
];
|
];
|
||||||
const seqBack = mirrorBack
|
const seqBack = mirrorBack
|
||||||
? [
|
? [
|
||||||
{ axis: W - t2, d: dTour, z: zTourEdge, type: 'tourillon' },
|
{ axis: backReferenceMm - t2, d: dTour, z: zTourEdge, type: 'tourillon' },
|
||||||
{ axis: W - ix, d: dEdge, z: zIxEdge, type: 'ixconnect' },
|
{ axis: backReferenceMm - ix, d: dEdge, z: zIxEdge, type: 'ixconnect' },
|
||||||
{ axis: W - t1, d: dTour, z: zTourEdge, type: 'tourillon' }
|
{ axis: backReferenceMm - t1, d: dTour, z: zTourEdge, type: 'tourillon' }
|
||||||
]
|
]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const lock = CONNECTORS.ixconnect.lockScrewInbus;
|
||||||
|
const dLock = Number(lock?.diameter) || 6;
|
||||||
|
const zLock = Number(lock?.depth) || 9;
|
||||||
|
const inbusOffset = Number(lock?.offsetFromAssemblyEdgeMm) || 25;
|
||||||
const pushEndRow = (yMm, seq, endSide) => {
|
const pushEndRow = (yMm, seq, endSide) => {
|
||||||
const cy = clampY(yMm);
|
const cy = clampY(yMm);
|
||||||
|
const inbusY = clampY(endSide === 'bottom' ? inbusOffset : H - inbusOffset);
|
||||||
for (const h of seq) {
|
for (const h of seq) {
|
||||||
const xMm = mapJunctionXAcrossWidthMm(W, Number(h.axis));
|
const xMm = mapAxisX(Number(h.axis));
|
||||||
out.push({
|
out.push({
|
||||||
xMm,
|
xMm,
|
||||||
yMm: cy,
|
yMm: cy,
|
||||||
@@ -449,6 +644,16 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
|
|||||||
face: 'end',
|
face: 'end',
|
||||||
end: endSide
|
end: endSide
|
||||||
});
|
});
|
||||||
|
if (h.type === 'ixconnect') {
|
||||||
|
out.push({
|
||||||
|
xMm,
|
||||||
|
yMm: inbusY,
|
||||||
|
diameterMm: dLock,
|
||||||
|
depthMm: zLock,
|
||||||
|
type: 'ixconnect_inbus',
|
||||||
|
face: 'face'
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -460,7 +665,8 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* cotes : face */
|
/* cotes sans habillage latéral : logement face uniquement.
|
||||||
|
* L'accès Inbus est sur la pièce opposée qui porte le long perçage en chant. */
|
||||||
const seqFront = [
|
const seqFront = [
|
||||||
{ axis: t1, d: dTour, z: zTour, type: 'tourillon' },
|
{ axis: t1, d: dTour, z: zTour, type: 'tourillon' },
|
||||||
{ axis: ix, d: dFace, z: zFace, type: 'ixconnect' },
|
{ axis: ix, d: dFace, z: zFace, type: 'ixconnect' },
|
||||||
@@ -468,15 +674,15 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
|
|||||||
];
|
];
|
||||||
const seqBack = mirrorBack
|
const seqBack = mirrorBack
|
||||||
? [
|
? [
|
||||||
{ axis: W - t2, d: dTour, z: zTour, type: 'tourillon' },
|
{ axis: backReferenceMm - t2, d: dTour, z: zTour, type: 'tourillon' },
|
||||||
{ axis: W - ix, d: dFace, z: zFace, type: 'ixconnect' },
|
{ axis: backReferenceMm - ix, d: dFace, z: zFace, type: 'ixconnect' },
|
||||||
{ axis: W - t1, d: dTour, z: zTour, type: 'tourillon' }
|
{ axis: backReferenceMm - t1, d: dTour, z: zTour, type: 'tourillon' }
|
||||||
]
|
]
|
||||||
: null;
|
: null;
|
||||||
const pushFaceRow = (yMm, seq) => {
|
const pushFaceRow = (yMm, seq) => {
|
||||||
const cy = clampY(yMm);
|
const cy = clampY(yMm);
|
||||||
for (const h of seq) {
|
for (const h of seq) {
|
||||||
const xMm = mapJunctionXAcrossWidthMm(W, Number(h.axis));
|
const xMm = mapAxisX(Number(h.axis));
|
||||||
out.push({
|
out.push({
|
||||||
xMm,
|
xMm,
|
||||||
yMm: cy,
|
yMm: cy,
|
||||||
@@ -487,10 +693,10 @@ export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heigh
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
for (const yMm of yRows) {
|
pushFaceRow(yRows[0], seqFront);
|
||||||
pushFaceRow(yMm, seqFront);
|
if (seqBack) pushFaceRow(yRows[0], seqBack);
|
||||||
if (seqBack) pushFaceRow(yMm, seqBack);
|
pushFaceRow(yRows[1], seqFront);
|
||||||
}
|
if (seqBack) pushFaceRow(yRows[1], seqBack);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+922
-14
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,350 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
addUpperBlockAboveHorizontalSpan,
|
||||||
|
getModuleVerticalBoundariesMm,
|
||||||
|
resizeHorizontalSpanUpperBlock,
|
||||||
|
resolveHorizontalSpan,
|
||||||
|
validateHorizontalSpans,
|
||||||
|
usesBlockConstruction,
|
||||||
|
columnUsesBlockConstruction,
|
||||||
|
horizontalConstructionRuns,
|
||||||
|
getAxiloPlateCentersMm,
|
||||||
|
getAxiloLateralCenterInsetMm,
|
||||||
|
getAxiloPlinthFrontSideDeductionMm,
|
||||||
|
PLINTH
|
||||||
|
} from './manufacturing.js';
|
||||||
|
import { validateConfig } from '../server/services/configValidation.js';
|
||||||
|
import { generateCuttingList } from '../server/services/cuttingEngine.js';
|
||||||
|
|
||||||
|
function makeConfig(columnCount, spanHeightMm = 900) {
|
||||||
|
const bottomMm = 300;
|
||||||
|
const topMm = bottomMm + spanHeightMm;
|
||||||
|
const bodyMm = 1800;
|
||||||
|
const modules = Array.from({ length: columnCount }, (_, index) => ({
|
||||||
|
id: `col-${index + 1}`,
|
||||||
|
type: 'penderie',
|
||||||
|
moduleCode: 'penderie',
|
||||||
|
widthMm: 600,
|
||||||
|
minWidth: 300,
|
||||||
|
maxWidth: 1200,
|
||||||
|
shelves: 0,
|
||||||
|
drawers: 0,
|
||||||
|
hasRod: false,
|
||||||
|
blockHeightsMm: [bottomMm, spanHeightMm, bodyMm - topMm],
|
||||||
|
blockBacks: [true, index % 2 === 0, false],
|
||||||
|
blockDoors: [true, false, true],
|
||||||
|
hasBack: true,
|
||||||
|
hasBackTop: false
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
installationType: 'free',
|
||||||
|
doorType: 'hinged',
|
||||||
|
widthMm: columnCount * 600,
|
||||||
|
heightMm: 1900,
|
||||||
|
depthMm: 600,
|
||||||
|
plinthHeightMm: 100,
|
||||||
|
effectiveDrawerDepthMm: 600,
|
||||||
|
hardware: { drawerType: 'visible', handleBoreHeightMm: 1000 },
|
||||||
|
modules,
|
||||||
|
horizontalSpans: [{
|
||||||
|
id: 'span-main',
|
||||||
|
moduleIds: modules.map((module) => module.id),
|
||||||
|
bottomMm,
|
||||||
|
topMm,
|
||||||
|
hasBack: true,
|
||||||
|
doorMode: 'none',
|
||||||
|
type: 'open'
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSynchronizedUpperBlock(config, expectedSpanTopMm, expectedUpperHeightMm) {
|
||||||
|
const resolved = resolveHorizontalSpan(config.horizontalSpans[0], config);
|
||||||
|
assert.equal(resolved.topMm, expectedSpanTopMm);
|
||||||
|
for (const module of config.modules) {
|
||||||
|
const boundaries = getModuleVerticalBoundariesMm(module, config);
|
||||||
|
assert.ok(boundaries.includes(expectedSpanTopMm));
|
||||||
|
assert.ok(boundaries.includes(1200));
|
||||||
|
const upperIndex = boundaries.indexOf(expectedSpanTopMm);
|
||||||
|
assert.equal(boundaries[upperIndex + 1] - boundaries[upperIndex], expectedUpperHeightMm);
|
||||||
|
assert.equal(module.blockDoors[upperIndex], true);
|
||||||
|
resolved.coveredBlocks
|
||||||
|
.find((covered) => covered.moduleId === module.id)
|
||||||
|
.blockIndexes
|
||||||
|
.forEach((blockIndex) => assert.equal(module.blockDoors[blockIndex], false));
|
||||||
|
assert.equal(module.blockHeightsMm.reduce((sum, height) => sum + height, 0), 1800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const columnCount of [2, 3]) {
|
||||||
|
test(`${columnCount}-column span adds and resizes a synchronized upper block`, () => {
|
||||||
|
const original = makeConfig(columnCount);
|
||||||
|
const added = addUpperBlockAboveHorizontalSpan(original, 'span-main');
|
||||||
|
assert.equal(added.ok, true);
|
||||||
|
assert.notEqual(added.config, original);
|
||||||
|
assert.deepEqual(original.modules[0].blockHeightsMm, [300, 900, 600]);
|
||||||
|
assertSynchronizedUpperBlock(added.config, 900, 300);
|
||||||
|
|
||||||
|
const resized = resizeHorizontalSpanUpperBlock(added.config, 'span-main', 450);
|
||||||
|
assert.equal(resized.ok, true);
|
||||||
|
assertSynchronizedUpperBlock(resized.config, 750, 450);
|
||||||
|
assert.deepEqual(validateHorizontalSpans(resized.config), []);
|
||||||
|
assert.deepEqual(validateConfig(resized.config).errors, []);
|
||||||
|
|
||||||
|
const cuttingList = generateCuttingList(resized.config, {}, resized.config.modules);
|
||||||
|
assert.ok(Array.isArray(cuttingList.pieces));
|
||||||
|
assert.ok(cuttingList.pieces.length > 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('too-short span rejects upper block without mutation', () => {
|
||||||
|
const config = makeConfig(2, 500);
|
||||||
|
const snapshot = structuredClone(config);
|
||||||
|
const result = addUpperBlockAboveHorizontalSpan(config, 'span-main');
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.code, 'span_too_short');
|
||||||
|
assert.deepEqual(config, snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resize rejects non-step heights without mutation', () => {
|
||||||
|
const added = addUpperBlockAboveHorizontalSpan(makeConfig(2), 'span-main');
|
||||||
|
assert.equal(added.ok, true);
|
||||||
|
const snapshot = structuredClone(added.config);
|
||||||
|
const result = resizeHorizontalSpanUpperBlock(added.config, 'span-main', 425);
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.code, 'invalid_height');
|
||||||
|
assert.deepEqual(added.config, snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('construction: dos → block ; sinon côté ou rayonnage → block', () => {
|
||||||
|
assert.equal(usesBlockConstruction({ type: 'penderie' }, true), true);
|
||||||
|
assert.equal(usesBlockConstruction({ type: 'penderie' }, false), false);
|
||||||
|
assert.equal(usesBlockConstruction({ type: 'tiroirs' }, false), false);
|
||||||
|
assert.equal(usesBlockConstruction({ type: 'rayonnage' }, false), true);
|
||||||
|
assert.equal(usesBlockConstruction({ moduleCode: 'rayonnage' }, false), true);
|
||||||
|
|
||||||
|
assert.equal(usesBlockConstruction({ type: 'penderie', shelves: 1 }, false), false);
|
||||||
|
assert.equal(usesBlockConstruction({ type: 'penderie', shelves: 4 }, false), true);
|
||||||
|
assert.equal(usesBlockConstruction({ type: 'mixte', shelves: 2 }, false), true);
|
||||||
|
|
||||||
|
const penderie = { type: 'penderie', shelves: 1, hasBack: false, blockBacks: [false] };
|
||||||
|
const rayonnage = { type: 'rayonnage', hasBack: false, blockBacks: [false] };
|
||||||
|
const shelvedPenderie = { type: 'penderie', shelves: 4, hasBack: false, blockBacks: [false] };
|
||||||
|
const withDos = { type: 'penderie', hasBack: true, blockBacks: [true] };
|
||||||
|
const config = { hasBack: false };
|
||||||
|
|
||||||
|
assert.equal(columnUsesBlockConstruction(penderie, config), false);
|
||||||
|
assert.equal(columnUsesBlockConstruction(rayonnage, config), true);
|
||||||
|
assert.equal(columnUsesBlockConstruction(shelvedPenderie, config), true);
|
||||||
|
assert.equal(columnUsesBlockConstruction(withDos, config), true);
|
||||||
|
|
||||||
|
const modules = [
|
||||||
|
{ type: 'penderie', widthMm: 600, hasBack: false, blockBacks: [false] },
|
||||||
|
{ type: 'penderie', widthMm: 600, hasBack: false, blockBacks: [false] },
|
||||||
|
{ type: 'rayonnage', widthMm: 500, hasBack: false, blockBacks: [false] },
|
||||||
|
{ type: 'penderie', widthMm: 600, hasBack: false, blockBacks: [false] }
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
horizontalConstructionRuns(modules, config, 'top').map(({ start, end, isBlock }) => ({ start, end, isBlock })),
|
||||||
|
[
|
||||||
|
{ start: 0, end: 1, isBlock: false },
|
||||||
|
{ start: 2, end: 2, isBlock: true },
|
||||||
|
{ start: 3, end: 3, isBlock: false }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const allPenderie = modules.map((m, i) => (
|
||||||
|
i === 2 ? { ...m, type: 'penderie', moduleCode: 'penderie', shelves: 0 } : m
|
||||||
|
));
|
||||||
|
assert.deepEqual(
|
||||||
|
horizontalConstructionRuns(allPenderie, config, 'top').map(({ start, end, isBlock }) => ({ start, end, isBlock })),
|
||||||
|
[{ start: 0, end: 3, isBlock: false }]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('horizontal block at shell top replaces per-column roofs with one panel', () => {
|
||||||
|
const modules = [1, 2, 3].map((index) => ({
|
||||||
|
id: `col-${index}`,
|
||||||
|
type: 'rayonnage',
|
||||||
|
widthMm: 600,
|
||||||
|
blockHeightsMm: [1800],
|
||||||
|
blockBacks: [false]
|
||||||
|
}));
|
||||||
|
const config = {
|
||||||
|
widthMm: 1800,
|
||||||
|
heightMm: 1900,
|
||||||
|
plinthHeightMm: 100,
|
||||||
|
modules,
|
||||||
|
horizontalSpans: [{
|
||||||
|
id: 'span-top',
|
||||||
|
moduleIds: ['col-1', 'col-2'],
|
||||||
|
bottomMm: 0,
|
||||||
|
topMm: 1800,
|
||||||
|
hasBack: false,
|
||||||
|
doorMode: 'none',
|
||||||
|
type: 'open'
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
horizontalConstructionRuns(modules, config, 'top')
|
||||||
|
.map(({ start, end, horizontalSpanId }) => ({ start, end, horizontalSpanId })),
|
||||||
|
[
|
||||||
|
{ start: 0, end: 1, horizontalSpanId: 'span-top' },
|
||||||
|
{ start: 2, end: 2, horizontalSpanId: undefined }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cutting list: merged dessus for separations, split for rayonnage', () => {
|
||||||
|
const baseModule = (index, type) => ({
|
||||||
|
id: `col-${index + 1}`,
|
||||||
|
type,
|
||||||
|
moduleCode: type,
|
||||||
|
widthMm: 600,
|
||||||
|
minWidth: 300,
|
||||||
|
maxWidth: 1200,
|
||||||
|
shelves: type === 'rayonnage' ? 4 : 0,
|
||||||
|
drawers: 0,
|
||||||
|
hasRod: type === 'penderie',
|
||||||
|
blockHeightsMm: [1800],
|
||||||
|
blockBacks: [false],
|
||||||
|
blockDoors: [true],
|
||||||
|
hasBack: false,
|
||||||
|
hasBackTop: false
|
||||||
|
});
|
||||||
|
const configOf = (types) => ({
|
||||||
|
installationType: 'free',
|
||||||
|
doorType: 'hinged',
|
||||||
|
widthMm: types.length * 600,
|
||||||
|
heightMm: 1900,
|
||||||
|
depthMm: 600,
|
||||||
|
plinthHeightMm: 100,
|
||||||
|
hasBack: false,
|
||||||
|
columnMode: 'separations',
|
||||||
|
hardware: { drawerType: 'visible', handleBoreHeightMm: 1000 },
|
||||||
|
modules: types.map((type, index) => baseModule(index, type)),
|
||||||
|
horizontalSpans: []
|
||||||
|
});
|
||||||
|
const dessusPieces = (config) =>
|
||||||
|
generateCuttingList(config, {}, config.modules).pieces.filter((piece) => piece.elementType === 'dessus');
|
||||||
|
|
||||||
|
const merged = dessusPieces(configOf(['penderie', 'penderie', 'penderie']));
|
||||||
|
assert.equal(merged.length, 1);
|
||||||
|
assert.match(merged[0].id, /col1-3/);
|
||||||
|
|
||||||
|
const split = dessusPieces(configOf(['penderie', 'rayonnage', 'penderie']));
|
||||||
|
assert.equal(split.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AXILO feet: 4 corners plus a pair every 600mm on the armoire', () => {
|
||||||
|
const inset = getAxiloLateralCenterInsetMm(true);
|
||||||
|
const narrow = getAxiloPlateCentersMm(500, 610, {
|
||||||
|
leftEdgeCenterInsetMm: inset,
|
||||||
|
rightEdgeCenterInsetMm: inset
|
||||||
|
});
|
||||||
|
assert.equal(narrow.length, 4, 'A bay shorter than 600mm keeps only the 4 corner feet');
|
||||||
|
|
||||||
|
const wide = getAxiloPlateCentersMm(2362, 610, {
|
||||||
|
leftEdgeCenterInsetMm: inset,
|
||||||
|
rightEdgeCenterInsetMm: inset
|
||||||
|
});
|
||||||
|
assert.equal(wide.length, 10, '2400mm armoire: 4 corners + 3 intermediate pairs');
|
||||||
|
const xs = [...new Set(wide.map((c) => c.xMm))].sort((a, b) => a - b);
|
||||||
|
assert.equal(xs.length, 5);
|
||||||
|
for (let i = 1; i < xs.length; i++) {
|
||||||
|
assert.ok(xs[i] - xs[i - 1] <= PLINTH.maxFootSpacingMm + 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = getAxiloPlateCentersMm(761, 610, {
|
||||||
|
leftEdgeCenterInsetMm: inset,
|
||||||
|
rightEdgeCenterInsetMm: inset,
|
||||||
|
spanWidthMm: 2362,
|
||||||
|
panelOffsetMm: 0,
|
||||||
|
assignMinMm: 0,
|
||||||
|
assignMaxMm: 781,
|
||||||
|
assignMaxInclusive: false
|
||||||
|
});
|
||||||
|
const mid = getAxiloPlateCentersMm(759, 610, {
|
||||||
|
leftEdgeCenterInsetMm: inset,
|
||||||
|
rightEdgeCenterInsetMm: inset,
|
||||||
|
spanWidthMm: 2362,
|
||||||
|
panelOffsetMm: 801.5,
|
||||||
|
assignMinMm: 781,
|
||||||
|
assignMaxMm: 1581,
|
||||||
|
assignMaxInclusive: false
|
||||||
|
});
|
||||||
|
const right = getAxiloPlateCentersMm(761, 610, {
|
||||||
|
leftEdgeCenterInsetMm: inset,
|
||||||
|
rightEdgeCenterInsetMm: inset,
|
||||||
|
spanWidthMm: 2362,
|
||||||
|
panelOffsetMm: 1601.5,
|
||||||
|
assignMinMm: 1581,
|
||||||
|
assignMaxMm: 2362,
|
||||||
|
assignMaxInclusive: true
|
||||||
|
});
|
||||||
|
assert.equal(left.length + mid.length + right.length, 10, 'Split fonds share the armoire foot count');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grooved bottom staggers the rear Ixconnect row away from AXILO holes', () => {
|
||||||
|
const config = makeConfig(1);
|
||||||
|
const bottom = generateCuttingList(config, {}, config.modules).pieces.find(
|
||||||
|
(piece) =>
|
||||||
|
piece.elementType === 'fond' &&
|
||||||
|
piece.machining?.groove &&
|
||||||
|
piece.machining?.axiloFootPlates &&
|
||||||
|
piece.machining?.carcassFasteners
|
||||||
|
);
|
||||||
|
assert.ok(bottom, 'Expected a grooved bottom with AXILO and carcass machining');
|
||||||
|
|
||||||
|
const rearFootYs = bottom.machining.axiloFootPlates.centers
|
||||||
|
.filter((center) => center.row === 'rear')
|
||||||
|
.flatMap((center) =>
|
||||||
|
bottom.machining.axiloFootPlates.holeOffsetsFromCenterMm.map(
|
||||||
|
(offset) => center.yMm + offset.y
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const rearJoint = bottom.machining.carcassFasteners.dowelStations.at(-1);
|
||||||
|
const minimumAxisClearanceMm = Math.min(
|
||||||
|
...rearJoint.holesAlongMm.flatMap((jointY) =>
|
||||||
|
rearFootYs.map((footY) => Math.abs(jointY - footY))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
minimumAxisClearanceMm >= 14,
|
||||||
|
`Rear T–IX–T and AXILO axes need clearance; received ${minimumAxisClearanceMm} mm`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('niche front plinth spans the full wardrobe width', () => {
|
||||||
|
const config = {
|
||||||
|
...makeConfig(3),
|
||||||
|
installationType: 'niche',
|
||||||
|
exteriorFinishesEnabled: true,
|
||||||
|
sideFinishLeftEnabled: true,
|
||||||
|
sideFinishRightEnabled: true
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(getAxiloPlinthFrontSideDeductionMm(config, 'left'), 0);
|
||||||
|
assert.equal(getAxiloPlinthFrontSideDeductionMm(config, 'right'), 0);
|
||||||
|
|
||||||
|
const plinth = generateCuttingList(config, {}, config.modules).pieces
|
||||||
|
.find((piece) => piece.elementType === 'plintheBas');
|
||||||
|
assert.ok(plinth);
|
||||||
|
assert.equal(plinth.widthMm, config.widthMm);
|
||||||
|
|
||||||
|
const cuttingList = generateCuttingList(config, {}, config.modules);
|
||||||
|
const sideFillers = cuttingList.pieces.find((piece) => piece.elementType === 'fileurCotes');
|
||||||
|
const topFiller = cuttingList.pieces.find((piece) => piece.elementType === 'fileurHaut');
|
||||||
|
assert.ok(sideFillers);
|
||||||
|
assert.equal(sideFillers.widthMm, 50);
|
||||||
|
assert.equal(sideFillers.heightMm, config.heightMm + 30);
|
||||||
|
assert.equal(sideFillers.quantity, 2);
|
||||||
|
assert.ok(topFiller);
|
||||||
|
assert.equal(topFiller.widthMm, config.widthMm + 60);
|
||||||
|
assert.equal(topFiller.heightMm, 50);
|
||||||
|
assert.equal(topFiller.quantity, 1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user