Co-authored-by: Cursor <[email protected]>
This commit is contained in:
lejay
2026-05-28 07:20:38 +02:00
co-authored by Cursor
parent 6923c319a1
commit 6c0d7e47b8
81 changed files with 21780 additions and 2986 deletions
+4
View File
@@ -1,6 +1,10 @@
node_modules/
server/data/
.env
.claude/
*.docx
~$*
~WRL*.tmp
*.db
*.sqlite
.DS_Store
+1 -11
View File
@@ -48,7 +48,6 @@
"version": "7.28.6",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -300,7 +299,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -903,7 +901,6 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz",
"integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.26.7",
@@ -1397,7 +1394,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1422,7 +1418,6 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz",
"integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
@@ -1537,7 +1532,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2013,7 +2007,6 @@
"node_modules/react": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -2036,7 +2029,6 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -2262,8 +2254,7 @@
"version": "0.169.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz",
"integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/three-mesh-bvh": {
"version": "0.7.8",
@@ -2422,7 +2413,6 @@
"version": "5.4.21",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
+2
View File
@@ -7,6 +7,7 @@ import Configurator from './pages/Configurator';
import Login from './pages/Login';
import Admin from './pages/Admin';
import Materials from './pages/Materials';
import Settings from './pages/Settings';
export default function App() {
return (
@@ -19,6 +20,7 @@ export default function App() {
<Route path="/configurateur" element={<ProtectedRoute><Configurator /></ProtectedRoute>} />
<Route path="/materiaux" element={<ProtectedRoute><Materials /></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute><Admin /></ProtectedRoute>} />
<Route path="/parametres" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
</Routes>
</Layout>
</AuthProvider>
+169 -3
View File
@@ -30,6 +30,83 @@ export async function fetchSettings() {
return res.json();
}
export async function updateSettings(data) {
const res = await fetch(`${API_BASE}/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// Engineering rules CRUD
export async function fetchEngineeringRules() {
const res = await fetch(`${API_BASE}/engineering-rules`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function createEngineeringRule(data) {
const res = await fetch(`${API_BASE}/engineering-rules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function updateEngineeringRule(id, data) {
const res = await fetch(`${API_BASE}/engineering-rules/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function deleteEngineeringRule(id) {
const res = await fetch(`${API_BASE}/engineering-rules/${id}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// Suppliers CRUD
export async function fetchSuppliers() {
const res = await fetch(`${API_BASE}/suppliers`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function createSupplier(data) {
const res = await fetch(`${API_BASE}/suppliers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function updateSupplier(id, data) {
const res = await fetch(`${API_BASE}/suppliers/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function deleteSupplier(id) {
const res = await fetch(`${API_BASE}/suppliers/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
}
export async function calculatePrice(config) {
const res = await fetch(`${API_BASE}/price`, {
method: 'POST',
@@ -46,6 +123,57 @@ export async function fetchOrders() {
return res.json();
}
export async function fetchOrderSupplierVouchers(orderId) {
const res = await fetch(`${API_BASE}/orders/${orderId}/supplier-vouchers`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchOrderPlanExports(orderId) {
const res = await fetch(`${API_BASE}/orders/${orderId}/plan-exports`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function saveOrderPlanExport(orderId, payload) {
const res = await fetch(`${API_BASE}/orders/${orderId}/plan-exports`, {
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 generateOrderPlanExports(orderId) {
const res = await fetch(`${API_BASE}/orders/${orderId}/plan-exports/generate`, {
method: 'POST'
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchOrderProductionDetails(orderId) {
const res = await fetch(`${API_BASE}/orders/${orderId}/production-details`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export function getOrderPlanExportDownloadUrl(orderId, exportId) {
return `${API_BASE}/orders/${orderId}/plan-exports/${exportId}/download`;
}
export function getOrderPlanExportViewUrl(orderId, exportId) {
return `${API_BASE}/orders/${orderId}/plan-exports/${exportId}/download?disposition=inline`;
}
export async function deleteOrderPlanExport(orderId, exportId) {
const res = await fetch(`${API_BASE}/orders/${orderId}/plan-exports/${exportId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error(await res.text());
}
export async function submitOrder(order) {
const res = await fetch(`${API_BASE}/orders`, {
method: 'POST',
@@ -70,8 +198,27 @@ export async function triggerFormexSync() {
/**
* Get the SSE stream URL for real-time sync progress
*/
export function getFormexSyncStreamUrl() {
return `${API_BASE}/sync/formex/stream`;
export function getFormexSyncStreamUrl(force = false) {
const url = `${API_BASE}/sync/formex/stream`;
return force ? `${url}?force=1` : url;
}
export function getHaefeleSyncStreamUrl(urls = []) {
const cleaned = (urls || []).map((v) => (typeof v === 'string' ? v.trim() : '')).filter(Boolean);
const params = new URLSearchParams();
for (const url of cleaned) params.append('url', url);
const query = params.toString();
return query ? `${API_BASE}/sync/haefele/stream?${query}` : `${API_BASE}/sync/haefele/stream`;
}
export async function triggerHaefeleSync(payload) {
const res = await fetch(`${API_BASE}/sync/haefele`, {
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 fetchSyncLogs(limit = 20) {
@@ -86,13 +233,26 @@ export async function fetchSyncStatus() {
return res.json();
}
export async function fetchHaefeleMaterials({ 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}/haefele/materials?${params.toString()}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// Formex Materials CRUD
export async function fetchFormexMaterials({ search = '', collection = '', category = '', active = null, page = 1, limit = 25 } = {}) {
export async function fetchFormexMaterials({ search = '', collection = '', category = '', supplier = '', active = null, page = 1, limit = 25 } = {}) {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (collection) params.set('collection', collection);
if (category) params.set('category', category);
if (supplier) params.set('supplier', supplier);
if (active !== null) params.set('active', active ? '1' : '0');
params.set('page', String(page));
params.set('limit', String(limit));
@@ -102,6 +262,12 @@ export async function fetchFormexMaterials({ search = '', collection = '', categ
return res.json();
}
export async function fetchMaterialSuppliers() {
const res = await fetch(`${API_BASE}/formex/suppliers`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchFormexMaterial(id) {
const res = await fetch(`${API_BASE}/formex/materials/${id}`);
if (!res.ok) throw new Error(await res.text());
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
.wrap {
position: relative;
width: 100%;
height: 360px;
border-radius: 8px;
overflow: hidden;
background: #f8fafc;
touch-action: none;
}
.axisBar {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
z-index: 2;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
pointer-events: auto;
}
.axisLabel {
font-size: 0.65rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-right: 2px;
}
.axisBtn {
padding: 4px 8px;
border: 1px solid #cbd5e1;
border-radius: 5px;
background: rgba(255, 255, 255, 0.92);
color: #334155;
font-size: 0.72rem;
font-weight: 600;
cursor: pointer;
font-variant-numeric: tabular-nums;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.axisBtn:hover {
background: #fff;
border-color: #94a3b8;
color: #0f172a;
}
.axisBtn:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 1px;
}
+157 -18
View File
@@ -7,6 +7,33 @@ import styles from './ColorPickerModal.module.css';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const PRIORITY_STORAGE_KEY = 'apex-ai-rec-priorities';
const DEFAULT_PRIORITIES = { price: 3, quality: 3, durability: 3, aesthetics: 3 };
function loadStoredPriorities() {
try {
const raw = localStorage.getItem(PRIORITY_STORAGE_KEY);
if (!raw) return { ...DEFAULT_PRIORITIES };
const p = JSON.parse(raw);
return {
price: Math.min(5, Math.max(1, Number(p.price) || DEFAULT_PRIORITIES.price)),
quality: Math.min(5, Math.max(1, Number(p.quality) || DEFAULT_PRIORITIES.quality)),
durability: Math.min(5, Math.max(1, Number(p.durability) || DEFAULT_PRIORITIES.durability)),
aesthetics: Math.min(5, Math.max(1, Number(p.aesthetics) || DEFAULT_PRIORITIES.aesthetics))
};
} catch {
return { ...DEFAULT_PRIORITIES };
}
}
const PRIORITY_LABELS = {
price: 'Prix & rapport qualité-prix',
quality: 'Qualité & finition',
durability: 'Durabilité & entretien',
aesthetics: 'Esthétique & harmonie'
};
const ELEMENT_LABELS = {
plintheBas: 'Plinthe bas',
fileur: 'Fileur',
@@ -42,8 +69,17 @@ export default function ColorPickerModal({
const [hoveredFinish, setHoveredFinish] = useState(null);
const [aiRecs, setAiRecs] = useState([]);
const [aiLoading, setAiLoading] = useState(false);
const [priorities, setPriorities] = useState(loadStoredPriorities);
const tooltipRef = useRef(null);
useEffect(() => {
try {
localStorage.setItem(PRIORITY_STORAGE_KEY, JSON.stringify(priorities));
} catch {
/* ignore */
}
}, [priorities]);
useEffect(() => {
if (isOpen) {
setSelectedFinish(currentFinish);
@@ -51,18 +87,30 @@ export default function ColorPickerModal({
setSelectedCollection('all');
setHoveredFinish(null);
setAiRecs([]);
// Fetch AI recommendations
if (config && finishes.length > 0) {
}
}, [isOpen, currentFinish]);
useEffect(() => {
if (!isOpen) {
setAiLoading(false);
return undefined;
}
if (!config || finishes.length === 0) {
return undefined;
}
setAiLoading(true);
const controller = new AbortController();
const t = setTimeout(() => {
fetch(`${API_BASE}/api/ai/recommend-materials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
priorities,
config: {
width: config.totalWidth,
height: config.height,
depth: config.depth,
installationType: config.installationType,
columns: config.modules?.map(m => ({
type: m.type || m.moduleCode,
width: m.width
@@ -70,22 +118,37 @@ export default function ColorPickerModal({
materialName: config.finishLabel || null,
doorType: config.doorType
},
finishes: finishes.map(f => ({ id: f.id, name: f.name || f.label, collection: f.collection, code: f.code }))
finishes: finishes.map(f => ({
id: f.id,
name: f.name || f.label,
collection: f.collection,
code: f.code,
price_chf: f.price_chf != null ? Number(f.price_chf) : undefined
}))
}),
signal: controller.signal
})
.then(r => r.ok ? r.json() : null)
.then(r => (r.ok ? r.json() : null))
.then(data => {
if (data?.recommendations) {
setAiRecs(data.recommendations);
}
})
.catch(() => {})
.finally(() => setAiLoading(false));
return () => controller.abort();
}
}
}, [isOpen, currentFinish]);
.finally(() => {
if (!controller.signal.aborted) setAiLoading(false);
});
}, 320);
return () => {
clearTimeout(t);
controller.abort();
};
}, [isOpen, currentFinish, priorities, config, finishes]);
const setPriority = useCallback((key, value) => {
const n = Math.min(5, Math.max(1, Number(value) || 3));
setPriorities(prev => ({ ...prev, [key]: n }));
}, []);
const collections = useMemo(() => {
const cols = [...new Set(finishes.map(f => f.collection).filter(Boolean))];
@@ -169,12 +232,41 @@ export default function ColorPickerModal({
{/* Swatch grid */}
<div className={styles.swatchArea}>
{/* AI Recommendations */}
{(aiRecs.length > 0 || aiLoading) && (
{config && finishes.length > 0 && (
<div className={styles.priorityPanel}>
<div className={styles.priorityTitleRow}>
<span className={styles.aiBadge}>IA</span>
<span className={styles.priorityTitle}>Priorit&eacute;s de recommandation</span>
</div>
<p className={styles.priorityHint}>
1 = peu important, 5 = tr&egrave;s important. L&apos;IA pond&egrave;re prix, qualit&eacute;, durabilit&eacute; et style
et explique chaque suggestion en fonction de vos r&eacute;glages.
</p>
<div className={styles.priorityGrid}>
{(['price', 'quality', 'durability', 'aesthetics']).map((key) => (
<label key={key} className={styles.priorityRow}>
<span className={styles.priorityLabel}>{PRIORITY_LABELS[key]}</span>
<input
type="range"
className={styles.priorityRange}
min={1}
max={5}
step={1}
value={priorities[key]}
onChange={(e) => setPriority(key, e.target.value)}
/>
<span className={styles.priorityValue}>{priorities[key]}</span>
</label>
))}
</div>
</div>
)}
{(aiRecs.length > 0 || aiLoading) && config && finishes.length > 0 && (
<div className={styles.aiSection}>
<div className={styles.aiHeader}>
<span className={styles.aiBadge}>IA</span>
<span className={styles.aiLabel}>Recommandations</span>
<span className={styles.aiLabel}>Suggestions (justifi&eacute;es selon vos priorit&eacute;s)</span>
</div>
{aiLoading ? (
<div className={styles.aiShimmer}>
@@ -184,28 +276,37 @@ export default function ColorPickerModal({
</div>
) : (
<div className={styles.aiSwatches}>
{aiRecs.map(rec => {
{aiRecs.map((rec) => {
const finish = finishes.find(f => f.id === rec.finishId);
if (!finish) return null;
const isSelected = selectedFinish?.id === finish.id;
const summary = rec.summary || rec.reason || '';
const detail = {
summary,
criteria: rec.criteria || {}
};
return (
<div key={`ai-${finish.id}`} className={styles.aiSwatchCard}>
<button
key={`ai-${finish.id}`}
type="button"
className={`${styles.swatch} ${isSelected ? styles.swatchSelected : ''} ${styles.aiSwatch}`}
onClick={() => setSelectedFinish(finish)}
onMouseEnter={() => setHoveredFinish({ ...finish, aiReason: rec.reason })}
onMouseEnter={() => setHoveredFinish({ ...finish, aiDetail: detail })}
onMouseLeave={() => setHoveredFinish(null)}
title={rec.reason}
title={summary}
>
{finish.image_url ? (
<img src={finish.image_url} alt="" className={styles.swatchImg} loading="lazy" />
) : (
<div className={styles.swatchColor} style={{ backgroundColor: finish.hexColor || '#ccc' }} />
)}
{isSelected && <span className={styles.swatchCheck}>✓</span>}
<span className={styles.aiSwatchBadge}>★</span>
{isSelected && <span className={styles.swatchCheck}>&check;</span>}
<span className={styles.aiSwatchBadge}>&#9733;</span>
</button>
{summary ? (
<p className={styles.aiCardSummary}>{summary}</p>
) : null}
</div>
);
})}
</div>
@@ -253,6 +354,7 @@ export default function ColorPickerModal({
{/* Hover detail strip -- shows info of hovered swatch */}
{hoveredFinish && (
<div className={styles.hoverDetail} ref={tooltipRef}>
<div className={styles.hoverDetailTop}>
{hoveredFinish.image_url && (
<img src={hoveredFinish.image_url} alt="" className={styles.hoverImg} />
)}
@@ -265,6 +367,43 @@ export default function ColorPickerModal({
)}
</div>
</div>
{hoveredFinish.aiDetail?.summary && (
<div className={styles.aiExplain}>
<p className={styles.aiExplainLead}>{hoveredFinish.aiDetail.summary}</p>
{(hoveredFinish.aiDetail.criteria?.price ||
hoveredFinish.aiDetail.criteria?.quality ||
hoveredFinish.aiDetail.criteria?.durability ||
hoveredFinish.aiDetail.criteria?.aesthetics) && (
<dl className={styles.aiExplainDl}>
{hoveredFinish.aiDetail.criteria.price ? (
<>
<dt>Prix</dt>
<dd>{hoveredFinish.aiDetail.criteria.price}</dd>
</>
) : null}
{hoveredFinish.aiDetail.criteria.quality ? (
<>
<dt>Qualit&eacute;</dt>
<dd>{hoveredFinish.aiDetail.criteria.quality}</dd>
</>
) : null}
{hoveredFinish.aiDetail.criteria.durability ? (
<>
<dt>Durabilit&eacute;</dt>
<dd>{hoveredFinish.aiDetail.criteria.durability}</dd>
</>
) : null}
{hoveredFinish.aiDetail.criteria.aesthetics ? (
<>
<dt>Style</dt>
<dd>{hoveredFinish.aiDetail.criteria.aesthetics}</dd>
</>
) : null}
</dl>
)}
</div>
)}
</div>
)}
</div>
</div>
@@ -220,8 +220,9 @@
/* --- Hover detail strip (bottom of swatch area) --- */
.hoverDetail {
display: flex;
align-items: center;
gap: 10px;
flex-direction: column;
align-items: stretch;
gap: 0;
padding: 8px 10px;
margin-top: 8px;
background: var(--bg-subtle);
@@ -230,6 +231,12 @@
flex-shrink: 0;
}
.hoverDetailTop {
display: flex;
align-items: center;
gap: 10px;
}
.hoverImg {
width: 40px;
height: 40px;
@@ -411,9 +418,11 @@
}
}
/* AI Recommendations section */
/* AI Recommendations section — compact strip (swatches use fixed size, not grid) */
.aiSection {
padding: 8px 12px;
align-self: flex-start;
max-width: 100%;
padding: 6px 10px;
margin-bottom: 8px;
background: linear-gradient(135deg, #f8f7ff 0%, #eef2ff 100%);
border-radius: 8px;
@@ -446,12 +455,17 @@
.aiSwatches {
display: flex;
gap: 6px;
gap: 10px;
flex-wrap: wrap;
align-items: flex-start;
}
/* Override .swatch { width: 100% } — in flex layout that made each rec span the full row */
.aiSwatch {
position: relative;
width: 40px;
max-width: 40px;
flex: 0 0 40px;
border-color: rgba(79, 70, 229, 0.3) !important;
}
@@ -477,8 +491,8 @@
}
.aiShimmerDot {
width: 48px;
height: 48px;
width: 40px;
height: 40px;
border-radius: 6px;
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
background-size: 200% 100%;
@@ -489,3 +503,126 @@
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* IA — priorités utilisateur */
.priorityPanel {
padding: 8px 10px 10px;
margin-bottom: 8px;
background: #fafafa;
border-radius: 8px;
border: 1px solid var(--border-light);
align-self: flex-start;
width: 100%;
max-width: 100%;
}
.priorityTitleRow {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.priorityTitle {
font-size: 0.78rem;
font-weight: 600;
color: var(--text);
}
.priorityHint {
font-size: 0.7rem;
color: var(--text-muted);
margin: 0 0 8px;
line-height: 1.35;
}
.priorityGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 14px;
}
@media (max-width: 720px) {
.priorityGrid {
grid-template-columns: 1fr;
}
}
.priorityRow {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(60px, 1fr) 22px;
align-items: center;
gap: 6px;
font-size: 0.72rem;
color: var(--text);
}
.priorityLabel {
min-width: 0;
}
.priorityRange {
width: 100%;
min-width: 0;
accent-color: var(--action, #4f46e5);
}
.priorityValue {
font-weight: 600;
font-size: 0.75rem;
color: var(--action, #4f46e5);
text-align: right;
}
.aiSwatchCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
max-width: 152px;
}
.aiCardSummary {
margin: 0;
font-size: 0.65rem;
line-height: 1.3;
color: var(--text-muted);
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.aiExplain {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-light);
}
.aiExplainLead {
margin: 0 0 8px;
font-size: 0.78rem;
color: var(--text);
line-height: 1.4;
}
.aiExplainDl {
margin: 0;
display: grid;
grid-template-columns: minmax(52px, auto) 1fr;
gap: 4px 10px;
font-size: 0.72rem;
}
.aiExplainDl dt {
margin: 0;
font-weight: 600;
color: var(--text-muted);
}
.aiExplainDl dd {
margin: 0;
color: var(--text);
line-height: 1.35;
}
+569 -22
View File
@@ -2,10 +2,334 @@
* CuttingListTable - Display cutting list in a table format
* Shows pieces grouped by finish with dimensions and quantities
*/
import { useMemo } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import {
CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM,
CARCASS_FASTENERS,
CONNECTORS,
tourillonFaceDrillDepthMm
} from '@shared/manufacturing.js';
import Cnc3DViewer, { getCncPartsForPiece } from './Cnc3DViewer';
import styles from './CuttingListTable.module.css';
export default function CuttingListTable({ cuttingList, onExport }) {
const CNC_MODAL_STORAGE_KEY = 'apex.cnc3d.modal';
/** Must match server `generatePiecePlanSvg` in cuttingEngine.js (multi-view row layout). */
const PIECE_PLAN_LAYOUT = {
margin: 28,
viewW: 330,
viewH: 220,
panelGap: 24,
yA: 42
};
function getPieceDepthMm(piece, cuttingListConfigDepthMm) {
const t = piece?.elementType;
const width = piece?.widthMm;
const height = piece?.heightMm;
// For readability in the cutting list, "Profondeur" is the dimension along wardrobe depth.
// Depending on the panel type, this maps to either width or height.
const depthIsWidth = new Set(['cotes', 'cotesDoublage', 'fileur', 'habillageFinitionGauche', 'habillageFinitionDroite']);
const depthIsHeight = new Set(['dessus', 'fond', 'habillageFinitionDessus']);
if (depthIsWidth.has(t)) return width ?? null;
if (depthIsHeight.has(t)) return height ?? null;
const configDepth = Number(cuttingListConfigDepthMm);
if (Number.isFinite(configDepth) && configDepth > 0) {
if (Number(width) === configDepth) return width;
if (Number(height) === configDepth) return height;
}
return null;
}
/** Nombre d'exemplaires MPR pour une ligne (aligné sur le serveur). */
function countMprInstancesForPiece(piece) {
const qty = Math.max(1, Math.round(Number(piece?.quantity) || 1));
if (
piece?.elementType === 'cotes' &&
qty === 2 &&
piece.id === 'piece-cotes' &&
String(piece.description || '').includes('gauche et droit')
) {
return 2;
}
return qty;
}
function getPiecePlanPanelPercents(svgEl) {
let vbW = PIECE_PLAN_LAYOUT.margin + 3 * PIECE_PLAN_LAYOUT.viewW + 2 * PIECE_PLAN_LAYOUT.panelGap + PIECE_PLAN_LAYOUT.margin;
let vbH = 478;
if (svgEl) {
if (svgEl.viewBox && svgEl.viewBox.baseVal.width > 0) {
vbW = svgEl.viewBox.baseVal.width;
vbH = svgEl.viewBox.baseVal.height;
} else if (svgEl.width && svgEl.width.baseVal) {
vbW = svgEl.width.baseVal.value;
vbH = svgEl.height?.baseVal?.value || vbH;
}
}
const { margin, viewW, panelGap, yA, viewH } = PIECE_PLAN_LAYOUT;
const xA = margin;
const xB = xA + viewW + panelGap;
const xC = xB + viewW + panelGap;
return {
vbW,
vbH,
panels: [
{ leftPct: (xA / vbW) * 100, widthPct: (viewW / vbW) * 100, topPct: (yA / vbH) * 100, heightPct: (viewH / vbH) * 100 },
{ leftPct: (xB / vbW) * 100, widthPct: (viewW / vbW) * 100, topPct: (yA / vbH) * 100, heightPct: (viewH / vbH) * 100 },
{ leftPct: (xC / vbW) * 100, widthPct: (viewW / vbW) * 100, topPct: (yA / vbH) * 100, heightPct: (viewH / vbH) * 100 }
]
};
}
export default function CuttingListTable({ cuttingList, onExportMprPiece, cncProgram = '', planRestoreKey = '' }) {
const [previewPiece, setPreviewPiece] = useState(null);
const [cncModal, setCncModal] = useState(null); // { piece, segments, instanceIndex }
const cncRestoreDone = useRef(false);
const [lightbox, setLightbox] = useState(null); // { views, index, title }
const piecePlanHostRef = useRef(null);
const [planPanelLayout, setPlanPanelLayout] = useState(() => getPiecePlanPanelPercents(null));
const [mprInstanceByPieceId, setMprInstanceByPieceId] = useState({});
const [mprExportingPieceId, setMprExportingPieceId] = useState(null);
function openPiecePlan(piece) {
if (!piece?.piecePlanSvg) return;
setPreviewPiece(piece);
}
function openCnc3d(piece) {
const segments = getCncPartsForPiece(cuttingList, piece, cncProgram);
if (!segments.length) return;
setCncModal({ piece, segments, instanceIndex: 0 });
}
async function handleExportMprRow(piece) {
if (!onExportMprPiece || !piece?.id) return;
const instChoice = mprInstanceByPieceId[piece.id] ?? 'all';
const instanceIndex = instChoice === 'all' ? null : Number(instChoice);
setMprExportingPieceId(piece.id);
try {
await onExportMprPiece(piece, instanceIndex);
} finally {
setMprExportingPieceId(null);
}
}
function closeCncModal() {
setCncModal(null);
try {
sessionStorage.removeItem(CNC_MODAL_STORAGE_KEY);
} catch {
// ignore
}
}
useEffect(() => {
if (!planRestoreKey || !cuttingList?.pieces?.length || !cncProgram) return;
if (cncRestoreDone.current) return;
cncRestoreDone.current = true;
try {
const raw = sessionStorage.getItem(CNC_MODAL_STORAGE_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (data.configKey !== planRestoreKey) {
sessionStorage.removeItem(CNC_MODAL_STORAGE_KEY);
return;
}
const piece = cuttingList.pieces.find((p) => p.id === data.pieceId);
if (!piece) return;
const segments = getCncPartsForPiece(cuttingList, piece, cncProgram);
if (!segments.length) return;
const idx = Math.min(Math.max(0, Number(data.instanceIndex) || 0), segments.length - 1);
setCncModal({ piece, segments, instanceIndex: idx });
} catch {
// ignore
}
}, [cuttingList, cncProgram, planRestoreKey]);
useEffect(() => {
if (!planRestoreKey || !cncModal) return;
try {
sessionStorage.setItem(
CNC_MODAL_STORAGE_KEY,
JSON.stringify({
pieceId: cncModal.piece.id,
instanceIndex: cncModal.instanceIndex,
configKey: planRestoreKey
})
);
} catch {
// ignore
}
}, [cncModal, planRestoreKey]);
function getPieceMaterialColor(piece) {
const explicit =
piece?.hexColor ||
piece?.colorHex ||
piece?.finishHexColor ||
piece?.finishColor ||
piece?.finish?.hexColor ||
piece?.finish?.colorHex;
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
const key = String(piece?.finishLabel || piece?.finishId || piece?.elementType || 'material');
let hash = 0;
for (let i = 0; i < key.length; i++) hash = ((hash << 5) - hash) + key.charCodeAt(i);
const palette = ['#c4a574', '#b89a6a', '#a98a5e', '#d4c4a4', '#b09060', '#8b7355'];
return palette[Math.abs(hash) % palette.length];
}
function buildManufacturingRows(piece) {
const m = piece?.machining || {};
const rows = [
['Dimensions finies', `${piece.widthMm} x ${piece.heightMm} x ${piece.thicknessMm} mm`],
['Dimensions brutes CNC', `${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm`],
['Chants', `${piece.edgeBanding?.sides || 0} côté(s) · épaisseur ${piece.edgeBanding?.thickness || 0} mm`]
];
if (m.system32) {
rows.push([
'System 32',
`Ø${m.system32.holeDiameter} · ${m.system32.throughHole ? 'traversant' : `prof. ${m.system32.holeDepth || 12} mm`} · pas ${m.system32.pitch || 32} mm · rangées ${m.system32.rows || 2}`
]);
}
if (m.hingeCups) {
rows.push([
'Cuvettes charnières',
`Ø${m.hingeCups.diameter} · prof. ${m.hingeCups.cupDepth} mm · positions ${m.hingeCups.positionsFromTop?.map(v => `${Math.round(v)}mm`).join(', ') || '-'}`
]);
}
if (m.carcassFasteners?.positionsAlongJointMm?.length) {
const cf = m.carcassFasteners;
const axes = (cf.positionsAlongJointMm || []).map((v) => `${Math.round(v)}`).join(', ');
const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset;
const nF = cf.positionsAlongJointMm.length;
const pat =
nF >= 3
? '3 ferrures/jonction (50 / milieu / 50)'
: nF === 2
? `2 ferrures/jonction (50/50, sans milieu si arête ≤ ${CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM} mm)`
: `${nF} ferrure(s)/jonction`;
rows.push([
'Ferrures corps (jonctions côtés/séparateurs)',
`${cf.jointCount || 1} jonctions · ${pat} · centres ${axes} mm · perçage ferrure ${cf.ferrureDepthAlongLengthMm ?? 46}/${cf.ferrureDepthInThicknessMm ?? 12} mm · +2 tourillons Ø${cf.dowelDiameter || 8}×${tourillonFaceDrillDepthMm(piece.thicknessMm)} (±${off} mm) par ferrure`
]);
}
if (m.connectorHoles) {
const edge = CONNECTORS.jointPatternEdge || m.connectorHoles.jointPattern;
const offMid = CONNECTORS.jointPatternMiddleTourillonOffsetFromIxMm ?? 30;
let patternNote = '';
const ixJ = m.connectorHoles.ixconnectJointsAlongDepth;
if (ixJ?.length && ['dessus', 'fond'].includes(piece.elementType)) {
patternNote = ` · Axes (bord avant profondeur): extrémités T@${edge?.tourillon1Axis}·IX@${edge?.ixconnectAxis}·T@${edge?.tourillon2Axis} · internes IX milieu + T ±${offMid} mm (${ixJ.length} jonction(s))`;
} else if (m.connectorHoles.jointPattern && edge) {
patternNote = ` · Pattern arête T@${edge.tourillon1Axis}·IX@${edge.ixconnectAxis}·T@${edge.tourillon2Axis} mm (depuis bord avant)`;
}
rows.push([
'Connecteurs Ixconnect',
`About Ø${m.connectorHoles.edgeHole?.diameter || 8} prof ${m.connectorHoles.edgeHole?.depth || 45} mm · Face Ø${m.connectorHoles.faceHole?.diameter || 8} prof ${m.connectorHoles.faceHole?.depth || 12} mm${patternNote}`
]);
}
if (m.groove) {
const groovePos = m.groove.axisFromPanelEdge || m.groove.axisFromBackEdge || 16;
rows.push([
'Rainure',
`${m.groove.width || 9} x ${m.groove.depth || 10.5} mm · position à ${groovePos} mm du bord`
]);
}
if (m.fullHeightHandleGroove) {
const off = m.fullHeightHandleGroove.topBottomOffsetMm ?? 2;
const gw = m.fullHeightHandleGroove.grooveWidthMm ?? 9;
const gd = m.fullHeightHandleGroove.grooveDepthMm ?? 10.5;
const useful = Math.max(0, (piece.heightMm || 0) - 2 * off);
rows.push([
'Rainure poignée TH',
`${gw} × ${gd} mm (larg. × prof.) · retraits ${off} mm haut/bas · longueur utile rainure ${Math.round(useful)} mm`
]);
}
if (m.finishConnectors) {
const fc = m.finishConnectors;
rows.push([
'SC 8/25 (finition)',
`Face Ø${fc.faceHole?.diameter || 8} · prof. ${fc.faceHole?.depth || 12.5} mm · axes arrière ${fc.axes?.back ?? 30} / avant ${fc.axes?.front ?? 43} mm (depuis face intérieure)`
]);
}
if (m.reinforcementFixing) {
const rf = m.reinforcementFixing;
const ix = rf.ixconnectAxisFromTopMm != null ? ` · Ixconnect ${rf.connectorModel || 'SC 8/60'} axe ${rf.ixconnectAxisFromTopMm} mm (depuis haut)` : '';
const axes = rf.tourillonAxesFromTopMm || rf.tourillonAxes || [];
const sides = rf.sideCount || 2;
rows.push([
'Fixation renfort',
`${sides} chants extrémité · par chant: ${rf.connectorModel || 'SC 8/60'}${ix} + ${rf.tourillonsPerSide || rf.tourillonCount || 2} tourillons Ø${rf.tourillonDiameter || 8} · axes ${axes.join(', ')} mm`
]);
}
return rows;
}
function openViewLightbox(piece, index = 0) {
const views = Array.isArray(piece?.piecePlanViews) ? piece.piecePlanViews : [];
if (!views.length) return;
setLightbox({ views, index, title: piece.description });
}
function shiftLightbox(step) {
if (!lightbox) return;
const total = lightbox.views.length;
const next = (lightbox.index + step + total) % total;
setLightbox({ ...lightbox, index: next });
}
useEffect(() => {
if (!lightbox) return undefined;
const onKeyDown = (event) => {
if (event.key === 'Escape') {
setLightbox(null);
} else if (event.key === 'ArrowRight') {
setLightbox((prev) => {
if (!prev) return prev;
const total = prev.views.length;
return { ...prev, index: (prev.index + 1) % total };
});
} else if (event.key === 'ArrowLeft') {
setLightbox((prev) => {
if (!prev) return prev;
const total = prev.views.length;
return { ...prev, index: (prev.index - 1 + total) % total };
});
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [lightbox]);
useLayoutEffect(() => {
if (!previewPiece?.piecePlanSvg) return undefined;
const host = piecePlanHostRef.current;
if (!host) return undefined;
const sync = () => {
const svg = host.querySelector('svg');
setPlanPanelLayout(getPiecePlanPanelPercents(svg));
};
sync();
const ro = new ResizeObserver(sync);
ro.observe(host);
return () => ro.disconnect();
}, [previewPiece]);
if (!cuttingList || !cuttingList.pieces) {
return (
<div className={styles.empty}>
@@ -41,7 +365,7 @@ export default function CuttingListTable({ cuttingList, onExport }) {
</div>
</div>
{/* Tables by finish */}
<div className={styles.tableScrollArea}>
{byFinish.map((group, groupIndex) => (
<div key={groupIndex} className={styles.finishGroup}>
<h4 className={styles.finishTitle}>
@@ -57,18 +381,37 @@ export default function CuttingListTable({ cuttingList, onExport }) {
<th>Description</th>
<th>Largeur</th>
<th>Hauteur</th>
<th title="Dimension dans la profondeur de l'armoire">Profondeur</th>
<th>Ép.</th>
<th>Qté</th>
<th>Surface</th>
<th>Chant</th>
<th>Perçages</th>
<th className={styles.cncHeader} title="Visualisation CNC 3D">
3D
</th>
{onExportMprPiece && <th className={styles.mprHeader}>MPR</th>}
</tr>
</thead>
<tbody>
{group.pieces.map((piece, pieceIndex) => (
{group.pieces.map((piece, pieceIndex) => {
const cncSegments = cncProgram ? getCncPartsForPiece(cuttingList, piece, cncProgram) : [];
const depthMm = getPieceDepthMm(piece, cuttingList?.config?.depthMm);
const widthMm = piece?.widthMm ?? null;
const heightMm = piece?.heightMm ?? null;
const depthNum = depthMm == null ? null : Number(depthMm);
const widthNum = widthMm == null ? null : Number(widthMm);
const heightNum = heightMm == null ? null : Number(heightMm);
const hideWidth = depthNum != null && widthNum != null && depthNum === widthNum;
const hideHeight = depthNum != null && heightNum != null && depthNum === heightNum;
const mprInstanceCount = countMprInstancesForPiece(piece);
const mprBusy = mprExportingPieceId === piece.id;
return (
<tr key={pieceIndex}>
<td className={styles.descCell}>{piece.description}</td>
<td className={styles.dimCell}>{piece.widthMm}</td>
<td className={styles.dimCell}>{piece.heightMm}</td>
<td className={styles.dimCell}>{hideWidth ? '—' : (widthMm ?? '—')}</td>
<td className={styles.dimCell}>{hideHeight ? '—' : (heightMm ?? '—')}</td>
<td className={styles.dimCell}>{depthMm ?? '—'}</td>
<td className={styles.dimCell}>{piece.thicknessMm}</td>
<td className={styles.qtyCell}>{piece.quantity}</td>
<td className={styles.surfaceCell}>{piece.surfaceM2.toFixed(3)}</td>
@@ -79,28 +422,232 @@ export default function CuttingListTable({ cuttingList, onExport }) {
</span>
)}
</td>
<td>
{piece.piecePlanSvg ? (
<button type="button" className={styles.planBtn} onClick={() => openPiecePlan(piece)}>
Voir plan
</button>
) : '—'}
</td>
<td className={styles.cncCell}>
{cncSegments.length > 0 ? (
<button
type="button"
className={styles.cncBtn}
onClick={() => openCnc3d(piece)}
title="Visualiser CNC 3D"
aria-label={`CNC 3D — ${piece.description}`}
>
3D
</button>
) : (
'—'
)}
</td>
{onExportMprPiece && (
<td className={styles.mprCell}>
<div className={styles.mprCellInner}>
{mprInstanceCount > 1 && (
<select
className={styles.mprInstanceSelect}
value={mprInstanceByPieceId[piece.id] ?? 'all'}
disabled={mprBusy}
onChange={(e) =>
setMprInstanceByPieceId((prev) => ({
...prev,
[piece.id]: e.target.value
}))
}
aria-label={`Exemplaire MPR pour ${piece.description}`}
title="Exemplaire à exporter"
>
<option value="all">T.{mprInstanceCount}</option>
{Array.from({ length: mprInstanceCount }, (_, i) => (
<option key={i} value={String(i)}>
{i + 1}
</option>
))}
</select>
)}
<button
type="button"
className={styles.mprBtn}
disabled={mprBusy}
onClick={() => handleExportMprRow(piece)}
title="Exporter MPR"
aria-label={`Exporter MPR — ${piece.description}`}
>
{mprBusy ? '…' : 'MPR'}
</button>
</div>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
))}
</div>
{cncModal && (
<div className={styles.previewOverlay} onClick={closeCncModal}>
<div className={styles.cncModal} onClick={(e) => e.stopPropagation()}>
<div className={styles.previewHeader}>
<button
type="button"
className={styles.previewClose}
onClick={closeCncModal}
aria-label="Fermer la prévisualisation CNC"
>
×
</button>
<h4>{cncModal.piece.description}</h4>
<div className={styles.previewMeta}>
Parcours outil (G-code) · Qté {cncModal.piece.quantity}
{cncModal.segments.length > 1 && (
<span className={styles.cncInstanceWrap}>
{' · '}
<label htmlFor="cnc-instance" className={styles.cncInstanceLabel}>Exemplaire</label>
<select
id="cnc-instance"
className={styles.cncInstanceSelect}
value={cncModal.instanceIndex}
onChange={(e) => {
const instanceIndex = Number(e.target.value);
setCncModal((prev) => (prev ? { ...prev, instanceIndex } : prev));
}}
>
{cncModal.segments.map((seg, i) => (
<option key={seg.id} value={i}>
{i + 1} / {cncModal.segments.length}
</option>
))}
</select>
</span>
)}
</div>
</div>
<div className={styles.cncModalBody}>
<div className={styles.cncViewerCol}>
<Cnc3DViewer
gcode={cncModal.segments[cncModal.instanceIndex]?.gcode || ''}
piece={cncModal.piece}
materialColor={getPieceMaterialColor(cncModal.piece)}
orbitStorageKey={`${planRestoreKey}:${cncModal.piece.id}:${cncModal.instanceIndex}`}
/>
<p className={styles.cncModalHint}>
Prévisualisation 3D du parcours pour cette pièce. Glisser pour tourner, molette pour zoomer, clic droit
pour déplacer.
</p>
</div>
<pre className={styles.cncCodeBlock}>
{cncModal.segments[cncModal.instanceIndex]?.gcode || '; —'}
</pre>
</div>
</div>
</div>
)}
{previewPiece && (
<div className={styles.previewOverlay} onClick={() => setPreviewPiece(null)}>
<div className={styles.previewModal} onClick={(e) => e.stopPropagation()}>
<div className={styles.previewHeader}>
<button
type="button"
className={styles.previewClose}
onClick={() => setPreviewPiece(null)}
aria-label="Fermer l'aperçu du plan"
>
×
</button>
<h4>{previewPiece.description}</h4>
<div className={styles.previewMeta}>
Fini: {previewPiece.widthMm} x {previewPiece.heightMm} x {previewPiece.thicknessMm} mm
{' · '}
Brut CNC: {previewPiece.rawCutWidthMm} x {previewPiece.rawCutHeightMm} mm
</div>
</div>
<div className={styles.previewCanvasWrap}>
<div
className={`${styles.previewContent} ${previewPiece?.piecePlanViews?.length ? styles.previewContentClickable : ''}`}
>
<div ref={piecePlanHostRef} className={styles.piecePlanHost}>
<div dangerouslySetInnerHTML={{ __html: previewPiece.piecePlanSvg }} />
{!!previewPiece?.piecePlanViews?.length &&
previewPiece.piecePlanViews.map((view, idx) => {
const p = planPanelLayout.panels[idx];
if (!p) return null;
return (
<div
key={view.id || idx}
className={styles.viewOverlayPanel}
style={{
left: `${p.leftPct}%`,
width: `${p.widthPct}%`,
top: `${p.topPct}%`,
height: `${p.heightPct}%`
}}
onClick={() => openViewLightbox(previewPiece, idx)}
title={`Agrandir ${view.label || `Vue ${idx + 1}`}`}
>
<button
type="button"
className={styles.viewIconBtn}
onClick={(e) => {
e.stopPropagation();
openViewLightbox(previewPiece, idx);
}}
title={`Plein écran ${view.label || `Vue ${idx + 1}`}`}
>
<span className={styles.viewIconGlyph}>⛶</span>
</button>
</div>
);
})}
</div>
</div>
</div>
<div className={styles.previewTech}>
<h5>Informations fabrication</h5>
<table className={styles.previewTable}>
<tbody>
{buildManufacturingRows(previewPiece).map(([label, value]) => (
<tr key={label}>
<th>{label}</th>
<td>{value}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
</div>
)}
{/* Export buttons */}
{onExport && (
<div className={styles.exportActions}>
<button
className={styles.exportBtn}
onClick={() => onExport('text')}
>
Exporter en texte
</button>
<button
className={`${styles.exportBtn} ${styles.primary}`}
onClick={() => onExport('pdf')}
>
Exporter en PDF
</button>
{lightbox && (
<div className={styles.lightboxOverlay} onClick={() => setLightbox(null)}>
<div className={styles.lightboxModal} onClick={(e) => e.stopPropagation()}>
<div className={styles.lightboxHeader}>
<div>
<strong>{lightbox.title}</strong>
<div className={styles.previewMeta}>
{lightbox.views[lightbox.index]?.label} - {lightbox.views[lightbox.index]?.title}
</div>
</div>
<button type="button" className={styles.previewClose} onClick={() => setLightbox(null)}>×</button>
</div>
<div
className={styles.lightboxContent}
dangerouslySetInnerHTML={{ __html: lightbox.views[lightbox.index]?.svg || '' }}
/>
<div className={styles.lightboxFooter}>
<button type="button" className={styles.planBtn} onClick={() => shiftLightbox(-1)}>Précédent</button>
<span className={styles.lightboxCounter}>{lightbox.index + 1} / {lightbox.views.length}</span>
<button type="button" className={styles.planBtn} onClick={() => shiftLightbox(1)}>Suivant</button>
</div>
</div>
</div>
)}
</div>
+453 -36
View File
@@ -2,6 +2,11 @@
background: #fff;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
.empty {
@@ -14,6 +19,15 @@
background: #f0f7ff;
padding: 16px;
border-bottom: 1px solid #cce0ff;
flex-shrink: 0;
}
.tableScrollArea {
flex: 1;
min-height: 0;
overflow: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.summaryTitle {
@@ -51,7 +65,7 @@
border-bottom: 1px solid #e5e7eb;
}
.finishGroup:last-of-type {
.finishGroup:last-child {
border-bottom: none;
}
@@ -80,10 +94,18 @@
.table {
width: 100%;
min-width: 820px;
border-collapse: collapse;
font-size: 0.85rem;
}
.table thead th {
position: sticky;
top: 0;
z-index: 2;
background: #f9fafb;
}
.table th {
padding: 8px 10px;
background: #f9fafb;
@@ -127,6 +149,419 @@
text-align: center;
}
.planBtn {
padding: 4px 8px;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #fff;
color: #1e3a8a;
cursor: pointer;
font-size: 0.75rem;
}
.planBtn:hover {
background: #eff6ff;
}
.cncCell {
text-align: center;
vertical-align: middle;
width: 2.75rem;
max-width: 2.75rem;
padding: 4px 2px;
}
.table th.cncHeader {
text-align: center;
vertical-align: middle;
width: 2.75rem;
max-width: 2.75rem;
white-space: nowrap;
z-index: 3;
padding: 6px 2px !important;
font-size: 0.65rem;
letter-spacing: 0.02em;
}
.cncBtn {
display: block;
width: 100%;
max-width: 2.5rem;
margin: 0 auto;
padding: 2px 0;
border: 1px solid #c4b5fd;
border-radius: 3px;
background: #f5f3ff;
color: #5b21b6;
cursor: pointer;
font-size: 0.62rem;
font-weight: 700;
line-height: 1.25;
letter-spacing: 0.03em;
}
.cncBtn:hover {
background: #ede9fe;
}
.table th.mprHeader {
text-align: center;
vertical-align: middle;
width: 2.75rem;
max-width: 2.75rem;
white-space: nowrap;
z-index: 3;
padding: 6px 2px !important;
font-size: 0.65rem;
letter-spacing: 0.02em;
}
.mprCell {
text-align: center;
vertical-align: middle;
width: 2.75rem;
max-width: 2.75rem;
padding: 4px 2px;
}
.mprCellInner {
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
gap: 2px;
width: 100%;
max-width: 2.5rem;
margin: 0 auto;
}
.mprInstanceSelect {
width: 100%;
padding: 1px 0;
border: 1px solid #cbd5e1;
border-radius: 3px;
font-size: 0.62rem;
line-height: 1.2;
color: #374151;
background: #fff;
text-align: center;
}
.mprBtn {
width: 100%;
padding: 2px 0;
border: 1px solid #86efac;
border-radius: 3px;
background: #f0fdf4;
color: #166534;
cursor: pointer;
font-size: 0.62rem;
font-weight: 700;
line-height: 1.25;
letter-spacing: 0.03em;
}
.mprBtn:hover:not(:disabled) {
background: #dcfce7;
}
.mprBtn:disabled {
opacity: 0.6;
cursor: wait;
}
@media (min-width: 768px) {
.table th.cncHeader,
.table th.mprHeader,
.cncCell,
.mprCell {
width: 3rem;
max-width: 3rem;
}
.cncBtn {
max-width: 2.75rem;
}
.mprCellInner {
max-width: 2.75rem;
}
}
.cncModal {
width: min(95vw, 960px);
max-height: 90vh;
background: #fff;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
.cncModalBody {
display: grid;
grid-template-columns: 1.15fr 1fr;
gap: 12px;
padding: 12px 16px 16px;
overflow: auto;
max-height: min(72vh, 640px);
}
.cncViewerCol {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.cncModalHint {
margin: 0;
font-size: 0.78rem;
color: #64748b;
}
.cncCodeBlock {
margin: 0;
min-height: 200px;
max-height: 420px;
overflow: auto;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #0f172a;
color: #e2e8f0;
font-size: 0.72rem;
line-height: 1.45;
}
.cncInstanceWrap {
display: inline-flex;
align-items: center;
gap: 6px;
}
.cncInstanceLabel {
font-size: 0.85rem;
color: #64748b;
font-weight: 500;
}
.cncInstanceSelect {
padding: 4px 8px;
border-radius: 6px;
border: 1px solid #cbd5e1;
font-size: 0.82rem;
background: #fff;
}
@media (max-width: 768px) {
.cncModalBody {
grid-template-columns: 1fr;
}
}
.previewOverlay {
position: fixed;
inset: 0;
background: rgba(17, 24, 39, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1200;
padding: 20px;
}
.previewModal {
width: min(95vw, 1100px);
max-height: 90vh;
background: #fff;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
.previewHeader {
position: relative;
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
padding-right: 44px;
}
.previewHeader h4 {
margin: 0 0 6px;
font-size: 1rem;
}
.previewMeta {
color: #6b7280;
font-size: 0.85rem;
}
.previewClose {
position: absolute;
top: 10px;
right: 10px;
width: 28px;
height: 28px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
color: #334155;
font-size: 20px;
line-height: 1;
cursor: pointer;
}
.previewClose:hover {
background: #eff6ff;
}
.previewCanvasWrap {
position: relative;
}
.previewContent {
overflow: auto;
padding: 12px;
background: #f8fafc;
}
.previewContentClickable {
cursor: default;
}
.piecePlanHost {
position: relative;
display: block;
width: 100%;
}
.piecePlanHost :global(svg) {
display: block;
max-width: 100%;
height: auto;
}
.viewOverlayPanel {
position: absolute;
z-index: 2;
cursor: zoom-in;
box-sizing: border-box;
}
.viewIconBtn {
position: absolute;
top: 8px;
right: 8px;
border: 1px solid #cbd5e1;
background: rgba(255, 255, 255, 0.92);
border-radius: 999px;
width: 34px;
height: 34px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #1e3a8a;
z-index: 3;
}
.viewIconBtn:hover {
background: #eff6ff;
}
.viewIconGlyph {
font-size: 14px;
line-height: 1;
}
.previewTech {
border-top: 1px solid #e5e7eb;
padding: 12px 16px 16px;
background: #fff;
}
.previewTech h5 {
margin: 0 0 8px;
font-size: 0.95rem;
}
.previewTable {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
}
.previewTable th,
.previewTable td {
border: 1px solid #e5e7eb;
padding: 6px 8px;
text-align: left;
vertical-align: top;
}
.previewTable th {
width: 220px;
background: #f8fafc;
color: #334155;
font-weight: 600;
}
.lightboxOverlay {
position: fixed;
inset: 0;
background: rgba(2, 6, 23, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 1300;
padding: 22px;
}
.lightboxModal {
width: min(96vw, 1280px);
max-height: 94vh;
background: #fff;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.lightboxHeader {
border-bottom: 1px solid #e5e7eb;
padding: 10px 16px;
position: relative;
padding-right: 44px;
}
.lightboxContent {
overflow: auto;
background: #f8fafc;
padding: 10px;
min-height: 420px;
}
.lightboxFooter {
border-top: 1px solid #e5e7eb;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.lightboxCounter {
font-size: 0.85rem;
color: #475569;
min-width: 54px;
text-align: center;
}
.edgeBadge {
display: inline-block;
padding: 2px 6px;
@@ -145,46 +580,28 @@
color: #92400e;
}
.exportActions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
.exportBtn {
padding: 10px 20px;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background: #fff;
border: 1px solid #d1d5db;
color: #374151;
}
.exportBtn:hover {
background: #f3f4f6;
}
.exportBtn.primary {
background: #4f46e5;
border-color: #4f46e5;
color: #fff;
}
.exportBtn.primary:hover {
background: #4338ca;
}
@media (max-width: 640px) {
.table {
font-size: 0.75rem;
}
.table th.mprHeader,
.mprCell {
position: sticky;
right: 0;
z-index: 3;
box-shadow: -4px 0 8px -4px rgba(15, 23, 42, 0.12);
}
.table th.mprHeader {
background: #f9fafb;
}
.mprCell {
background: #fff;
z-index: 2;
}
.descCell {
min-width: 100px;
}
@@ -0,0 +1,91 @@
import styles from './HardwareListTable.module.css';
function formatChf(value) {
const n = Number(value);
if (!Number.isFinite(n)) return '—';
return `CHF ${n.toLocaleString('fr-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function formatUnitPrice({ unitPriceChf, unitLabel, unit }) {
const n = Number(unitPriceChf);
if (!Number.isFinite(n)) return '—';
const money = `CHF ${n.toLocaleString('fr-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const u = unitLabel || unit || null;
return u ? `${money} / ${u}` : money;
}
export default function HardwareListTable({ hardwareList, error = null }) {
if (error) {
return <div className={styles.empty}>{error}</div>;
}
if (!hardwareList) {
return <div className={styles.empty}>Aucune donnée de quincaillerie disponible</div>;
}
const items = Array.isArray(hardwareList.items) ? hardwareList.items : [];
return (
<div className={styles.container}>
<div className={styles.summary}>
<h3 className={styles.summaryTitle}>Résumé</h3>
<div className={styles.summaryGrid}>
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>Articles</span>
<span className={styles.summaryValue}>{items.length}</span>
</div>
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>Total</span>
<span className={styles.summaryValue}>{formatChf(hardwareList.totalChf)}</span>
</div>
</div>
</div>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.thumbCol}></th>
<th>Matériel</th>
<th>Code</th>
<th className={styles.num}>Qté</th>
<th className={styles.num}>Prix unitaire</th>
<th className={styles.num}>Total</th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={it.key}>
<td className={styles.thumbCell}>
{it.imageUrl ? (
<img className={styles.thumbImg} src={it.imageUrl} alt="" loading="lazy" />
) : (
<span className={styles.thumbEmpty}>—</span>
)}
</td>
<td className={styles.labelCell}>
{it.productUrl ? (
<a className={styles.productLink} href={it.productUrl} target="_blank" rel="noreferrer">
{it.label}
</a>
) : (
it.label
)}
</td>
<td className={styles.codeCell}>{it.code || '—'}</td>
<td className={`${styles.num} ${styles.qty}`}>{it.quantity}</td>
<td className={styles.num}>{formatUnitPrice(it)}</td>
<td className={styles.num}>{formatChf(it.lineTotalChf)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<th colSpan={5} className={styles.totalLabel}>Total</th>
<th className={`${styles.num} ${styles.totalValue}`}>{formatChf(hardwareList.totalChf)}</th>
</tr>
</tfoot>
</table>
</div>
);
}
@@ -0,0 +1,156 @@
.container {
display: flex;
flex-direction: column;
gap: 16px;
}
.empty {
padding: 18px;
border: 1px dashed var(--border);
border-radius: 10px;
background: var(--bg-subtle);
color: var(--text-muted);
font-size: 0.9rem;
}
.summary {
background: #f0f7ff;
border-radius: 10px;
padding: 18px;
border: 1px solid rgba(59, 130, 246, 0.15);
}
.summaryTitle {
margin: 0 0 12px;
font-size: 1.05rem;
font-weight: 700;
color: var(--text);
}
.summaryGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.summaryItem {
display: flex;
flex-direction: column;
gap: 4px;
}
.summaryLabel {
font-size: 0.78rem;
color: var(--text-muted);
font-weight: 600;
}
.summaryValue {
font-size: 1.25rem;
font-weight: 800;
color: #1d4ed8;
}
.table {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 1px solid var(--border-light);
border-radius: 10px;
overflow: hidden;
}
.table th,
.table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border-light);
vertical-align: middle;
}
.table thead th {
background: var(--bg-subtle);
font-size: 0.82rem;
letter-spacing: 0.01em;
text-transform: uppercase;
color: var(--text-muted);
font-weight: 700;
}
.thumbCol {
width: 52px;
}
.thumbCell {
width: 52px;
text-align: center;
}
.thumbImg {
width: 38px;
height: 38px;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--border-light);
background: #fff;
}
.thumbEmpty {
color: var(--text-muted);
}
.labelCell {
font-weight: 600;
color: var(--text);
}
.codeCell {
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.productLink {
color: var(--action);
text-decoration: none;
font-weight: 700;
}
.productLink:hover {
text-decoration: underline;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.qty {
font-weight: 700;
}
.table tfoot th,
.table tfoot td {
background: #fff7ed;
border-top: 1px solid rgba(245, 158, 11, 0.25);
border-bottom: none;
}
.totalLabel {
text-align: right;
font-weight: 800;
color: #7c2d12;
}
.totalValue {
font-weight: 900;
color: #7c2d12;
}
@media (max-width: 768px) {
.summaryGrid {
grid-template-columns: 1fr;
}
.table {
font-size: 0.9rem;
}
}
+1
View File
@@ -22,6 +22,7 @@ export default function Layout({ children }) {
<Link to="/configurateur">Configurateur</Link>
<Link to="/materiaux">Matériaux</Link>
<Link to="/admin">Admin</Link>
<Link to="/parametres">Paramètres</Link>
<button type="button" className={styles.logoutBtn} onClick={handleLogout}>Déconnexion</button>
</>
) : (
+119 -3
View File
@@ -12,6 +12,13 @@ export default function MaterialModal({ material, collections, onClose, onSave }
width_mm: '',
height_mm: '',
thickness_mm: '',
diameter_mm: '',
length_mm: '',
bore_diameter_mm: '',
bore_depth_base_mm: '',
bore_depth_piece_mm: '',
price_chf: '',
price_unit: '',
formex_url: ''
});
@@ -27,6 +34,13 @@ export default function MaterialModal({ material, collections, onClose, onSave }
width_mm: material.width_mm?.toString() || '',
height_mm: material.height_mm?.toString() || '',
thickness_mm: material.thickness_mm?.toString() || '',
diameter_mm: material.diameter_mm?.toString() || '',
length_mm: material.length_mm?.toString() || '',
bore_diameter_mm: material.bore_diameter_mm?.toString() || '',
bore_depth_base_mm: material.bore_depth_base_mm?.toString() || '',
bore_depth_piece_mm: material.bore_depth_piece_mm?.toString() || '',
price_chf: material.price_chf?.toString() || '',
price_unit: material.price_unit || '',
formex_url: material.formex_url || ''
});
}
@@ -56,7 +70,14 @@ export default function MaterialModal({ material, collections, onClose, onSave }
collection: formData.collection.trim(),
width_mm: formData.width_mm ? parseInt(formData.width_mm, 10) : null,
height_mm: formData.height_mm ? parseInt(formData.height_mm, 10) : null,
thickness_mm: formData.thickness_mm ? parseInt(formData.thickness_mm, 10) : null,
thickness_mm: formData.thickness_mm ? parseFloat(formData.thickness_mm) : null,
diameter_mm: formData.diameter_mm ? parseFloat(formData.diameter_mm) : null,
length_mm: formData.length_mm ? parseFloat(formData.length_mm) : null,
bore_diameter_mm: formData.bore_diameter_mm ? parseFloat(formData.bore_diameter_mm) : null,
bore_depth_base_mm: formData.bore_depth_base_mm ? parseFloat(formData.bore_depth_base_mm) : null,
bore_depth_piece_mm: formData.bore_depth_piece_mm ? parseFloat(formData.bore_depth_piece_mm) : null,
price_chf: formData.price_chf ? parseFloat(formData.price_chf) : null,
price_unit: formData.price_unit.trim() || null,
formex_url: formData.formex_url.trim() || null
};
@@ -174,15 +195,110 @@ export default function MaterialModal({ material, collections, onClose, onSave }
</div>
</div>
<div className={styles.row}>
<div className={styles.field}>
<label htmlFor="formex_url">URL Formex</label>
<label htmlFor="diameter_mm">Ø Diamètre (mm)</label>
<input
type="number"
id="diameter_mm"
name="diameter_mm"
value={formData.diameter_mm}
onChange={handleChange}
placeholder="8"
step="0.1"
/>
</div>
<div className={styles.field}>
<label htmlFor="length_mm">Longueur (mm)</label>
<input
type="number"
id="length_mm"
name="length_mm"
value={formData.length_mm}
onChange={handleChange}
placeholder="55"
step="0.1"
/>
</div>
</div>
<fieldset className={styles.fieldset}>
<legend>Perçage</legend>
<div className={styles.row}>
<div className={styles.field}>
<label htmlFor="bore_diameter_mm">Ø Perçage (mm)</label>
<input
type="number"
id="bore_diameter_mm"
name="bore_diameter_mm"
value={formData.bore_diameter_mm}
onChange={handleChange}
placeholder="8"
step="0.1"
/>
</div>
<div className={styles.field}>
<label htmlFor="bore_depth_base_mm">Prof. fond (mm)</label>
<input
type="number"
id="bore_depth_base_mm"
name="bore_depth_base_mm"
value={formData.bore_depth_base_mm}
onChange={handleChange}
placeholder="11"
step="0.1"
/>
</div>
<div className={styles.field}>
<label htmlFor="bore_depth_piece_mm">Prof. pièce (mm)</label>
<input
type="number"
id="bore_depth_piece_mm"
name="bore_depth_piece_mm"
value={formData.bore_depth_piece_mm}
onChange={handleChange}
placeholder="8"
step="0.1"
/>
</div>
</div>
</fieldset>
<div className={styles.row}>
<div className={styles.field}>
<label htmlFor="price_chf">Prix CHF</label>
<input
type="number"
id="price_chf"
name="price_chf"
value={formData.price_chf}
onChange={handleChange}
placeholder="0.60"
step="0.01"
/>
</div>
<div className={styles.field}>
<label htmlFor="price_unit">Unité de prix</label>
<input
type="text"
id="price_unit"
name="price_unit"
value={formData.price_unit}
onChange={handleChange}
placeholder="par Pièce (PC)"
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="formex_url">URL</label>
<input
type="url"
id="formex_url"
name="formex_url"
value={formData.formex_url}
onChange={handleChange}
placeholder="https://www.formex.ch/fr/shop/..."
placeholder="https://..."
/>
</div>
+19 -1
View File
@@ -12,7 +12,7 @@
.modal {
background: #fff;
border-radius: 8px;
max-width: 500px;
max-width: 560px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
@@ -92,6 +92,24 @@
gap: 1rem;
}
.row:has(> :nth-child(2):last-child) {
grid-template-columns: repeat(2, 1fr);
}
.fieldset {
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem 1rem 0.5rem;
margin-bottom: 1rem;
}
.fieldset legend {
font-size: 0.8125rem;
font-weight: 600;
color: var(--accent);
padding: 0 0.375rem;
}
.actions {
display: flex;
gap: 0.75rem;
+279 -16
View File
@@ -4,22 +4,74 @@
*/
import { useState, useEffect, useCallback } from 'react';
import CuttingListTable from './CuttingListTable';
import HardwareListTable from './HardwareListTable';
import styles from './Plans2DView.module.css';
import { saveOrderPlanExport, generateOrderPlanExports } from '../api/client';
import Icon from './ui/Icon';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function Plans2DView({ config, modules = [], colors = {}, onClose }) {
const [activeTab, setActiveTab] = useState('assembly');
const PLANS_TAB_KEY = 'apex.plans.tab';
const VALID_PLAN_TABS = ['assembly', 'side', 'top', 'cutting', 'hardware'];
function readSavedPlanTab() {
try {
let t = localStorage.getItem(PLANS_TAB_KEY);
if (VALID_PLAN_TABS.includes(t)) return t;
t = sessionStorage.getItem(PLANS_TAB_KEY);
if (VALID_PLAN_TABS.includes(t)) {
localStorage.setItem(PLANS_TAB_KEY, t);
sessionStorage.removeItem(PLANS_TAB_KEY);
return t;
}
} catch {
// ignore
}
return 'assembly';
}
export default function Plans2DView({ config, modules = [], colors = {}, orderId = null, onClose }) {
const [activeTab, setActiveTab] = useState(readSavedPlanTab);
const [plans, setPlans] = useState(null);
const [cuttingList, setCuttingList] = useState(null);
const [hardwareList, setHardwareList] = useState(null);
const [hardwareError, setHardwareError] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
/** Erreur API plans 2D seulement — n’empêche pas liste débitage / quincaillerie */
const [planViewsError, setPlanViewsError] = useState(null);
const [exportStatus, setExportStatus] = useState('');
const [cncProgram, setCncProgram] = useState('');
useEffect(() => {
try {
localStorage.setItem(PLANS_TAB_KEY, activeTab);
sessionStorage.removeItem(PLANS_TAB_KEY);
} catch {
// ignore
}
}, [activeTab]);
const planRestoreKey = `${config.widthMm}|${config.heightMm}|${config.depthMm}`;
const handleClose = useCallback(() => {
try {
localStorage.removeItem(PLANS_TAB_KEY);
sessionStorage.removeItem(PLANS_TAB_KEY);
sessionStorage.removeItem('apex.cnc3d.modal');
} catch {
// ignore
}
onClose?.();
}, [onClose]);
// Fetch plans and cutting list
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
setPlanViewsError(null);
setHardwareError(null);
try {
// Fetch plans
@@ -28,10 +80,24 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
const plansData = await plansRes.json();
let plansData = {};
try {
plansData = await plansRes.json();
} catch {
plansData = {};
}
if (plansData.success) {
if (plansRes.ok && plansData.success && plansData.plans) {
setPlans(plansData.plans);
setPlanViewsError(null);
} else {
setPlans(null);
const detail = Array.isArray(plansData.details) ? plansData.details[0]?.message : undefined;
const msg =
plansData.error ||
detail ||
(!plansRes.ok ? `Plans 2D indisponibles (HTTP ${plansRes.status}).` : 'Réponse serveur invalide pour les plans 2D.');
setPlanViewsError(msg);
}
// Fetch cutting list
@@ -42,8 +108,34 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
});
const cutData = await cutRes.json();
if (cutData.success) {
if (cutRes.ok && cutData.success) {
setCuttingList(cutData.cuttingList);
} else {
setCuttingList(null);
}
// Fetch hardware list
const hwRes = await fetch(`${API_BASE}/api/plans/hardware-list`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
const hwData = await hwRes.json();
if (hwData.success) {
setHardwareList({ items: hwData.items || [], totalChf: hwData.totalChf ?? 0 });
} else {
setHardwareList(null);
setHardwareError('Erreur lors de la génération de la liste de quincaillerie');
}
const cncRes = await fetch(`${API_BASE}/api/plans/export-cnc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
if (cncRes.ok) {
const cncText = await cncRes.text();
setCncProgram(cncText);
}
} catch (err) {
@@ -60,13 +152,16 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
// Export handlers
const handleExport = useCallback(async (format) => {
try {
setExportStatus('');
if (format === 'text') {
const res = await fetch(`${API_BASE}/api/plans/export-text`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const textContent = await blob.text();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -74,33 +169,141 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
a.click();
URL.revokeObjectURL(url);
if (orderId) {
await saveOrderPlanExport(orderId, {
exportType: 'cutting_list_text',
content: textContent,
mimeType: 'text/plain; charset=utf-8',
fileExtension: 'txt'
});
setExportStatus(`Export enregistré pour la commande #${orderId}.`);
} else {
setExportStatus('Export téléchargé localement (non archivé: commande non envoyée).');
}
} else if (format === 'pdf') {
const res = await fetch(`${API_BASE}/api/plans/export-pdf`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
if (!res.ok) throw new Error(await res.text());
const html = await res.text();
// Open in new window for printing
const printWindow = window.open('', '_blank');
if (printWindow) {
printWindow.document.write(html);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print();
};
}
if (orderId) {
await generateOrderPlanExports(orderId);
setExportStatus(`PDF dossier enregistré pour la commande #${orderId}.`);
} else {
setExportStatus('Version imprimable ouverte (non archivée: commande non envoyée).');
}
} else if (format === 'cnc') {
const res = await fetch(`${API_BASE}/api/plans/export-cnc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
if (!res.ok) throw new Error(await res.text());
const gcode = await res.text();
setCncProgram(gcode);
const blob = new Blob([gcode], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'apex-program.nc';
a.click();
URL.revokeObjectURL(url);
setExportStatus('Programme CNC (.nc) téléchargé.');
} else if (format === 'mpr') {
const res = await fetch(`${API_BASE}/api/plans/export-mpr`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config,
modules,
colors,
orderReference: orderId ? `COMMANDE_${orderId}` : '',
stackListKm: '01',
mprGroupIndex: 1
})
});
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'apex-mpr-g1.zip';
a.click();
URL.revokeObjectURL(url);
setExportStatus('Archive Homag MPR (.zip) téléchargée.');
}
} catch (err) {
console.error('Export error:', err);
alert('Erreur lors de l\'export');
}
}, [config, modules, colors]);
}, [config, modules, colors, orderId]);
const handleExportMprPiece = useCallback(async (piece, instanceIndex = null) => {
try {
setExportStatus('');
if (!piece?.id) return;
const res = await fetch(`${API_BASE}/api/plans/export-mpr-piece`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config,
modules,
colors,
pieceId: piece.id,
...(instanceIndex != null && Number.isFinite(instanceIndex) ? { instanceIndex } : {}),
orderReference: orderId ? `COMMANDE_${orderId}` : '',
stackListKm: '01',
mprGroupIndex: 1
})
});
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try {
const err = await res.json();
msg = err.error || msg;
} catch {
msg = (await res.text()) || msg;
}
throw new Error(msg);
}
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename="?([^";\n]+)"?/i);
const downloadName = match?.[1] || 'piece.mpr';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = downloadName;
a.click();
URL.revokeObjectURL(url);
setExportStatus(`MPR téléchargé : ${piece.description}`);
} catch (err) {
console.error('Export MPR piece error:', err);
alert(err.message || 'Erreur lors de l\'export MPR');
}
}, [config, modules, colors, orderId]);
const tabs = [
{ id: 'assembly', label: 'Vue de face' },
{ id: 'side', label: 'Vue de côté' },
{ id: 'top', label: 'Vue de dessus' },
{ id: 'cutting', label: 'Liste de débitage' }
{ id: 'cutting', label: 'Liste de débitage' },
{ id: 'hardware', label: 'Quincaillerie' }
];
return (
@@ -108,7 +311,10 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
<div className={styles.modal}>
<div className={styles.header}>
<h2 className={styles.title}>Plans de fabrication</h2>
<button className={styles.closeBtn} onClick={onClose}>×</button>
<button className={styles.closeBtn} onClick={handleClose}>
<Icon name="x" size={14} />
Fermer
</button>
</div>
<div className={styles.tabs}>
@@ -123,38 +329,65 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
))}
</div>
<div className={styles.content}>
<div className={`${styles.content} ${activeTab === 'cutting' ? styles.contentCutting : ''}`}>
{loading ? (
<div className={styles.loading}>Génération des plans...</div>
) : error ? (
<div className={styles.error}>{error}</div>
) : (
<>
{activeTab === 'assembly' && plans?.assembly && (
{activeTab === 'assembly' && (
planViewsError ? (
<div className={styles.empty}>{planViewsError}</div>
) : plans?.assembly ? (
<div
className={styles.planContainer}
dangerouslySetInnerHTML={{ __html: plans.assembly }}
/>
) : (
<div className={styles.empty}>Aucun plan de face disponible.</div>
)
)}
{activeTab === 'side' && plans?.side && (
{activeTab === 'side' && (
planViewsError ? (
<div className={styles.empty}>{planViewsError}</div>
) : plans?.side ? (
<div
className={styles.planContainer}
dangerouslySetInnerHTML={{ __html: plans.side }}
/>
) : (
<div className={styles.empty}>Aucun plan de côté disponible.</div>
)
)}
{activeTab === 'top' && plans?.top && (
{activeTab === 'top' && (
planViewsError ? (
<div className={styles.empty}>{planViewsError}</div>
) : plans?.top ? (
<div
className={styles.planContainer}
dangerouslySetInnerHTML={{ __html: plans.top }}
/>
) : (
<div className={styles.empty}>Aucun plan de dessus disponible.</div>
)
)}
{activeTab === 'cutting' && (
<CuttingListTable
cuttingList={cuttingList}
onExport={handleExport}
onExportMprPiece={handleExportMprPiece}
cncProgram={cncProgram}
planRestoreKey={planRestoreKey}
/>
)}
{activeTab === 'hardware' && (
<HardwareListTable
hardwareList={hardwareList}
error={hardwareError}
/>
)}
</>
@@ -162,20 +395,50 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
</div>
<div className={styles.footer}>
<div className={styles.footerInfo}>
<div className={styles.specs}>
<span>{config.widthMm} × {config.heightMm} × {config.depthMm} mm</span>
<span>•</span>
<span className={styles.specsDims}>
{config.widthMm} × {config.heightMm} × {config.depthMm} mm
</span>
<span className={styles.specsSep} aria-hidden="true">•</span>
<span>{config.installationType === 'niche' ? 'Encastrée' : 'Libre'}</span>
<span>•</span>
<span className={styles.specsSep} aria-hidden="true">•</span>
<span>{config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</span>
</div>
{exportStatus && <div className={styles.exportStatus}>{exportStatus}</div>}
</div>
<div className={styles.actions}>
{activeTab === 'cutting' && (
<button
className={styles.exportBtnSecondary}
onClick={() => handleExport('text')}
>
<Icon name="download" size={15} />
Liste texte
</button>
)}
<button
className={styles.exportBtn}
onClick={() => handleExport('pdf')}
>
<Icon name="download" size={15} />
Imprimer / PDF
</button>
{activeTab === 'cutting' && (
<>
<button
className={styles.exportBtn}
onClick={() => handleExport('cnc')}
>
<Icon name="download" size={15} />
Export CNC (.nc)
</button>
<button className={styles.exportBtn} onClick={() => handleExport('mpr')}>
<Icon name="download" size={15} />
Export MPR (ZIP)
</button>
</>
)}
</div>
</div>
</div>
+131 -21
View File
@@ -17,10 +17,11 @@
border-radius: 12px;
width: 100%;
max-width: 1000px;
max-height: 90vh;
max-height: min(90vh, calc(100dvh - 40px));
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.header {
@@ -39,17 +40,26 @@
}
.closeBtn {
background: none;
border: none;
font-size: 1.5rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: #fff;
border: 1px solid var(--border);
border-radius: 9px;
color: var(--text-muted);
cursor: pointer;
padding: 4px 8px;
line-height: 1;
padding: 0.35rem 0.6rem;
font-size: 0.8rem;
font-weight: 600;
line-height: 1.2;
transition: all 0.15s ease;
}
.closeBtn:hover {
color: var(--text);
border-color: var(--action);
color: var(--action);
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.15);
transform: translateY(-1px);
}
.tabs {
@@ -97,6 +107,15 @@
min-height: 400px;
}
.contentCutting {
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
padding: 20px;
}
.loading,
.error {
display: flex;
@@ -111,6 +130,20 @@
color: var(--danger);
}
.empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 280px;
padding: 24px;
text-align: center;
color: var(--text-muted);
font-size: 0.9rem;
line-height: 1.45;
max-width: 420px;
margin: 0 auto;
}
.planContainer {
display: flex;
align-items: center;
@@ -128,45 +161,107 @@
.footer {
display: flex;
align-items: center;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
padding: 16px 20px;
gap: 12px 16px;
padding: 14px 16px 16px;
border-top: 1px solid var(--border-light);
background: var(--bg-subtle);
flex-shrink: 0;
}
.footerInfo {
flex: 1 1 12rem;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 8px;
}
.specs {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 8px;
font-size: 0.85rem;
line-height: 1.45;
color: var(--text-muted);
}
.specsDims {
white-space: nowrap;
}
.specsSep {
opacity: 0.55;
}
.exportStatus {
font-size: 0.8rem;
line-height: 1.4;
color: #166534;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
flex: 1 1 16rem;
max-width: 100%;
}
.exportBtn {
padding: 10px 20px;
background: var(--action);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 9px 14px;
background: linear-gradient(135deg, var(--action), var(--action-hover));
border: none;
border-radius: 6px;
border-radius: 10px;
color: #fff;
font-size: 0.9rem;
font-weight: 500;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
line-height: 1.25;
}
.exportBtnSecondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 9px 14px;
background: #fff;
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
line-height: 1.25;
}
.exportBtnSecondary:hover {
border-color: var(--action);
color: var(--action);
}
.exportBtn:hover {
background: var(--action-hover);
box-shadow: 0 10px 22px rgba(79, 70, 229, 0.3);
transform: translateY(-1px);
}
@media (max-width: 768px) {
.modal {
max-height: 95vh;
max-height: min(95vh, calc(100dvh - 24px));
}
.tabs {
@@ -182,11 +277,26 @@
.footer {
flex-direction: column;
align-items: stretch;
gap: 12px;
padding: 12px 14px 14px;
}
.footerInfo {
padding-right: 0;
}
.specs {
flex-wrap: wrap;
justify-content: center;
justify-content: flex-start;
}
.actions {
justify-content: stretch;
}
.exportBtn,
.exportBtnSecondary {
flex: 1 1 calc(50% - 4px);
min-width: 9rem;
}
}
@@ -23,6 +23,8 @@ const DEFAULT_COLORS = {
panneauxInterieurs: '#d4c4a4',
separateurs: '#6b5344',
etageres: '#d4c4a4',
rayonFixe: '#d4c4a4',
renfortsTiroir: '#c4a574',
tiroirs: '#c4a574',
penderie: '#8b7355'
};
@@ -43,6 +45,8 @@ const ELEMENT_LABELS = {
panneauxInterieurs: 'Panneaux intérieurs',
separateurs: 'Séparateurs',
etageres: 'Étagères',
rayonFixe: 'Rayon fixe',
renfortsTiroir: 'Renforts tiroir',
tiroirs: 'Tiroirs',
penderie: 'Tringle penderie'
};
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,9 @@
min-height: 280px;
border-radius: 8px;
overflow: hidden;
background: var(--bg);
border: 1px solid var(--border);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
}
.controls {
@@ -17,6 +20,19 @@
z-index: 10;
}
.stepBadge {
padding: 4px 8px;
border-radius: 999px;
background: rgba(79, 70, 229, 0.92);
color: #fff;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.01em;
text-transform: uppercase;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.controlBtn {
width: 36px;
height: 36px;
@@ -44,6 +60,20 @@
transform: scale(0.95);
}
.controlBtnDisabled,
.controlBtn:disabled {
opacity: 0.42;
cursor: not-allowed;
transform: none;
pointer-events: none;
}
.controlBtnDisabled:hover,
.controlBtn:disabled:hover {
transform: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.loading {
display: flex;
align-items: center;
@@ -121,3 +151,4 @@
border-color: var(--action);
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2);
}
@@ -0,0 +1,140 @@
import { useCallback, useEffect } from 'react';
import WardrobePreview from '../WardrobePreview';
import DesignTip from '../DesignTip';
import { resolveShelfPositionsMm, minShelvesForStructuralHeight } from '../../constants/wardrobe';
import styles from './PreviewArea.module.css';
export default function PreviewArea({ wc, ui }) {
const colorPickEnabled = ui.activeStep === 4 || ui.activeStep === 6;
const infoByStep = {
1: 'Survolez les zones colorees du cadre puis glissez pour regler L/H/P.',
2: 'Tirez les separateurs pour redimensionner et glissez les rayonnages par la tranche avant.',
3: 'Activez ou masquez les portes pour verifier l ouverture.',
4: 'Choisissez un element puis appliquez sa couleur.',
5: 'Verifiez les volumes interieurs pour valider la quincaillerie.',
6: 'Controle final: zoom, rotation et verification globale.'
};
const structuralWarnings = (() => {
if (ui.activeStep !== 2) return [];
if (minShelvesForStructuralHeight(wc.config.heightMm) <= 0) return [];
const heightMm = Number(wc.config.heightMm) || 0;
const plinthMm = Number(wc.config.plinthHeightMm) || 0;
const midMm = (heightMm - plinthMm) / 2;
const mods = Array.isArray(wc.config.modules) ? wc.config.modules : [];
const out = [];
for (let i = 0; i < mods.length; i++) {
const mod = mods[i];
const shelfCount = Math.max(0, Number(mod?.shelves) || 0);
const positions = resolveShelfPositionsMm({
shelfCount,
heightMm,
plinthHeightMm: plinthMm,
shelfPositionsMm: mod?.shelfPositionsMm
});
const hasStructural = positions.some((p) => p >= midMm - 200 && p <= midMm + 200);
if (!hasStructural) out.push(`Colonne ${i + 1}: placer un rayon dans la zone structurelle (±20 cm).`);
}
return out;
})();
const handleSetDimension = useCallback((key, valueMm) => {
wc.setDimension(key, valueMm);
}, [wc.setDimension]);
useEffect(() => {
const onKeyDown = (e) => {
if (!e.ctrlKey && !e.metaKey) return;
const el = e.target;
if (el && (el.closest?.('input, textarea, select') || el.isContentEditable)) return;
if (e.key === 'z' && !e.shiftKey) {
e.preventDefault();
wc.undo();
return;
}
if (e.key === 'z' && e.shiftKey) {
e.preventDefault();
wc.redo();
return;
}
if (e.key === 'y' && e.ctrlKey && !e.metaKey) {
e.preventDefault();
wc.redo();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [wc.undo, wc.redo]);
return (
<div className={styles.previewArea}>
<div className={styles.roomControls}>
{ui.roomViewActive && ui.roomConfig ? (
<>
<span className={styles.roomLabel}>
Vue : Ma pi&egrave;ce ({ui.roomAnalysis?.roomDimensions?.widthM}m &times; {ui.roomAnalysis?.roomDimensions?.lengthM}m)
</span>
<div className={styles.roomBtnGroup}>
<button type="button" className={styles.roomBtnSecondary} onClick={() => ui.setShowRoomUpload(true)}>
Nouvelle photo
</button>
<button type="button" className={styles.roomBtn} onClick={() => ui.setRoomViewActive(false)}>
Vue simple
</button>
</div>
</>
) : (
<>
<span className={styles.roomLabel}>Vue : Simple</span>
<div className={styles.roomBtnGroup}>
{ui.roomConfig && (
<button type="button" className={styles.roomBtn} onClick={() => ui.setRoomViewActive(true)}>
Afficher ma pi&egrave;ce
</button>
)}
<button
type="button"
className={ui.roomConfig ? styles.roomBtnSecondary : styles.roomBtn}
onClick={() => ui.setShowRoomUpload(true)}
>
{ui.roomConfig ? 'Nouvelle photo' : 'Visualiser dans ma pi\u00e8ce'}
</button>
<button type="button" className={styles.aiRenderBtn} onClick={() => ui.setShowAIRender(true)}>
<span className={styles.aiBadgeSmall}>IA</span>
Rendu r&eacute;aliste
</button>
</div>
</>
)}
</div>
<div className={styles.preview}>
<WardrobePreview
config={wc.config}
activeStep={ui.activeStep}
roomConfig={ui.roomViewActive ? ui.roomConfig : null}
colors={wc.config.colors}
modules={wc.config.modules}
onElementClick={colorPickEnabled ? (elementType, label) => ui.openColorPicker(elementType, label) : undefined}
onModuleClick={ui.handleModuleClick}
onUpdateModule={wc.handleColumnUpdate}
onResizeModules={wc.handleResizeModules}
selectedModuleIndex={ui.expandedColumn}
onSetDimension={handleSetDimension}
onBeginUndoableGesture={wc.pushUndoSnapshot}
canUndo3d={wc.canUndo}
canRedo3d={wc.canRedo}
onUndo3d={wc.undo}
onRedo3d={wc.redo}
suppressHtmlOverlays={ui.showPlansView || ui.showAIRender || ui.showRoomUpload}
/>
</div>
<div className={styles.infoZone}>
<div className={styles.infoLine}>{infoByStep[ui.activeStep] || 'Manipulez la vue pour controler la configuration.'}</div>
{structuralWarnings.map((msg) => (
<div key={msg} className={styles.warnLine}>{msg}</div>
))}
</div>
<DesignTip config={wc.config} />
</div>
);
}
@@ -0,0 +1,102 @@
.previewArea {
position: relative;
width: 100%;
max-width: 100%;
margin: 0;
}
.roomControls {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
gap: 0.5rem;
flex-wrap: wrap;
}
.roomLabel {
color: var(--text-muted);
font-size: 0.8125rem;
}
.roomBtnGroup {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.roomBtn, .roomBtnSecondary, .aiRenderBtn {
padding: 0.3rem 0.65rem;
border-radius: 5px;
font-size: 0.75rem;
font-weight: 500;
border: 1px solid var(--border);
cursor: pointer;
background: var(--bg-card);
color: var(--text);
transition: all 0.15s;
}
.roomBtn:hover, .roomBtnSecondary:hover, .aiRenderBtn:hover {
background: var(--bg);
}
.aiRenderBtn {
background: var(--action);
color: #fff;
border-color: var(--action);
}
.aiRenderBtn:hover { opacity: 0.9; }
.aiBadgeSmall {
display: inline-block;
background: rgba(255,255,255,0.2);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
padding: 0.1rem 0.3rem;
border-radius: 3px;
margin-right: 0.25rem;
vertical-align: middle;
}
.preview {
width: 100%;
aspect-ratio: 16 / 10;
max-height: 50vh;
background: var(--bg);
border-radius: 8px;
overflow: hidden;
}
.infoZone {
margin-top: 0.5rem;
border: 1px solid var(--border);
background: var(--bg-card);
border-radius: 8px;
padding: 0.45rem 0.6rem;
display: grid;
gap: 0.35rem;
}
.infoLine {
font-size: 0.78rem;
color: var(--text-muted);
}
.warnLine {
font-size: 0.78rem;
color: #b91c1c;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
padding: 0.25rem 0.45rem;
}
@media (max-width: 700px) {
.preview {
aspect-ratio: 16 / 9;
max-height: 40vh;
}
.roomControls { padding: 0.35rem 0.5rem; }
}
@@ -0,0 +1,443 @@
import { useState, useRef, useCallback } from 'react';
import {
COLUMN_TYPES,
maxShelfGapMm,
SHELF_GAP_MM,
minShelvesForStructuralHeight,
resolveShelfPositionsMm,
system32ShelfPositionsMm,
SYSTEM32_PITCH_MM
} from '../../constants/wardrobe';
import s from './steps.module.css';
/** After moving array item from -> to, map a selected index to the new position. */
function mapExpandedIndexAfterReorder(expanded, from, to) {
if (expanded === null || expanded === undefined) return expanded;
if (expanded === from) return to;
if (from < to) {
if (expanded > from && expanded <= to) return expanded - 1;
} else if (from > to) {
if (expanded >= to && expanded < from) return expanded + 1;
}
return expanded;
}
export default function StepColumns({ wc, ui }) {
const {
config, numColumns,
handleNumColumnsChange, handleAIOptimize, handleColumnTypeChange,
handleColumnLock, handleColumnUpdate,
aiOptimizing, aiOptimizeMsg, setAiOptimizeMsg, totalModuleWidth,
reorderModules, pushUndoSnapshot,
} = wc;
const { expandedColumn, setExpandedColumn } = ui;
const [draggingIndex, setDraggingIndex] = useState(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
const dragSourceRef = useRef(null);
const suppressCardClickRef = useRef(false);
const canReorderColumns = config.modules.length > 1;
const handleColumnDragStart = useCallback((index, e) => {
if (!canReorderColumns) {
e.preventDefault();
return;
}
dragSourceRef.current = index;
setDraggingIndex(index);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(index));
}, [canReorderColumns]);
const handleColumnDragOver = useCallback((index, e) => {
if (!canReorderColumns || dragSourceRef.current === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(index);
}, [canReorderColumns]);
const handleColumnDrop = useCallback((toIndex, e) => {
e.preventDefault();
const raw = e.dataTransfer.getData('text/plain');
const fromIndex = Number.parseInt(raw, 10);
dragSourceRef.current = null;
setDragOverIndex(null);
setDraggingIndex(null);
if (!Number.isFinite(fromIndex) || fromIndex === toIndex) return;
suppressCardClickRef.current = true;
pushUndoSnapshot();
reorderModules(fromIndex, toIndex);
setExpandedColumn((prev) => mapExpandedIndexAfterReorder(prev, fromIndex, toIndex));
}, [pushUndoSnapshot, reorderModules, setExpandedColumn]);
const handleColumnDragEnd = useCallback(() => {
dragSourceRef.current = null;
setDraggingIndex(null);
setDragOverIndex(null);
}, []);
const handleColCardClick = useCallback((i, isActive) => {
if (suppressCardClickRef.current) {
suppressCardClickRef.current = false;
return;
}
setExpandedColumn(isActive ? null : i);
}, [setExpandedColumn]);
const minShelvesStructural = minShelvesForStructuralHeight(config.heightMm);
const hasTallCabinet = minShelvesStructural > 0;
const hw = config.hardware || {};
const drawerDepth = config.effectiveDrawerDepthMm || Math.max(400, Math.min(650, config.depthMm - 10));
return (
<div className={s.stepSection}>
<h2 className={s.stepTitle}>Configuration int&eacute;rieure</h2>
<p className={s.stepDesc}>
Choisissez le nombre de colonnes et personnalisez chacune. Cliquez sur un module dans la vue 3D pour le s&eacute;lectionner.
{canReorderColumns && (
<span className={s.stepDescExtra}> Glissez-d&eacute;posez les cartes ci-dessous pour changer l&apos;ordre des colonnes (gauche &rarr; droite = avant &rarr; arri&egrave;re dans la vue 3D).</span>
)}
</p>
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Choix colonne: séparations ou block</label>
<div className={s.colCountBtns}>
{[
{ key: 'separations', label: 'Séparations' },
{ key: 'block', label: 'Block (x caisses)' }
].map((m) => (
<button
key={m.key}
type="button"
className={`${s.colCountBtn} ${config.columnMode === m.key ? s.colCountActive : ''}`}
onClick={() => wc.set('columnMode', m.key)}
>
{m.label}
</button>
))}
</div>
{config.columnMode === 'block' && (
<p className={s.hint}>Assemblage block: vis de jonction + perçages 3 milieux et +100/-100 mm (30 mm face/dos).</p>
)}
</div>
{/* Column count */}
<div className={s.colCountRow}>
<span className={s.colCountLabel}>Nombre de colonnes</span>
<div className={s.colCountBtns}>
{[1, 2, 3, 4, 5, 6].map(n => (
<button
key={n}
type="button"
className={`${s.colCountBtn} ${numColumns === n ? s.colCountActive : ''}`}
onClick={() => handleNumColumnsChange(n)}
>
{n}
</button>
))}
</div>
<button
type="button"
className={s.optimizeBtn}
onClick={handleAIOptimize}
disabled={aiOptimizing}
>
{aiOptimizing ? '...' : '\u2728 Optimiser'}
</button>
</div>
{aiOptimizeMsg && (
<div className={s.aiMsg}>
<span className={s.aiBadge}>IA</span>
<span>{aiOptimizeMsg}</span>
<button className={s.aiDismiss} onClick={() => setAiOptimizeMsg('')}>&times;</button>
</div>
)}
{/* Horizontal column strip */}
{config.modules.length > 0 && (
<>
<p className={s.columnStripLegend}>
Gauche &rarr; droite : colonne 1, 2, 3&hellip; (identique &agrave; la vue 3D).
</p>
<div className={s.columnStrip}>
{config.modules.map((mod, i) => {
const typeDef = COLUMN_TYPES.find(t => t.key === mod.type) || COLUMN_TYPES[0];
const isActive = expandedColumn === i;
const isDragging = draggingIndex === i;
const isOver = dragOverIndex === i && draggingIndex !== null && draggingIndex !== i;
return (
<button
key={mod.id}
type="button"
draggable={canReorderColumns}
onDragStart={(e) => handleColumnDragStart(i, e)}
onDragOver={(e) => handleColumnDragOver(i, e)}
onDrop={(e) => handleColumnDrop(i, e)}
onDragEnd={handleColumnDragEnd}
className={`${s.colCard} ${isActive ? s.colCardActive : ''} ${mod.locked ? s.colCardLocked : ''} ${isDragging ? s.colCardDragging : ''} ${isOver ? s.colCardDropTarget : ''}`}
onClick={() => handleColCardClick(i, isActive)}
title={canReorderColumns ? `Colonne ${i + 1} — cliquer pour d\u00e9tails, glisser pour r\u00e9organiser` : `Colonne ${i + 1}`}
>
{mod.locked && <span className={s.colLockIcon}>&#x1F512;</span>}
<span className={s.colCardIndex}>Colonne {i + 1}</span>
{canReorderColumns && (
<span className={s.colDragHint} aria-hidden="true">::</span>
)}
<span className={s.colCardIcon}>{typeDef.icon}</span>
<span className={s.colCardType}>{typeDef.label}</span>
<span className={s.colCardWidth}>{mod.widthMm} mm</span>
</button>
);
})}
</div>
{/* Detail tray for expanded column */}
{expandedColumn !== null && config.modules[expandedColumn] && (() => {
const i = expandedColumn;
const mod = config.modules[i];
const typeDef = COLUMN_TYPES.find(t => t.key === mod.type) || COLUMN_TYPES[0];
const shelfN = mod.shelves || 0;
const shelfPositions = resolveShelfPositionsMm({
shelfCount: shelfN,
heightMm: config.heightMm,
plinthHeightMm: config.plinthHeightMm ?? 100,
shelfPositionsMm: mod.shelfPositionsMm
});
const minShelves = minShelvesStructural;
const bodyHmm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 0);
const bodyMidMm = bodyHmm / 2;
const shelfSlots = system32ShelfPositionsMm(config.heightMm, config.plinthHeightMm ?? 100);
const structuralIdx =
minShelves > 0 && shelfPositions.length > 0
? shelfPositions.reduce((bestIdx, posMm, idx, arr) => {
const d = Math.abs(posMm - bodyMidMm);
const best = Math.abs(arr[bestIdx] - bodyMidMm);
return d < best ? idx : bestIdx;
}, 0)
: null;
const hasShelfInStructuralWindow =
minShelves <= 0 ||
shelfPositions.some((p) => p >= bodyMidMm - 200 && p <= bodyMidMm + 200);
const gapMax = Math.min(
SHELF_GAP_MM.max,
maxShelfGapMm(config.heightMm, config.plinthHeightMm ?? 100, shelfN)
);
const gapMin = SHELF_GAP_MM.min;
const canCustomGap = shelfN >= 2 && gapMax >= gapMin;
const handleShelfPosChange = (shelfIndex, rawValue) => {
if (!shelfPositions.length) return;
const next = [...shelfPositions];
next[shelfIndex] = Number(rawValue);
handleColumnUpdate(i, { shelfPositionsMm: next });
};
return (
<div className={s.colDetail}>
<div className={s.colDetailHeader}>
<span className={s.colDetailTitle}>Colonne {i + 1} &mdash; {typeDef.icon} {typeDef.label}</span>
<button
type="button"
className={`${s.lockBtn} ${mod.locked ? s.isLocked : ''}`}
onClick={() => handleColumnLock(i)}
>
{mod.locked ? '\u{1F512} Verrouill\u00e9e' : '\u{1F513} Verrouiller'}
</button>
</div>
{/* Type tiles */}
<div className={s.typeTiles}>
{COLUMN_TYPES.map(t => (
<button
key={t.key}
type="button"
className={`${s.typeTile} ${mod.type === t.key ? s.typeTileActive : ''}`}
onClick={() => handleColumnTypeChange(i, t.key)}
>
{t.icon} {t.label}
</button>
))}
</div>
{/* Shelves */}
<div className={s.detailRow}>
<span className={s.detailLabel}>&Eacute;tag&egrave;res</span>
<div className={s.counter}>
<button
type="button"
className={s.counterBtn}
onClick={() => handleColumnUpdate(i, { shelves: Math.max(minShelves, (mod.shelves || 0) - 1) })}
disabled={(mod.shelves || 0) <= minShelves}
>&minus;</button>
<span className={s.counterValue}>{mod.shelves || 0}</span>
<button
type="button"
className={s.counterBtn}
onClick={() => handleColumnUpdate(i, { shelves: Math.min(10, (mod.shelves || 0) + 1) })}
disabled={(mod.shelves || 0) >= 10}
>+</button>
</div>
</div>
{minShelves >= 1 && (mod.shelves || 0) <= minShelves && (
<p className={s.hint}>
Hauteur &gt; 2&nbsp;m : au moins 1 &eacute;tag&egrave;re par colonne (rigidit&eacute;, assemblage RAZENTZAB). Le compteur ne peut pas descendre en dessous.
</p>
)}
{shelfN >= 2 && (
<div className={s.detailRow}>
<span className={s.detailLabel}>Entre rayons (clair)</span>
{mod.shelfGapMm == null ? (
<div className={s.gapModeRow}>
<span className={s.gapAutoLabel}>Automatique</span>
<button
type="button"
className={s.gapCustomizeBtn}
disabled={!canCustomGap}
onClick={() => handleColumnUpdate(i, {
shelfGapMm: Math.round(Math.min(280, gapMax) / SHELF_GAP_MM.step) * SHELF_GAP_MM.step,
})}
>
Personnaliser
</button>
</div>
) : (
<div className={s.gapModeRow}>
<span className={s.counterValue}>{mod.shelfGapMm} mm</span>
<button
type="button"
className={s.gapCustomizeBtn}
onClick={() => handleColumnUpdate(i, { shelfGapMm: null })}
>
Auto
</button>
</div>
)}
</div>
)}
{shelfN >= 2 && mod.shelfGapMm != null && canCustomGap && (
<div className={s.gapRangeRow}>
<input
type="range"
className={s.gapRange}
min={gapMin}
max={gapMax}
step={SHELF_GAP_MM.step}
value={Math.min(gapMax, Math.max(gapMin, mod.shelfGapMm))}
onChange={(e) => handleColumnUpdate(i, { shelfGapMm: Number(e.target.value) })}
/>
</div>
)}
{shelfN >= 2 && !canCustomGap && (
<p className={s.hint}>Hauteur insuffisante pour fixer l&apos;espacement entre rayons (répartition auto).</p>
)}
{shelfN > 0 && shelfPositions.length > 0 && (
<div className={s.detailRow} style={{ alignItems: 'flex-start' }}>
<span className={s.detailLabel}>Positions rayons</span>
<div style={{ display: 'grid', gap: 8, width: '100%' }}>
{shelfPositions.map((posMm, shelfIdx) => {
let minMm = shelfSlots[shelfIdx] ?? shelfSlots[0];
let maxMm = shelfSlots[shelfSlots.length - (shelfPositions.length - shelfIdx)] ?? shelfSlots[shelfSlots.length - 1];
return (
<div key={`shelf-pos-${shelfIdx}`} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className={s.counterValue} style={{ minWidth: 56 }}>
R{String(shelfIdx + 1)}{structuralIdx === shelfIdx ? '*' : ''}
</span>
<input
type="number"
min={minMm}
max={maxMm}
step={SYSTEM32_PITCH_MM}
value={posMm}
onChange={(e) => handleShelfPosChange(shelfIdx, e.target.value)}
className={s.gapRange}
/>
<span className={s.hint}>mm</span>
</div>
);
})}
</div>
</div>
)}
{structuralIdx !== null && (
<p className={s.hint}>* Rayon structurel recommandé autour du milieu (zone ±200 mm).</p>
)}
{minShelves > 0 && !hasShelfInStructuralWindow && (
<p className={s.warnText}>Aucun rayon dans la zone structurelle ±200 mm autour du milieu. Placez-en un pour la rigidité.</p>
)}
{/* Drawers */}
<div className={s.detailRow}>
<span className={s.detailLabel}>Tiroirs</span>
<div className={s.counter}>
<button
type="button"
className={s.counterBtn}
onClick={() => handleColumnUpdate(i, { drawers: Math.max(0, (mod.drawers || 0) - 1) })}
disabled={(mod.drawers || 0) <= 0}
>&minus;</button>
<span className={s.counterValue}>{mod.drawers || 0}</span>
<button
type="button"
className={s.counterBtn}
onClick={() => handleColumnUpdate(i, { drawers: Math.min(6, (mod.drawers || 0) + 1) })}
disabled={(mod.drawers || 0) >= 6}
>+</button>
</div>
</div>
{(mod.drawers || 0) > 0 && (
<>
<div className={s.detailRow}>
<span className={s.detailLabel}>Type tiroirs</span>
<div className={s.colCountBtns} style={{ flexWrap: 'wrap' }}>
{[
{ key: 'visible', label: 'Visible' },
{ key: 'hidden', label: 'Caché' }
].map((item) => (
<button
key={item.key}
type="button"
className={`${s.colCountBtn} ${hw.drawerType === item.key ? s.colCountActive : ''}`}
onClick={() => wc.setHardware('drawerType', item.key)}
>
{item.label}
</button>
))}
</div>
</div>
<p className={s.hint} style={{ marginTop: 0 }}>
Manufacture depuis le bas. Profondeur utile: {drawerDepth} mm (réglage global).
</p>
</>
)}
{/* Rod */}
<div className={s.detailRow}>
<span className={s.detailLabel}>Tringle</span>
<button
type="button"
className={`${s.toggleBtn} ${mod.hasRod ? s.toggleOn : ''}`}
onClick={() => handleColumnUpdate(i, { hasRod: !mod.hasRod })}
>
{mod.hasRod ? 'Oui' : 'Non'}
</button>
</div>
</div>
);
})()}
<p className={s.summaryLine}>
{config.modules.length} colonne{config.modules.length > 1 ? 's' : ''} &mdash; Largeur totale : {totalModuleWidth} mm
</p>
{hasTallCabinet && <p className={s.warnText}>Armoire &gt; 2m: rayon au milieu obligatoire avec assemblage RAZENTZAB.</p>}
{config.modules.some((m) => (m.drawers || 0) > 0 && (m.widthMm || 0) > 1000) && (
<p className={s.warnText}>Une colonne avec tiroirs dépasse 1000 mm. Limite max appliquée.</p>
)}
{config.modules.some((m) => m.hasRod) && config.heightMm < 1200 && (
<p className={s.warnText}>Rayon avec tringle: hauteur minimale 1200 mm.</p>
)}
<p className={s.hint}>Tirez les s&eacute;parateurs dans la vue 3D pour redimensionner</p>
</>
)}
</div>
);
}
@@ -0,0 +1,196 @@
import s from './steps.module.css';
const DIMS = [
{ key: 'widthMm', label: 'Largeur totale', min: 400, max: 5000, step: 50, unit: 'mm' },
{ key: 'heightMm', label: 'Hauteur', min: 1800, max: 2800, step: 50, unit: 'mm' },
{ key: 'depthMm', label: 'Profondeur', min: 400, max: 800, step: 10, unit: 'mm' },
{ key: 'plinthHeightMm', label: 'Plinthe', min: 60, max: 160, step: 10, unit: 'mm' },
];
export default function StepDimensions({ wc }) {
const { config, set } = wc;
const niche = config.nicheMeasurements || {};
const sideDefaultsEnabled = config.sideFinishesEnabled !== false;
const sideLeftEnabled = config.sideFinishLeftEnabled ?? sideDefaultsEnabled;
const sideRightEnabled = config.sideFinishRightEnabled ?? sideDefaultsEnabled;
const topFinishEnabled = config.topFinishEnabled !== false;
const minNicheWidth = Math.min(niche.widthTopMm || config.widthMm, niche.widthMidMm || config.widthMm, niche.widthBottomMm || config.widthMm);
const minNicheDepth = Math.min(niche.depthTopMm || config.depthMm, niche.depthMidMm || config.depthMm, niche.depthBottomMm || config.depthMm);
const minNicheHeight = Math.min(niche.heightLeftMm || config.heightMm, niche.heightRightMm || config.heightMm);
const gap = Number(config.sanitaryGapMm || 30);
const usableWidth = Math.max(400, minNicheWidth - gap * 2);
const usableDepth = Math.max(400, minNicheDepth - gap * 2);
const usableHeight = Math.max(1800, minNicheHeight - gap * 2);
return (
<div className={s.stepSection}>
<h2 className={s.stepTitle}>Espace &amp; Dimensions</h2>
{/* Installation type */}
<div className={s.cardToggle}>
<button
type="button"
className={`${s.cardToggleItem} ${config.installationType === 'free' ? s.cardToggleActive : ''}`}
onClick={() => set('installationType', 'free')}
>
<span className={s.cardToggleIcon} aria-hidden="true">
<svg className={s.cardToggleIconSvg} viewBox="0 0 24 24" fill="none">
<rect x="4" y="6" width="10" height="12" rx="1.5" />
<path d="M17 4v16" />
<path d="M7.5 10h3M7.5 14h3" />
</svg>
</span>
<span className={s.cardToggleLabel}>Libre</span>
<span className={s.cardToggleHint}>Pos&eacute;e contre un mur</span>
</button>
<button
type="button"
className={`${s.cardToggleItem} ${config.installationType === 'niche' ? s.cardToggleActive : ''}`}
onClick={() => set('installationType', 'niche')}
>
<span className={s.cardToggleIcon} aria-hidden="true">
<svg className={s.cardToggleIconSvg} viewBox="0 0 24 24" fill="none">
<path d="M4 4v16M20 4v16" />
<rect x="8" y="6" width="8" height="12" rx="1.5" />
<path d="M10.5 10h3M10.5 14h3" />
</svg>
</span>
<span className={s.cardToggleLabel}>Niche</span>
<span className={s.cardToggleHint}>Encastr&eacute;e entre deux murs</span>
</button>
</div>
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Type de pièce</label>
<div className={s.typeTiles}>
{[
{ key: 'other', label: 'Autre' },
{ key: 'bathroom', label: 'Salle de bain' },
{ key: 'laundry', label: 'Buanderie' },
{ key: 'technical', label: 'Local technique' }
].map((r) => (
<button
key={r.key}
type="button"
className={`${s.typeTile} ${config.roomType === r.key ? s.typeTileActive : ''}`}
onClick={() => set('roomType', r.key)}
>
{r.label}
</button>
))}
</div>
</div>
{/* Dimension sliders */}
<div className={s.dimGrid}>
{DIMS.map(({ key, label, min, max, step, unit }) => (
<div key={key} className={s.dimField}>
<span className={s.dimLabel}>{label}</span>
<div className={s.dimSliderRow}>
<input
type="range"
className={s.dimSlider}
min={min}
max={max}
step={step}
value={config[key]}
onChange={(e) => set(key, Number(e.target.value))}
/>
<input
type="number"
className={s.dimValue}
min={min}
max={max}
step={step}
value={config[key]}
onChange={(e) => set(key, Number(e.target.value) || min)}
/>
<span className={s.dimUnit}>{unit}</span>
</div>
</div>
))}
</div>
{config.installationType === 'free' && (
<div className={`${s.hardwareGroup} ${s.finishHabillageGroup}`}>
<label className={s.hardwareLabel}>Habillages de finition</label>
<div className={s.finishSelector}>
<button
type="button"
className={`${s.finishOption} ${sideLeftEnabled ? s.finishOptionActive : ''}`}
onClick={() => set('sideFinishLeftEnabled', !sideLeftEnabled)}
>
<span className={s.finishOptionLabel}>C&ocirc;t&eacute; gauche</span>
<span className={s.finishOptionState}>{sideLeftEnabled ? 'Actif' : 'Retir&eacute;'}</span>
</button>
<button
type="button"
className={`${s.finishOption} ${sideRightEnabled ? s.finishOptionActive : ''}`}
onClick={() => set('sideFinishRightEnabled', !sideRightEnabled)}
>
<span className={s.finishOptionLabel}>C&ocirc;t&eacute; droit</span>
<span className={s.finishOptionState}>{sideRightEnabled ? 'Actif' : 'Retir&eacute;'}</span>
</button>
<button
type="button"
className={`${s.finishOption} ${topFinishEnabled ? s.finishOptionActive : ''}`}
onClick={() => set('topFinishEnabled', !topFinishEnabled)}
>
<span className={s.finishOptionLabel}>Dessus</span>
<span className={s.finishOptionState}>{topFinishEnabled ? 'Actif' : 'Retir&eacute;'}</span>
</button>
</div>
</div>
)}
{config.installationType === 'niche' && (
<div className={s.panelInfo}>
<div className={s.panelInfoTitle}>Mesures niche complètes</div>
<div className={s.ruleGrid3}>
<label className={s.dimField}>
<span className={s.dimLabel}>Largeur haut</span>
<input className={s.dimValue} type="number" value={niche.widthTopMm || config.widthMm} onChange={(e) => set('nicheMeasurements', { ...niche, widthTopMm: Number(e.target.value) || config.widthMm })} />
</label>
<label className={s.dimField}>
<span className={s.dimLabel}>Largeur milieu</span>
<input className={s.dimValue} type="number" value={niche.widthMidMm || config.widthMm} onChange={(e) => set('nicheMeasurements', { ...niche, widthMidMm: Number(e.target.value) || config.widthMm })} />
</label>
<label className={s.dimField}>
<span className={s.dimLabel}>Largeur bas</span>
<input className={s.dimValue} type="number" value={niche.widthBottomMm || config.widthMm} onChange={(e) => set('nicheMeasurements', { ...niche, widthBottomMm: Number(e.target.value) || config.widthMm })} />
</label>
<label className={s.dimField}>
<span className={s.dimLabel}>Profondeur haut</span>
<input className={s.dimValue} type="number" value={niche.depthTopMm || config.depthMm} onChange={(e) => set('nicheMeasurements', { ...niche, depthTopMm: Number(e.target.value) || config.depthMm })} />
</label>
<label className={s.dimField}>
<span className={s.dimLabel}>Profondeur milieu</span>
<input className={s.dimValue} type="number" value={niche.depthMidMm || config.depthMm} onChange={(e) => set('nicheMeasurements', { ...niche, depthMidMm: Number(e.target.value) || config.depthMm })} />
</label>
<label className={s.dimField}>
<span className={s.dimLabel}>Profondeur bas</span>
<input className={s.dimValue} type="number" value={niche.depthBottomMm || config.depthMm} onChange={(e) => set('nicheMeasurements', { ...niche, depthBottomMm: Number(e.target.value) || config.depthMm })} />
</label>
<label className={s.dimField}>
<span className={s.dimLabel}>Hauteur gauche</span>
<input className={s.dimValue} type="number" value={niche.heightLeftMm || config.heightMm} onChange={(e) => set('nicheMeasurements', { ...niche, heightLeftMm: Number(e.target.value) || config.heightMm })} />
</label>
<label className={s.dimField}>
<span className={s.dimLabel}>Hauteur droite</span>
<input className={s.dimValue} type="number" value={niche.heightRightMm || config.heightMm} onChange={(e) => set('nicheMeasurements', { ...niche, heightRightMm: Number(e.target.value) || config.heightMm })} />
</label>
<label className={s.dimField}>
<span className={s.dimLabel}>Vide sanitaire (mm)</span>
<input className={s.dimValue} type="number" min={10} max={50} step={1} value={config.sanitaryGapMm || 30} onChange={(e) => set('sanitaryGapMm', Number(e.target.value) || 30)} />
</label>
</div>
<div className={s.panelRow}><span>Plus petite largeur:</span><strong>{minNicheWidth} mm</strong></div>
<div className={s.panelRow}><span>Plus petite profondeur:</span><strong>{minNicheDepth} mm</strong></div>
<div className={s.panelRow}><span>Plus petite hauteur:</span><strong>{minNicheHeight} mm</strong></div>
<div className={s.panelRow}><span>Cote utile retenue:</span><strong>{usableWidth} × {usableHeight} × {usableDepth} mm</strong></div>
</div>
)}
</div>
);
}
@@ -0,0 +1,187 @@
import s from './steps.module.css';
import { getDoorHingeSide } from '@shared/manufacturing.js';
export default function StepDoors({ wc }) {
const { config, set } = wc;
const hw = config.hardware || {};
const handleHeight = Number(hw.handleBoreHeightMm || 1000);
const isNonStandardHandleHeight = handleHeight < 800 || handleHeight > 1200;
const doorCount = Math.max(1, (config.modules?.length || 0) || (config.blocks?.length || 0) || 2);
const hingeSides = Array.isArray(hw.doorHingeSides) ? hw.doorHingeSides : null;
const getHingeSideForDoor = (i) => {
if (hingeSides && hingeSides.length === doorCount) {
return hingeSides[i] === 'right' ? 'right' : 'left';
}
return getDoorHingeSide(i, doorCount, hw);
};
const setDoorHingeSideAt = (i, side) => {
const next = Array.from({ length: doorCount }, (_, idx) => getHingeSideForDoor(idx));
next[i] = side === 'right' ? 'right' : 'left';
wc.setHardware('doorHingeSides', next);
};
return (
<div className={s.stepSection}>
<h2 className={s.stepTitle}>Type de portes</h2>
<p className={s.stepDesc}>Portes battantes uniquement pour le moment. Configurez charni&egrave;res et poign&eacute;es.</p>
<div className={s.cardToggle}>
<button
type="button"
className={`${s.cardToggleItem} ${config.doorType === 'hinged' ? s.cardToggleActive : ''}`}
onClick={() => set('doorType', 'hinged')}
>
<span className={s.cardToggleIcon}>&#x1F6AA;</span>
<span className={s.cardToggleLabel}>Battantes</span>
<span className={s.cardToggleHint}>Portes classiques &agrave; charni&egrave;res</span>
</button>
</div>
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Côté charnières (par porte)</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{Array.from({ length: doorCount }).map((_, i) => {
const active = getHingeSideForDoor(i);
return (
<div key={`door-hinge-${i}`} style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span style={{ minWidth: 88, fontSize: 13, color: '#334155', fontWeight: 600 }}>
Porte {i + 1}
</span>
<div className={s.colCountBtns}>
{[
{ key: 'left', label: 'Gauche' },
{ key: 'right', label: 'Droite' }
].map((item) => (
<button
key={item.key}
type="button"
className={`${s.colCountBtn} ${active === item.key ? s.colCountActive : ''}`}
onClick={() => setDoorHingeSideAt(i, item.key)}
>
{item.label}
</button>
))}
</div>
</div>
);
})}
</div>
</div>
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Valeurs par défaut (si non défini par porte)</label>
<div className={s.detailRow} style={{ marginBottom: 8 }}>
<span className={s.hint}>
Si vous définissez « par porte » ci-dessus, ces réglages ne servent que de fallback.
</span>
</div>
<label className={s.hardwareLabel}>Portes uniques: charnières gauche/droite</label>
<div className={s.colCountBtns}>
{[
{ key: 'left', label: 'Gauche' },
{ key: 'right', label: 'Droite' }
].map((item) => (
<button
key={item.key}
type="button"
className={`${s.colCountBtn} ${hw.doorHingeSide === item.key ? s.colCountActive : ''}`}
onClick={() => wc.setHardware('doorHingeSide', item.key)}
>
{item.label}
</button>
))}
</div>
<label className={s.hardwareLabel} style={{ marginTop: 10 }}>Portes centrales: fiche gauche/droite</label>
<div className={s.colCountBtns}>
{[
{ key: 'left', label: 'Fiche gauche' },
{ key: 'right', label: 'Fiche droite' }
].map((item) => (
<button
key={item.key}
type="button"
className={`${s.colCountBtn} ${hw.centerDoorHingeSide === item.key ? s.colCountActive : ''}`}
onClick={() => wc.setHardware('centerDoorHingeSide', item.key)}
>
{item.label}
</button>
))}
</div>
</div>
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Perçage poignée (axe depuis le sol)</label>
<div className={s.dimSliderRow}>
<input
type="range"
className={s.dimSlider}
min={600}
max={1600}
step={10}
value={handleHeight}
onChange={(e) => wc.setHardware('handleBoreHeightMm', Number(e.target.value))}
/>
<input
type="number"
className={s.dimValue}
min={600}
max={1600}
step={10}
value={handleHeight}
onChange={(e) => wc.setHardware('handleBoreHeightMm', Number(e.target.value) || 1000)}
/>
<span className={s.dimUnit}>mm</span>
</div>
<p className={s.hint}>Plage standard visible sur plan: 1000 mm ± 200 mm.</p>
{isNonStandardHandleHeight && (
<p className={s.warnText}>Configuration non standard: hauteur hors plage standard.</p>
)}
</div>
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Type de poignée</label>
<div className={s.colCountBtns}>
{[
{ key: 'standard', label: 'Standard' },
{ key: 'full_height', label: 'Toute hauteur' }
].map((item) => (
<button
key={item.key}
type="button"
className={`${s.colCountBtn} ${hw.handleType === item.key ? s.colCountActive : ''}`}
onClick={() => wc.setHardware('handleType', item.key)}
>
{item.label}
</button>
))}
</div>
</div>
<div className={s.detailRow}>
<span className={s.detailLabel}>Portes coulissantes</span>
<span className={s.hint}>Indisponible pour le moment (support backend conservé).</span>
</div>
{config.doorType === 'sliding' && (
<div className={s.detailRow}>
<span className={s.detailLabel}>Nombre de vantaux (legacy)</span>
<div className={s.colCountBtns}>
{[2, 3].map((n) => (
<button
key={n}
type="button"
className={`${s.colCountBtn} ${config.slidingPanelsCount === n ? s.colCountActive : ''}`}
onClick={() => set('slidingPanelsCount', n)}
>
{n}
</button>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,239 @@
import { COLUMN_TYPES } from '../../constants/wardrobe';
import { submitOrder } from '../../api/client';
import Icon from '../ui/Icon';
import s from './steps.module.css';
export default function StepFinalize({ wc, ui }) {
const { config, priceResult, serviceOptions, selectedFinish, totalModuleWidth } = wc;
const { orderForm, setOrderForm, orderSent, setOrderSent, setActiveStep } = ui;
const errors = priceResult?.errors || [];
const totalChf = priceResult?.totalChf ?? 0;
const handleSubmitOrder = (e) => {
e.preventDefault();
submitOrder({ config, totalChf: priceResult?.totalChf, ...orderForm })
.then((order) => {
setOrderSent(true);
ui.setLastOrderId(order?.id || null);
})
.catch(alert);
};
// Build summary lines
const installLabel = config.installationType === 'free' ? 'Libre' : 'Niche';
const doorLabel = config.doorType === 'hinged' ? 'Battantes' : `Coulissantes (${config.slidingPanelsCount} vantaux) [legacy]`;
const roomLabel = {
other: 'Autre',
bathroom: 'Salle de bain',
laundry: 'Buanderie',
technical: 'Local technique'
}[config.roomType || 'other'];
const columnsDesc = config.modules.map((m, i) => {
const t = COLUMN_TYPES.find(ct => ct.key === m.type) || COLUMN_TYPES[0];
return `${t.icon} ${t.label} (${m.widthMm}mm)`;
}).join(', ');
return (
<div className={s.stepSection}>
<h2 className={s.stepTitle}>R&eacute;capitulatif &amp; Commande</h2>
{errors.length > 0 && (
<ul className={s.errors}>
{errors.map((err, i) => <li key={i}>{err.message}</li>)}
</ul>
)}
{/* Configuration summary */}
<div className={s.summaryCard}>
<div className={s.summaryRow}>
<span className={s.summaryLabel}>Installation</span>
<span className={s.summaryValue}>
{installLabel} · {roomLabel}
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(1)}>modifier</button>
</span>
</div>
<div className={s.summaryRow}>
<span className={s.summaryLabel}>Dimensions</span>
<span className={s.summaryValue}>
{config.widthMm} &times; {config.heightMm} &times; {config.depthMm} mm
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(1)}>modifier</button>
</span>
</div>
<div className={s.summaryRow}>
<span className={s.summaryLabel}>Colonnes ({config.modules.length})</span>
<span className={s.summaryValue}>
{totalModuleWidth} mm
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(2)}>modifier</button>
</span>
</div>
<div className={s.summaryRow}>
<span className={s.summaryLabel}>Portes</span>
<span className={s.summaryValue}>
{doorLabel}
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(3)}>modifier</button>
</span>
</div>
<div className={s.summaryRow}>
<span className={s.summaryLabel}>Mat&eacute;riau</span>
<span className={s.summaryValue}>
{selectedFinish?.label || 'Non s\u00e9lectionn\u00e9'}
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(4)}>modifier</button>
</span>
</div>
</div>
{/* Panel optimization */}
{priceResult?.details?.panelOptimization && (
<div className={s.panelInfo}>
<div className={s.panelInfoTitle}>Optimisation panneaux</div>
<div className={s.panelRow}>
<span>Format optimal :</span>
<strong>
{priceResult.details.panelOptimization.selectedDimension.width_mm} &times;{' '}
{priceResult.details.panelOptimization.selectedDimension.height_mm} mm
</strong>
</div>
<div className={s.panelRow}>
<span>Panneaux n&eacute;cessaires :</span>
<strong>{priceResult.details.panelOptimization.panelsNeeded}</strong>
</div>
<div className={s.panelRow}>
<span>Surface requise :</span>
<span>{priceResult.surfaceM2?.toFixed(2)} m&sup2;</span>
</div>
<div className={s.panelRow}>
<span>Chute estim&eacute;e :</span>
<span>{priceResult.details.panelOptimization.wasteM2?.toFixed(2)} m&sup2; ({priceResult.details.panelOptimization.wastePercent}%)</span>
</div>
</div>
)}
{/* Manufacturing specs (APEX 4.0) */}
<div className={s.panelInfo}>
<div className={s.panelInfoTitle}>Fabrication APEX 4.0</div>
<div className={s.panelRow}>
<span>Structure / Renforts / Dos :</span>
<strong>19 mm / 16 mm / 8 mm</strong>
</div>
<div className={s.panelRow}>
<span>System 32 :</span>
<strong>&Oslash;5 mm, prof. 12 mm, pas 32 mm</strong>
</div>
<div className={s.panelRow}>
<span>Rainure dos :</span>
<strong>9&times;10.5 mm, &agrave; 16 mm du bord</strong>
</div>
<div className={s.panelRow}>
<span>Connecteurs structure :</span>
<strong>SC 8/60 (extrémités T@20 &rarr; IX@50 &rarr; T@80 · milieu IX + T ±30 mm)</strong>
</div>
<div className={s.panelRow}>
<span>Connecteurs finition :</span>
<strong>SC 8/25 (axes 30/43 mm)</strong>
</div>
<div className={s.panelRow}>
<span>Plinthe :</span>
<strong>AXILO&trade; 78</strong>
</div>
{priceResult?.details?.reinforcementCount > 0 && (
<div className={s.panelRow}>
<span>Renforts Forteresse :</span>
<strong>{priceResult.details.reinforcementCount} pcs (1&times; SC 8/60 + 2&times; tourillons)</strong>
</div>
)}
{priceResult?.details?.reinforcementChf > 0 && (
<div className={s.panelRow}>
<span>Co&ucirc;t renforts :</span>
<strong>CHF {priceResult.details.reinforcementChf.toFixed(2)}</strong>
</div>
)}
{priceResult?.details?.blockExtraStructureChf > 0 && (
<div className={s.panelRow}>
<span>Surplus structure (assemblage block, montants suppl.) :</span>
<strong>CHF {priceResult.details.blockExtraStructureChf.toFixed(2)}</strong>
</div>
)}
</div>
{/* Action buttons */}
<div className={s.actionBtns}>
<button type="button" className={s.actionBtn} onClick={() => ui.setShowPlansView(true)}>
<Icon name="blueprint" size={14} />
Plans de fabrication
</button>
<button type="button" className={s.actionBtn} onClick={() => ui.setShowAIRender(true)}>
<Icon name="sparkles" size={14} />
Rendu IA r&eacute;aliste
</button>
<button type="button" className={s.actionBtn} onClick={() => ui.setShowRoomUpload(true)}>
<Icon name="eye" size={14} />
Visualiser dans ma pi&egrave;ce
</button>
</div>
{/* Service options */}
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Options</label>
{serviceOptions.map((opt) => (
<label key={opt.id} className={s.checkLabel}>
<input
type="checkbox"
checked={(config.serviceOptionIds || []).includes(opt.id)}
onChange={(e) => {
const ids = config.serviceOptionIds || [];
wc.set('serviceOptionIds', e.target.checked ? [...ids, opt.id] : ids.filter((id) => id !== opt.id));
}}
/>
{opt.label} &mdash; CHF {opt.price_chf}/h
</label>
))}
</div>
{/* Price */}
<div className={s.priceBreakdown}>
<div className={s.priceTotal}>
<span>Total</span>
<span>CHF {totalChf.toLocaleString('fr-CH', { minimumFractionDigits: 2 })}.&ndash;</span>
</div>
</div>
{/* Order form */}
{!orderSent ? (
<form onSubmit={handleSubmitOrder} className={s.orderForm}>
<input
type="email"
placeholder="Email *"
value={orderForm.email}
onChange={(e) => setOrderForm((f) => ({ ...f, email: e.target.value }))}
required
/>
<input
type="text"
placeholder="Nom"
value={orderForm.name}
onChange={(e) => setOrderForm((f) => ({ ...f, name: e.target.value }))}
/>
<input
type="tel"
placeholder="T&eacute;l&eacute;phone"
value={orderForm.phone}
onChange={(e) => setOrderForm((f) => ({ ...f, phone: e.target.value }))}
/>
<textarea
placeholder="Remarques"
value={orderForm.notes}
onChange={(e) => setOrderForm((f) => ({ ...f, notes: e.target.value }))}
rows={2}
/>
<button type="submit" className={s.submitBtn}>
<Icon name="file" size={15} />
Commander / Envoyer ma demande
</button>
</form>
) : (
<p className={s.success}>Votre demande a bien &eacute;t&eacute; envoy&eacute;e. Nous vous recontacterons.</p>
)}
</div>
);
}
@@ -0,0 +1,110 @@
import s from './steps.module.css';
export default function StepHardware({ wc }) {
const { config, handleVariants, hingeVariants, blumHardware, setHardware } = wc;
const hasDrawers = config.modules.some(m => m.drawers > 0);
const drawerType = config.hardware?.drawerType || 'visible';
const standardHeights = [60, 90, 128, 177, 240];
const currentHeights = config.hardware?.drawerHeightsMm || [90, 128, 177, 240];
return (
<div className={s.stepSection}>
<h2 className={s.stepTitle}>Quincaillerie</h2>
<p className={s.stepDesc}>S&eacute;lectionnez les poign&eacute;es, charni&egrave;res et syst&egrave;mes de tiroirs.</p>
{/* Handles */}
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Poign&eacute;es</label>
<div className={s.hardwareGrid}>
{handleVariants.map(h => (
<button
key={h.id}
type="button"
className={`${s.hardwareBtn} ${config.hardware.handleVariantId === h.id ? s.hardwareBtnSelected : ''}`}
onClick={() => setHardware('handleVariantId', h.id)}
title={`${h.name} - ${h.price_chf} CHF`}
disabled={drawerType === 'visible' && hasDrawers}
>
{h.image_url ? (
<img src={h.image_url} alt={h.name} />
) : (
<span className={s.hardwareIcon}>&#x1F6AA;</span>
)}
<span className={s.hardwareName}>{h.name}</span>
<span className={s.hardwarePrice}>{h.price_chf} CHF</span>
</button>
))}
</div>
{drawerType === 'visible' && hasDrawers && (
<p className={s.hint}>Tiroir visible avec coulisse Blum Motion: poignée non requise.</p>
)}
</div>
{/* Hinges */}
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Charni&egrave;res</label>
<div className={s.hardwareGrid}>
{hingeVariants.map(h => (
<button
key={h.id}
type="button"
className={`${s.hardwareBtn} ${config.hardware.hingeVariantId === h.id ? s.hardwareBtnSelected : ''}`}
onClick={() => setHardware('hingeVariantId', h.id)}
>
<span className={s.hardwareIcon}>{h.type === 'vissee' ? '\u{1F529}' : '\u{1F4CC}'}</span>
<span className={s.hardwareName}>{h.name}</span>
<span className={s.hardwareType}>{h.type === 'vissee' ? 'Viss\u00e9e' : 'Plant\u00e9e'}</span>
<span className={s.hardwarePrice}>{h.price_chf} CHF</span>
</button>
))}
</div>
</div>
{/* Drawer systems */}
{hasDrawers && (
<div className={s.hardwareGroup}>
<label className={s.hardwareLabel}>Syst&egrave;me de tiroirs (Blum)</label>
<div className={s.hardwareGrid}>
{blumHardware.filter(h => h.type === 'drawer_system').map(h => (
<button
key={h.id}
type="button"
className={`${s.hardwareBtn} ${config.hardware.drawerSystemId === h.id ? s.hardwareBtnSelected : ''}`}
onClick={() => setHardware('drawerSystemId', h.id)}
>
<span className={s.hardwareIcon}>&#x1F5C4;&#xFE0F;</span>
<span className={s.hardwareName}>{h.name}</span>
<span className={s.hardwarePrice}>{h.price_chf} CHF</span>
</button>
))}
</div>
<div className={s.panelInfo}>
<div className={s.panelInfoTitle}>Hauteurs standard tiroirs</div>
<div className={s.typeTiles}>
{standardHeights.map((mm) => {
const active = currentHeights.includes(mm);
return (
<button
key={mm}
type="button"
className={`${s.typeTile} ${active ? s.typeTileActive : ''}`}
onClick={() => {
const next = active
? currentHeights.filter((h) => h !== mm)
: [...currentHeights, mm].sort((a, b) => a - b);
setHardware('drawerHeightsMm', next.slice(0, 4));
}}
>
{mm} mm
</button>
);
})}
</div>
<p className={s.hint}>4 hauteurs standard max (hypothèse codée en dur pour cette version).</p>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,154 @@
import s from './steps.module.css';
const ELEMENT_LABELS = {
portes: 'Portes',
cotes: 'C\u00f4t\u00e9s',
dessus: 'Dessus',
fond: 'Fond',
dos: 'Dos',
etageres: '\u00c9tag\u00e8res',
tiroirs: 'Tiroirs',
separateurs: 'S\u00e9parateurs',
plintheBas: 'Plinthe',
bandeauHaut: 'Bandeau haut',
};
export default function StepMaterials({ wc, ui }) {
const { config, finishes, selectedFinish } = wc;
const roomType = config.roomType || 'other';
const needsHydro = roomType === 'bathroom' || roomType === 'laundry';
const needsFire = roomType === 'technical';
const hasOverride = !!config.allowMaterialOverride;
const normalize = (v) => String(v || '').toLowerCase();
const isHydro = (m) =>
[normalize(m.category), normalize(m.collection), normalize(m.label), normalize(m.name), normalize(m.code)]
.join(' ')
.match(/hydro|hydrofuge|hpl|marine/);
const isFire = (m) =>
[normalize(m.category), normalize(m.collection), normalize(m.label), normalize(m.name), normalize(m.code)]
.join(' ')
.match(/igni|fire|m1|a2|incomb/);
const allowedMaterial = (m) => {
if (hasOverride) return true;
if (needsHydro) return !!isHydro(m);
if (needsFire) return !!isFire(m);
return true;
};
// Quick pick: first 10 finishes with images
const quickPicks = finishes.filter((f) => f.image_url && allowedMaterial(f)).slice(0, 10);
return (
<div className={s.stepSection}>
<h2 className={s.stepTitle}>Mat&eacute;riaux &amp; Couleurs</h2>
<p className={s.stepDesc}>Choisissez le mat&eacute;riau principal et personnalisez les couleurs par &eacute;l&eacute;ment.</p>
{(needsHydro || needsFire) && !hasOverride && (
<p className={s.warnText}>
{needsHydro
? 'Pièce humide: seuls les panneaux hydrofuges sont autorisés.'
: 'Local technique: seuls les panneaux ignifuges sont autorisés.'}
</p>
)}
{(needsHydro || needsFire) && hasOverride && (
<p className={s.warnText}>Dérogation active: tous les matériaux sont affichés (configuration non standard).</p>
)}
{(needsHydro || needsFire) && (
<label className={s.overrideToggle}>
<input
type="checkbox"
checked={hasOverride}
onChange={(e) => wc.set('allowMaterialOverride', e.target.checked)}
/>
<span>
<strong>Dérogation matériaux</strong>
<small>Débloquer tous les matériaux avec avertissement.</small>
</span>
</label>
)}
{/* Current material */}
{selectedFinish && (
<div className={s.currentMaterial}>
{selectedFinish.image_url ? (
<img src={selectedFinish.image_url} alt={selectedFinish.label} className={s.currentMaterialImg} />
) : (
<div className={s.quickSwatchNoImg}>?</div>
)}
<div className={s.currentMaterialInfo}>
<strong>{selectedFinish.label}</strong>
<span>{selectedFinish.code} &mdash; {selectedFinish.collection}</span>
</div>
</div>
)}
{/* Quick pick row */}
<div className={s.quickPick}>
{quickPicks.map(mat => (
<button
key={mat.id}
type="button"
className={`${s.quickSwatch} ${mat.id === config.finishId ? s.quickSwatchActive : ''}`}
onClick={() => {
ui.openMaterialDetail(mat);
}}
title={mat.label}
>
<img src={mat.image_url} alt={mat.label} className={s.quickSwatchImg} />
<span className={s.quickSwatchLabel}>{mat.label}</span>
</button>
))}
</div>
<button
type="button"
className={s.browseBtn}
onClick={() => ui.setShowMaterialPicker(true)}
>
{(needsHydro || needsFire) && !hasOverride
? 'Parcourir les matériaux autorisés'
: 'Parcourir tous les mat&eacute;riaux'}
</button>
<div className={s.panelInfo}>
<div className={s.panelInfoTitle}>Contraintes chants et finitions</div>
<div className={s.panelRow}>
<span>Bande de chant:</span>
<strong>même couleur que la face extérieure</strong>
</div>
<div className={s.panelRow}>
<span>Mode libre:</span>
<strong>couleur doublage au choix</strong>
</div>
<div className={s.panelRow}>
<span>Mode encastré:</span>
<strong>fileurs + bandeau couleur face extérieure</strong>
</div>
</div>
{/* Per-element color dots */}
<div className={s.colorDotsTitle}>Couleur par &eacute;l&eacute;ment</div>
<div className={s.colorDots}>
{Object.entries(ELEMENT_LABELS).map(([key, label]) => {
const c = config.colors[key];
return (
<button
key={key}
type="button"
className={s.colorDot}
onClick={() => ui.openColorPicker(key, label)}
title={`${label}: ${c?.label || 'Non d\u00e9fini'}`}
>
<span
className={s.colorDotSwatch}
style={{ background: c?.hexColor || '#ccc' }}
/>
{label}
</button>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,29 @@
import { STEPS } from '../../constants/wardrobe';
import styles from './StepperBar.module.css';
export default function StepperBar({ activeStep, onStepChange, totalChf, touched }) {
return (
<div className={styles.stepperBar}>
{STEPS.map(({ id, label }) => (
<button
key={id}
type="button"
className={`${styles.step} ${activeStep === id ? styles.stepActive : ''}`}
onClick={() => onStepChange(id)}
>
<span className={styles.stepNum}>
{touched?.has(id) && activeStep !== id ? (
<span className={styles.stepCheck}>&#10003;</span>
) : id}
</span>
<span className={styles.stepLabel}>{label}</span>
</button>
))}
<span className={styles.spacer} />
<div className={styles.priceBadge}>
<span className={styles.priceLabel}>CHF</span>
{(totalChf || 0).toLocaleString('fr-CH', { minimumFractionDigits: 0 })}.&ndash;
</div>
</div>
);
}
@@ -0,0 +1,96 @@
.stepperBar {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 1rem;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
position: -webkit-sticky;
position: sticky;
top: 0;
z-index: 60;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.stepperBar::-webkit-scrollbar { display: none; }
.step {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.85rem;
border: none;
background: transparent;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
flex-shrink: 0;
}
.step:hover { background: var(--bg); color: var(--text); }
.stepActive {
background: var(--action);
color: #fff;
}
.stepActive:hover {
background: var(--action);
color: #fff;
}
.stepNum {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 700;
background: var(--border);
color: var(--text-muted);
flex-shrink: 0;
}
.stepActive .stepNum {
background: rgba(255,255,255,0.25);
color: #fff;
}
.stepCheck {
color: var(--success, #22c55e);
font-size: 0.75rem;
}
.spacer { flex: 1; min-width: 0.5rem; }
.priceBadge {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.85rem;
background: var(--primary);
color: #fff;
border-radius: 6px;
font-size: 0.9375rem;
font-weight: 700;
white-space: nowrap;
flex-shrink: 0;
letter-spacing: -0.01em;
}
.priceLabel {
font-weight: 400;
font-size: 0.75rem;
opacity: 0.8;
}
@media (max-width: 700px) {
.stepperBar { padding: 0.4rem 0.5rem; gap: 0.15rem; }
.step { padding: 0.4rem 0.6rem; font-size: 0.75rem; }
.stepLabel { display: none; }
.priceBadge { font-size: 0.8125rem; padding: 0.35rem 0.65rem; }
}
@@ -0,0 +1,987 @@
/* Shared step styles */
.stepSection {
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.stepTitle {
font-size: 1.1rem;
font-weight: 700;
color: var(--primary);
margin: 0 0 1rem;
}
.stepDesc {
font-size: 0.8125rem;
color: var(--text-muted);
margin: -0.5rem 0 1rem;
}
.stepDescExtra {
display: block;
margin-top: 0.4rem;
font-size: 0.78rem;
line-height: 1.35;
}
/* Card toggle (installation type, door type) */
.cardToggle {
display: flex;
gap: 0.8rem;
margin-bottom: 1.25rem;
}
.cardToggleItem {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.15rem;
min-height: 5.85rem;
padding: 0.9rem 1rem;
border: 2px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
cursor: pointer;
text-align: center;
transition: all 0.15s;
}
.cardToggleItem:hover { border-color: var(--text-muted); }
.cardToggleActive {
border-color: var(--action);
background: color-mix(in srgb, var(--action) 6%, var(--bg-card));
}
.cardToggleIcon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
line-height: 1;
}
.cardToggleIconSvg {
width: 2rem;
height: 2rem;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.cardToggleLabel {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
line-height: 1.25;
}
.cardToggleHint {
display: block;
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.12rem;
line-height: 1.3;
}
.cardToggleActive .cardToggleIcon {
color: var(--action);
}
/* Dimension sliders */
.dimGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.dimField {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.dimLabel {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
}
.dimSliderRow {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dimSlider {
flex: 1;
accent-color: var(--action);
height: 6px;
}
.dimValue {
min-width: 4.5rem;
padding: 0.35rem 0.5rem;
border: 1px solid var(--border);
border-radius: 5px;
font-size: 0.8125rem;
text-align: right;
font-variant-numeric: tabular-nums;
}
.dimUnit {
font-size: 0.75rem;
color: var(--text-muted);
}
/* Column count row */
.colCountRow {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.colCountLabel {
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
}
.colCountBtns {
display: flex;
gap: 0.25rem;
align-items: center;
}
.colCountBtn {
min-width: 2rem;
height: 2rem;
padding: 0 0.55rem;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
font-weight: 600;
font-size: 0.875rem;
white-space: nowrap;
cursor: pointer;
transition: all 0.15s;
}
.colCountBtn:hover { border-color: var(--action); }
.colCountActive {
background: var(--action);
border-color: var(--action);
color: #fff;
}
.optimizeBtn {
padding: 0.4rem 0.75rem;
border: 1px solid var(--action);
border-radius: 6px;
background: transparent;
color: var(--action);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
margin-left: 0.35rem;
transition: all 0.15s;
}
.optimizeBtn:hover { background: var(--action); color: #fff; }
.optimizeBtn:disabled { opacity: 0.5; cursor: default; }
.aiMsg {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
background: color-mix(in srgb, var(--action) 8%, var(--bg));
border-radius: 6px;
font-size: 0.8125rem;
color: var(--text);
margin-bottom: 0.75rem;
}
.aiBadge {
display: inline-block;
background: var(--action);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
padding: 0.15rem 0.35rem;
border-radius: 3px;
flex-shrink: 0;
margin-top: 0.1rem;
}
.aiDismiss {
margin-left: auto;
background: none;
border: none;
font-size: 1rem;
color: var(--text-muted);
cursor: pointer;
padding: 0;
line-height: 1;
}
/* Column strip (horizontal cards mirroring 3D) */
.columnStripLegend {
font-size: 0.72rem;
color: var(--text-muted);
margin: 0 0 0.45rem;
line-height: 1.35;
}
.columnStrip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(4.85rem, 1fr));
gap: 0.4rem;
margin-bottom: 0.75rem;
width: 100%;
min-width: 0;
}
/* Many columns: allow horizontal scroll instead of squashing */
@media (max-width: 520px) {
.columnStrip {
display: flex;
overflow-x: auto;
padding-bottom: 0.35rem;
-webkit-overflow-scrolling: touch;
}
.colCard {
flex: 0 0 auto;
min-width: 5.25rem;
}
}
.colCard {
flex-shrink: 0;
min-width: 0;
width: 100%;
padding: 0.45rem 0.45rem 0.5rem;
border: 2px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
cursor: grab;
text-align: center;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s, opacity 0.15s;
position: relative;
}
.colCard:active { cursor: grabbing; }
.colCard:hover { border-color: var(--text-muted); }
.colCardActive { border-color: var(--action); background: color-mix(in srgb, var(--action) 6%, var(--bg-card)); }
.colCardLocked { opacity: 0.7; }
.colCardDragging {
opacity: 0.55;
cursor: grabbing;
}
.colCardDropTarget {
border-color: var(--action);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--action) 35%, transparent);
background: color-mix(in srgb, var(--action) 10%, var(--bg-card));
}
.colCardIndex {
display: block;
font-size: 0.65rem;
font-weight: 800;
letter-spacing: 0.02em;
color: var(--action);
line-height: 1.15;
margin-bottom: 0.2rem;
user-select: none;
}
.colDragHint {
display: block;
font-size: 0.55rem;
font-weight: 800;
letter-spacing: 0.06em;
color: var(--text-muted);
line-height: 1;
margin-bottom: 0.15rem;
opacity: 0.75;
user-select: none;
}
.colCardIcon { font-size: 1.2rem; display: block; }
.colCardType { font-size: 0.6875rem; font-weight: 600; color: var(--text); margin-top: 0.15rem; }
.colCardWidth { font-size: 0.625rem; color: var(--text-muted); }
.colLockIcon {
position: absolute;
top: 0.2rem;
right: 0.25rem;
font-size: 0.6rem;
}
/* Column detail tray (expanded below strip) */
.colDetail {
padding: 1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
margin-bottom: 0.75rem;
animation: fadeIn 0.15s ease-out;
}
.colDetailHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.colDetailTitle {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
}
.lockBtn {
background: none;
border: 1px solid var(--border);
border-radius: 5px;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
cursor: pointer;
color: var(--text-muted);
}
.lockBtn:hover { border-color: var(--text); }
.isLocked { background: var(--bg); }
/* Type selector tiles */
.typeTiles {
display: flex;
gap: 0.35rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.typeTile {
padding: 0.4rem 0.7rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
cursor: pointer;
font-size: 0.75rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.3rem;
transition: all 0.15s;
}
.typeTile:hover { border-color: var(--action); }
.typeTileActive {
border-color: var(--action);
background: color-mix(in srgb, var(--action) 10%, var(--bg));
color: var(--action);
}
/* Counter rows */
.detailRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 0;
}
.detailLabel {
font-size: 0.8125rem;
color: var(--text);
}
.counter {
display: flex;
align-items: center;
gap: 0.35rem;
}
.counterBtn {
width: 1.6rem;
height: 1.6rem;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-card);
cursor: pointer;
font-size: 0.875rem;
color: var(--text);
transition: all 0.1s;
}
.counterBtn:hover:not(:disabled) { border-color: var(--action); }
.counterBtn:disabled { opacity: 0.3; cursor: default; }
.counterValue {
min-width: 1.5rem;
text-align: center;
font-weight: 600;
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
}
.gapModeRow {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.gapAutoLabel {
font-size: 0.8125rem;
color: var(--text-muted);
}
.gapCustomizeBtn {
padding: 0.25rem 0.55rem;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-card);
cursor: pointer;
font-size: 0.75rem;
color: var(--action);
transition: border-color 0.15s, background 0.15s;
}
.gapCustomizeBtn:hover:not(:disabled) {
border-color: var(--action);
background: color-mix(in srgb, var(--action) 8%, var(--bg-card));
}
.gapCustomizeBtn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.gapRangeRow {
padding: 0 0 0.35rem;
margin-top: -0.15rem;
}
.gapRange {
width: 100%;
accent-color: var(--action);
}
/* Toggle button */
.toggleBtn {
padding: 0.3rem 0.7rem;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg);
cursor: pointer;
font-size: 0.8125rem;
color: var(--text-muted);
transition: all 0.15s;
}
.toggleOn {
background: var(--action);
border-color: var(--action);
color: #fff;
}
/* Summary line */
.summaryLine {
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
.hint {
font-size: 0.75rem;
color: var(--text-muted);
opacity: 0.7;
margin-top: 0.25rem;
}
/* Hardware grid */
.hardwareGroup {
margin-bottom: 1rem;
}
/** Extra air above habillage row (after dimension sliders). */
.finishHabillageGroup {
padding-top: 0.85rem;
margin-top: 0.35rem;
}
.hardwareLabel {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
display: block;
margin-bottom: 0.5rem;
}
.hardwareGrid {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.hardwareBtn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.6rem 0.75rem;
border: 2px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
cursor: pointer;
min-width: 5.5rem;
transition: all 0.15s;
}
.hardwareBtn:hover { border-color: var(--text-muted); }
.hardwareBtnSelected {
border-color: var(--action);
background: color-mix(in srgb, var(--action) 6%, var(--bg-card));
}
.hardwareBtn img {
width: 3rem;
height: 3rem;
object-fit: contain;
border-radius: 4px;
}
.hardwareIcon { font-size: 1.5rem; }
.hardwareName { font-size: 0.6875rem; font-weight: 600; color: var(--text); text-align: center; }
.hardwareType { font-size: 0.625rem; color: var(--text-muted); }
.hardwarePrice { font-size: 0.6875rem; color: var(--action); font-weight: 500; }
.skipBtn {
padding: 0.4rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text-muted);
font-size: 0.8125rem;
cursor: pointer;
margin-bottom: 1rem;
}
.skipBtn:hover { color: var(--text); border-color: var(--text-muted); }
/* Material quick pick */
.quickPick {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.35rem;
margin-bottom: 1rem;
-webkit-overflow-scrolling: touch;
}
.quickSwatch {
flex-shrink: 0;
width: 4.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
cursor: pointer;
border: 2px solid transparent;
border-radius: 8px;
padding: 0.35rem;
transition: all 0.15s;
}
.quickSwatch:hover { border-color: var(--text-muted); }
.quickSwatchActive { border-color: var(--action); }
.quickSwatchImg {
width: 3.5rem;
height: 3.5rem;
border-radius: 6px;
object-fit: cover;
background: var(--bg);
}
.quickSwatchNoImg {
width: 3.5rem;
height: 3.5rem;
border-radius: 6px;
background: var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
color: var(--text-muted);
}
.quickSwatchLabel {
font-size: 0.5625rem;
color: var(--text);
text-align: center;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.browseBtn {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
margin-bottom: 1rem;
}
.browseBtn:hover { border-color: var(--action); color: var(--action); }
.currentMaterial {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 0.75rem;
background: var(--bg-card);
}
.currentMaterialImg {
width: 3rem;
height: 3rem;
border-radius: 6px;
object-fit: cover;
}
.currentMaterialInfo {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.currentMaterialInfo strong { font-size: 0.875rem; }
.currentMaterialInfo span { font-size: 0.75rem; color: var(--text-muted); }
/* Element color dots */
.colorDotsTitle {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.5rem;
}
.colorDots {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.colorDot {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.55rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
cursor: pointer;
font-size: 0.6875rem;
color: var(--text);
transition: all 0.15s;
}
.colorDot:hover { border-color: var(--action); }
.colorDotSwatch {
width: 1rem;
height: 1rem;
border-radius: 3px;
border: 1px solid rgba(0,0,0,0.1);
flex-shrink: 0;
}
/* Finalize: summary card */
.summaryCard {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
padding: 1rem;
margin-bottom: 1rem;
}
.summaryRow {
display: flex;
justify-content: space-between;
padding: 0.35rem 0;
font-size: 0.8125rem;
border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
}
.summaryRow:last-child { border-bottom: none; }
.summaryLabel { color: var(--text-muted); }
.summaryValue { color: var(--text); font-weight: 500; }
.summaryEdit {
font-size: 0.6875rem;
color: var(--action);
cursor: pointer;
background: none;
border: none;
padding: 0;
margin-left: 0.5rem;
}
.summaryEdit:hover { text-decoration: underline; }
/* Price breakdown */
.priceBreakdown {
margin-bottom: 1rem;
}
.priceRow {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
font-size: 0.8125rem;
}
.priceTotal {
display: flex;
justify-content: space-between;
padding: 0.6rem 0;
font-size: 1.1rem;
font-weight: 700;
border-top: 2px solid var(--primary);
margin-top: 0.35rem;
}
/* Action buttons */
.actionBtns {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.actionBtn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.85rem;
border: 1px solid var(--action);
border-radius: 9px;
background: var(--bg-card);
color: var(--action);
font-size: 0.8rem;
font-weight: 600;
transition: all 0.15s ease;
}
.actionBtn:hover {
box-shadow: 0 8px 18px rgba(79, 70, 229, 0.18);
transform: translateY(-1px);
}
/* Order form */
.orderForm {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.orderForm input,
.orderForm textarea {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.875rem;
}
.orderForm textarea { resize: vertical; }
.submitBtn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.7rem 1.25rem;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, var(--action), var(--action-hover));
color: #fff;
font-size: 0.95rem;
font-weight: 600;
transition: all 0.15s ease;
margin-top: 0.25rem;
}
.submitBtn:hover {
box-shadow: 0 10px 22px rgba(79, 70, 229, 0.3);
transform: translateY(-1px);
}
.success {
padding: 1rem;
background: color-mix(in srgb, var(--success, #22c55e) 10%, var(--bg-card));
border-radius: 8px;
color: var(--text);
font-size: 0.875rem;
text-align: center;
}
.errors {
list-style: none;
padding: 0;
margin: 0 0 0.75rem;
}
.errors li {
padding: 0.35rem 0.6rem;
background: color-mix(in srgb, #ef4444 10%, var(--bg-card));
border-radius: 5px;
font-size: 0.8125rem;
color: #ef4444;
margin-bottom: 0.25rem;
}
/* Checkboxes for service options */
.checkLabel {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
}
/* Panel optimization info */
.panelInfo {
padding: 0.75rem;
background: var(--bg);
border-radius: 6px;
margin-bottom: 0.75rem;
}
.panelInfoTitle {
font-size: 0.8125rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.panelRow {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
padding: 0.15rem 0;
}
.finishSelector {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.45rem;
align-items: stretch;
}
.finishOption {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
min-width: 0;
padding: 0.5rem 0.45rem;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
}
.finishOption:hover {
border-color: var(--action);
transform: translateY(-1px);
}
.finishOptionActive {
border-color: var(--action);
background: color-mix(in srgb, var(--action) 10%, var(--bg-card));
box-shadow: 0 8px 18px color-mix(in srgb, var(--action) 20%, transparent);
}
.finishOptionLabel {
font-size: 0.75rem;
font-weight: 600;
color: var(--text);
line-height: 1.2;
text-align: left;
word-break: break-word;
}
.finishOptionState {
font-size: 0.68rem;
color: var(--text-muted);
}
.ruleGrid3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.warnText {
font-size: 0.8125rem;
color: #b45309;
background: color-mix(in srgb, #f59e0b 10%, var(--bg-card));
border: 1px solid color-mix(in srgb, #f59e0b 30%, var(--border));
border-radius: 6px;
padding: 0.4rem 0.6rem;
margin: 0.4rem 0;
}
.overrideToggle {
display: flex;
align-items: flex-start;
gap: 0.55rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
margin: 0.4rem 0 0.75rem;
cursor: pointer;
}
.overrideToggle input {
margin-top: 0.15rem;
}
.overrideToggle span {
display: flex;
flex-direction: column;
gap: 0.12rem;
font-size: 0.8125rem;
color: var(--text);
}
.overrideToggle small {
color: var(--text-muted);
font-size: 0.75rem;
}
@media (max-width: 600px) {
.dimGrid { grid-template-columns: 1fr; }
.cardToggle { flex-direction: column; }
.columnStrip { gap: 0.25rem; }
.ruleGrid3 { grid-template-columns: 1fr; }
}
/* Compact mode for desktop split-panel (keeps everything visible) */
@media (min-width: 1000px) {
.stepTitle {
font-size: 1rem;
margin-bottom: 0.65rem;
}
.stepDesc {
margin: -0.35rem 0 0.65rem;
font-size: 0.78rem;
}
.cardToggle {
gap: 0.5rem;
margin-bottom: 0.85rem;
}
.cardToggleItem {
min-height: 4.6rem;
padding: 0.65rem 0.75rem;
border-radius: 9px;
}
.cardToggleLabel {
font-size: 0.82rem;
}
.cardToggleHint {
font-size: 0.7rem;
}
.dimGrid {
gap: 0.75rem;
}
/* Prevent right-column slider rows from being clipped in narrow 1/3 panel */
.dimSliderRow {
display: grid;
grid-template-columns: minmax(0, 1fr) 4.1rem auto;
gap: 0.45rem;
align-items: center;
}
.dimSlider {
min-width: 0;
}
.dimLabel {
font-size: 0.78rem;
}
.dimValue {
min-width: 4.1rem;
padding: 0.3rem 0.45rem;
font-size: 0.78rem;
}
.colCountRow {
margin-bottom: 0.6rem;
gap: 0.6rem;
}
.aiMsg {
padding: 0.5rem 0.65rem;
margin-bottom: 0.6rem;
}
}
+89
View File
@@ -0,0 +1,89 @@
export default function Icon({ name, size = 16, className = '' }) {
const props = {
width: size,
height: size,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
strokeLinecap: 'round',
strokeLinejoin: 'round',
'aria-hidden': 'true',
className
};
switch (name) {
case 'logout':
return (
<svg {...props}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</svg>
);
case 'sync':
return (
<svg {...props}>
<path d="M3 12a9 9 0 0 1 15.5-6.4L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-15.5 6.4L3 16" />
<path d="M3 21v-5h5" />
</svg>
);
case 'eye':
return (
<svg {...props}>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
case 'download':
return (
<svg {...props}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="m7 10 5 5 5-5" />
<path d="M12 15V3" />
</svg>
);
case 'trash':
return (
<svg {...props}>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
);
case 'file':
return (
<svg {...props}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
</svg>
);
case 'blueprint':
return (
<svg {...props}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
</svg>
);
case 'sparkles':
return (
<svg {...props}>
<path d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8Z" />
<path d="M5 3v3M3.5 4.5h3M19 16v5M16.5 18.5h5" />
</svg>
);
case 'x':
return (
<svg {...props}>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
default:
return null;
}
}
+228
View File
@@ -0,0 +1,228 @@
import { PANEL } from '@shared/manufacturing.js';
/** Inner carcass height (mm): floor to ceiling inside side panels. */
export function innerCarcassHeightMm(heightMm, plinthHeightMm) {
return heightMm - plinthHeightMm - 2 * PANEL.structure;
}
/** Max clear vertical gap (mm) between shelf faces for n shelves; n >= 2. */
export function maxShelfGapMm(heightMm, plinthHeightMm, shelfCount) {
const H = innerCarcassHeightMm(heightMm, plinthHeightMm);
const n = shelfCount;
if (n < 2) return 0;
const t = PANEL.structure;
if (H <= n * t) return 0;
return Math.floor((H - n * t) / (n - 1));
}
export const SHELF_GAP_MM = { min: 80, max: 520, step: 10 };
export const SYSTEM32_PITCH_MM = 32;
export const SYSTEM32_FIRST_HOLE_MM = 51;
/** Above this carcass height (mm), at least one shelf per column is required (rigidité / RAZENTZAB). */
export const STRUCTURAL_MIN_SHELF_HEIGHT_MM = 2000;
/** Minimum shelf count for a column at this height (0 or 1). */
export function minShelvesForStructuralHeight(heightMm) {
return (Number(heightMm) || 0) > STRUCTURAL_MIN_SHELF_HEIGHT_MM ? 1 : 0;
}
/**
* Shelf center Y (meters), wardrobe group space — matches WardrobePreview formulas.
* @param {number|null|undefined} shelfGapMm — clear opening between shelf faces; null = equal division (default).
*/
export function shelfCenterYsMeters({
shelfCount,
heightM,
plinthHM,
shelfGapMm,
shelfPositionsMm,
}) {
const T = PANEL.structure / 1000;
const bodyH = heightM - plinthHM;
const H_inner = bodyH - 2 * T;
const yBase = -heightM / 2 + plinthHM + T;
if (shelfCount <= 0) return [];
if (Array.isArray(shelfPositionsMm) && shelfPositionsMm.length === shelfCount) {
return shelfPositionsMm.map((posMm) => -heightM / 2 + plinthHM + Number(posMm) / 1000);
}
const equal = () =>
Array.from({ length: shelfCount }, (_, i) =>
yBase + (H_inner / (shelfCount + 1)) * (i + 1)
);
if (shelfCount === 1 || shelfGapMm == null || !Number.isFinite(Number(shelfGapMm))) {
return equal();
}
const n = shelfCount;
const gReq = Number(shelfGapMm) / 1000;
const maxG = n > 1 ? (H_inner - n * T) / (n - 1) : 0;
if (n > 1 && !(maxG > 0)) {
return equal();
}
const gUse = n > 1 ? Math.max(0, Math.min(gReq, maxG)) : 0;
const needed = n * T + (n > 1 ? (n - 1) * gUse : 0);
const m = (H_inner - needed) / 2;
return Array.from({ length: n }, (_, i) =>
yBase + m + T / 2 + i * (T + (n > 1 ? gUse : 0))
);
}
export function system32ShelfPositionsMm(heightMm, plinthHeightMm) {
const bodyH = (Number(heightMm) || 0) - (Number(plinthHeightMm) || 0);
if (!(bodyH > 0)) return [];
const minPos = SYSTEM32_FIRST_HOLE_MM;
const maxPos = bodyH - SYSTEM32_FIRST_HOLE_MM;
if (maxPos < minPos) return [];
const count = Math.floor((maxPos - minPos) / SYSTEM32_PITCH_MM) + 1;
return Array.from({ length: Math.max(0, count) }, (_, i) => minPos + i * SYSTEM32_PITCH_MM);
}
export function resolveShelfPositionsMm({
shelfCount,
heightMm,
plinthHeightMm,
shelfPositionsMm
}) {
const n = Math.max(0, Number(shelfCount) || 0);
if (n <= 0) return [];
const slots = system32ShelfPositionsMm(heightMm, plinthHeightMm);
if (slots.length <= 0) return [];
if (n >= slots.length) return slots.slice(0, n);
const toNearestIndex = (valueMm) => {
const v = Number(valueMm);
if (!Number.isFinite(v)) return null;
let bestIdx = 0;
let bestDist = Infinity;
for (let i = 0; i < slots.length; i++) {
const d = Math.abs(slots[i] - v);
if (d < bestDist) {
bestDist = d;
bestIdx = i;
}
}
return bestIdx;
};
let indexes = [];
if (Array.isArray(shelfPositionsMm) && shelfPositionsMm.length === n) {
indexes = shelfPositionsMm.map(toNearestIndex);
} else {
indexes = Array.from({ length: n }, (_, i) => {
const t = ((i + 1) * (slots.length + 1)) / (n + 1);
return Math.max(0, Math.min(slots.length - 1, Math.round(t) - 1));
});
}
for (let i = 0; i < n; i++) {
const minIdx = i;
const idx = Number.isFinite(indexes[i]) ? indexes[i] : minIdx;
indexes[i] = Math.max(minIdx, Math.min(slots.length - (n - i), idx));
}
for (let i = n - 2; i >= 0; i--) {
indexes[i] = Math.min(indexes[i], indexes[i + 1] - 1);
}
return indexes.map((idx) => slots[idx]);
}
// Column element types -- single source of truth
export const COLUMN_TYPES = [
{ key: 'penderie', label: 'Penderie', icon: '\u{1F454}', shelves: 1, drawers: 0, hasRod: true, defaultWidth: 600, minWidth: 400, maxWidth: 1200 },
{ key: 'tiroirs', label: 'Tiroirs', icon: '\u{1F5C4}\uFE0F', shelves: 0, drawers: 3, hasRod: false, defaultWidth: 500, minWidth: 400, maxWidth: 800 },
{ key: 'rayonnage', label: 'Rayonnage', icon: '\u{1F4DA}', shelves: 4, drawers: 0, hasRod: false, defaultWidth: 500, minWidth: 400, maxWidth: 900 },
{ key: 'bloc_carre', label: 'Bloc carr\u00e9', icon: '\u2B1C', shelves: 0, drawers: 0, hasRod: false, defaultWidth: 400, minWidth: 300, maxWidth: 600 },
{ key: 'mixte', label: 'Mixte', icon: '\u{1F4E6}', shelves: 2, drawers: 2, hasRod: true, defaultWidth: 600, minWidth: 400, maxWidth: 1000 },
];
// Default cycling types when creating new columns
export const DEFAULT_CYCLE = ['penderie', 'rayonnage', 'tiroirs'];
// Default colors for each element type — must include ALL element types rendered in 3D
export const DEFAULT_COLORS = {
plintheBas: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#8b7355' },
fileur: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#8b7355' },
bandeauHaut: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#8b7355' },
champs: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#6b5344' },
caisse: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
cotesDoublage: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
habillageFinitionGauche: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
habillageFinitionDroite: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
habillageFinitionDessus: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
cotes: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#b09060' },
dessus: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
fond: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#d4c4a4' },
dos: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#d4c4a4' },
portes: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#a08060' },
panneauxInterieurs: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#d4c4a4' },
separateurs: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#6b5344' },
etageres: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#d4c4a4' },
rayonFixe: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#d4c4a4' },
renfortsTiroir: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
tiroirs: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
penderie: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#8b7355' }
};
export const DEFAULT_CONFIG = {
roomType: 'other', // other | bathroom | laundry | technical
allowMaterialOverride: false,
installationType: 'free',
widthMm: 2400,
heightMm: 2400,
depthMm: 600,
plinthHeightMm: 100,
exteriorFinishesEnabled: true,
sideFinishesEnabled: true,
sideFinishLeftEnabled: true,
sideFinishRightEnabled: true,
topFinishEnabled: true,
nicheMeasurements: {
widthTopMm: 2400,
widthMidMm: 2400,
widthBottomMm: 2400,
depthTopMm: 600,
depthMidMm: 600,
depthBottomMm: 600,
heightLeftMm: 2400,
heightRightMm: 2400
},
sanitaryGapMm: 30,
modules: [],
columnMode: 'separations', // separations | block
colors: { ...DEFAULT_COLORS },
hardware: {
handleVariantId: null,
hingeVariantId: null,
drawerSystemId: null,
/**
* Optional per-door hinge side override.
* When present and length matches door count, each entry is 'left'|'right' for door i.
*/
doorHingeSides: null,
doorHingeSide: 'left', // left | right
centerDoorHingeSide: 'left', // left | right
handleBoreHeightMm: 1000,
handleType: 'standard', // standard | full_height
drawerType: 'visible', // visible | hidden
drawerHeightsMm: [90, 128, 177, 240]
},
doorType: 'hinged',
slidingPanelsCount: 2,
finishId: null,
dimensionId: null,
serviceOptionIds: []
};
export const STEPS = [
{ id: 1, key: 'espace', label: 'Espace' },
{ id: 2, key: 'colonnes', label: 'Colonnes' },
{ id: 3, key: 'portes', label: 'Portes' },
{ id: 4, key: 'materiaux', label: 'Mat\u00e9riaux' },
{ id: 5, key: 'quincaillerie', label: 'Quincaillerie' },
{ id: 6, key: 'finaliser', label: 'Finaliser' },
];
+126
View File
@@ -0,0 +1,126 @@
import { useState, useEffect, useCallback } from 'react';
const SESSION_STEP_KEY = 'apex_wardrobe_step';
const PLANS_OPEN_KEY = 'apex.plans.open';
function readPlansOpenFlag() {
try {
if (localStorage.getItem(PLANS_OPEN_KEY) === '1') return true;
if (sessionStorage.getItem(PLANS_OPEN_KEY) === '1') {
localStorage.setItem(PLANS_OPEN_KEY, '1');
sessionStorage.removeItem(PLANS_OPEN_KEY);
return true;
}
} catch {
// ignore
}
return false;
}
export default function useConfiguratorUI() {
const [activeStep, setActiveStep] = useState(() => {
try { return Number(sessionStorage.getItem(SESSION_STEP_KEY)) || 1; } catch { return 1; }
});
const [expandedColumn, setExpandedColumn] = useState(null);
useEffect(() => {
try { sessionStorage.setItem(SESSION_STEP_KEY, String(activeStep)); } catch {}
}, [activeStep]);
// Modal visibility
const [showMaterialPicker, setShowMaterialPicker] = useState(false);
const [showMaterialModal, setShowMaterialModal] = useState(false);
const [showColorPicker, setShowColorPicker] = useState(false);
const [showRoomUpload, setShowRoomUpload] = useState(false);
const [showPlansView, setShowPlansView] = useState(readPlansOpenFlag);
useEffect(() => {
try {
if (showPlansView) {
localStorage.setItem(PLANS_OPEN_KEY, '1');
sessionStorage.removeItem(PLANS_OPEN_KEY);
} else {
localStorage.removeItem(PLANS_OPEN_KEY);
sessionStorage.removeItem(PLANS_OPEN_KEY);
}
} catch {
// ignore
}
}, [showPlansView]);
const [showAIRender, setShowAIRender] = useState(false);
// Material picker state
const [selectedMaterial, setSelectedMaterial] = useState(null);
const [materialFilter, setMaterialFilter] = useState('');
const [selectedCollection, setSelectedCollection] = useState('all');
// Color picker state
const [colorPickerElement, setColorPickerElement] = useState(null);
// Room visualization
const [roomConfig, setRoomConfig] = useState(null);
const [roomAnalysis, setRoomAnalysis] = useState(null);
const [roomViewActive, setRoomViewActive] = useState(false);
// Order form
const [orderForm, setOrderForm] = useState({ email: '', name: '', phone: '', notes: '' });
const [orderSent, setOrderSent] = useState(false);
const [lastOrderId, setLastOrderId] = useState(null);
// Module click from 3D view
const handleModuleClick = useCallback((moduleIndex) => {
setExpandedColumn(prev => prev === moduleIndex ? null : moduleIndex);
setActiveStep(2); // jump to columns step when clicking a module
}, []);
// Element click from 3D view
const openColorPicker = useCallback((elementType, label) => {
setColorPickerElement({ type: elementType, label });
setShowColorPicker(true);
setActiveStep(4); // jump to materials step
}, []);
const closeColorPicker = useCallback(() => {
setShowColorPicker(false);
setColorPickerElement(null);
}, []);
// Material handlers
const openMaterialDetail = useCallback((material) => {
setSelectedMaterial(material);
setShowMaterialModal(true);
}, []);
const handleVariantSwitch = useCallback((variant, finishes) => {
const variantMaterial = finishes.find(f => f.code === variant.variant_code);
if (variantMaterial) {
setSelectedMaterial(variantMaterial);
}
}, []);
return {
activeStep, setActiveStep,
expandedColumn, setExpandedColumn,
showMaterialPicker, setShowMaterialPicker,
showMaterialModal, setShowMaterialModal,
showColorPicker, setShowColorPicker,
showRoomUpload, setShowRoomUpload,
showPlansView, setShowPlansView,
showAIRender, setShowAIRender,
selectedMaterial, setSelectedMaterial,
materialFilter, setMaterialFilter,
selectedCollection, setSelectedCollection,
colorPickerElement, setColorPickerElement,
roomConfig, setRoomConfig,
roomAnalysis, setRoomAnalysis,
roomViewActive, setRoomViewActive,
orderForm, setOrderForm,
orderSent, setOrderSent,
lastOrderId, setLastOrderId,
handleModuleClick,
openColorPicker,
closeColorPicker,
openMaterialDetail,
handleVariantSwitch,
};
}
+676
View File
@@ -0,0 +1,676 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { fetchFinishes, fetchServiceOptions, calculatePrice } from '../api/client';
import {
COLUMN_TYPES, DEFAULT_CYCLE, DEFAULT_COLORS, DEFAULT_CONFIG, maxShelfGapMm, SHELF_GAP_MM,
minShelvesForStructuralHeight, resolveShelfPositionsMm
} from '../constants/wardrobe';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const SESSION_KEY = 'apex_wardrobe_config';
const SESSION_COLS_KEY = 'apex_wardrobe_numcols';
const MAX_CONFIG_UNDO = 50;
function loadSavedConfig() {
try {
const raw = sessionStorage.getItem(SESSION_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export default function useWardrobeConfig() {
const saved = loadSavedConfig();
const [config, setConfig] = useState(saved || DEFAULT_CONFIG);
const configRef = useRef(config);
useEffect(() => {
configRef.current = config;
}, [config]);
const undoPastRef = useRef([]);
const undoFutureRef = useRef([]);
const [undoRevision, setUndoRevision] = useState(0);
const pushUndoSnapshot = useCallback(() => {
const snap = JSON.parse(JSON.stringify(configRef.current));
undoPastRef.current.push(snap);
if (undoPastRef.current.length > MAX_CONFIG_UNDO) undoPastRef.current.shift();
undoFutureRef.current = [];
setUndoRevision((n) => n + 1);
}, []);
const undo = useCallback(() => {
if (undoPastRef.current.length === 0) return;
const prev = undoPastRef.current.pop();
undoFutureRef.current.push(JSON.parse(JSON.stringify(configRef.current)));
setConfig(prev);
setUndoRevision((n) => n + 1);
}, []);
const redo = useCallback(() => {
if (undoFutureRef.current.length === 0) return;
const next = undoFutureRef.current.pop();
undoPastRef.current.push(JSON.parse(JSON.stringify(configRef.current)));
setConfig(next);
setUndoRevision((n) => n + 1);
}, []);
const [finishes, setFinishes] = useState([]);
const [serviceOptions, setServiceOptions] = useState([]);
const [priceResult, setPriceResult] = useState(null);
const [loading, setLoading] = useState(true);
const [numColumns, setNumColumns] = useState(() => {
try { return Number(sessionStorage.getItem(SESSION_COLS_KEY)) || 3; } catch { return 3; }
});
// Hardware catalogs
const [handleVariants, setHandleVariants] = useState([]);
const [hingeVariants, setHingeVariants] = useState([]);
const [blumHardware, setBlumHardware] = useState([]);
// AI optimize state
const [aiOptimizing, setAiOptimizing] = useState(false);
const [aiOptimizeMsg, setAiOptimizeMsg] = useState('');
// Persist config to sessionStorage on every change
useEffect(() => {
try { sessionStorage.setItem(SESSION_KEY, JSON.stringify(config)); } catch {}
}, [config]);
// Keep extended configuration schema compatible with older saved sessions.
useEffect(() => {
setConfig((c) => ({
...DEFAULT_CONFIG,
...c,
nicheMeasurements: {
...DEFAULT_CONFIG.nicheMeasurements,
...(c.nicheMeasurements || {})
},
hardware: {
...DEFAULT_CONFIG.hardware,
...(c.hardware || {})
}
}));
// Run once at startup for legacy session payloads.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
try { sessionStorage.setItem(SESSION_COLS_KEY, String(numColumns)); } catch {}
}, [numColumns]);
// Clamp per-module shelf gap (mm) when height or shelf count changes.
// Enforce minimum shelf count when height requires a structural middle shelf.
useEffect(() => {
setConfig((c) => {
const h = c.heightMm;
const pl = c.plinthHeightMm ?? 100;
const minS = minShelvesForStructuralHeight(h);
const list = c.modules || [];
let touched = false;
const modules = list.map((mod) => {
const nRaw = mod.shelves || 0;
const n = Math.max(minS, nRaw);
let nextMod = mod;
if (n !== nRaw) {
touched = true;
nextMod = { ...nextMod, shelves: n };
}
const resolvedShelfPositions = resolveShelfPositionsMm({
shelfCount: n,
heightMm: h,
plinthHeightMm: pl,
shelfPositionsMm: nextMod.shelfPositionsMm
});
const nextShelfPositions = n > 0 ? resolvedShelfPositions : [];
const prevShelfPositions = Array.isArray(nextMod.shelfPositionsMm) ? nextMod.shelfPositionsMm : [];
const shelfPosChanged =
nextShelfPositions.length !== prevShelfPositions.length ||
nextShelfPositions.some((v, idx) => v !== prevShelfPositions[idx]);
if (shelfPosChanged) {
touched = true;
nextMod = { ...nextMod, shelfPositionsMm: nextShelfPositions };
}
if (n < 2) {
if (nextMod.shelfGapMm != null) {
touched = true;
return { ...nextMod, shelfGapMm: null };
}
return nextMod;
}
const capMax = maxShelfGapMm(h, pl, n);
if (nextMod.shelfGapMm != null && capMax < SHELF_GAP_MM.min) {
touched = true;
return { ...nextMod, shelfGapMm: null };
}
if (nextMod.shelfGapMm == null || !Number.isFinite(Number(nextMod.shelfGapMm))) return nextMod;
const minG = SHELF_GAP_MM.min;
const raw = Math.round(Number(nextMod.shelfGapMm) / SHELF_GAP_MM.step) * SHELF_GAP_MM.step;
const g = Math.min(capMax, Math.max(minG, raw));
if (g !== Number(nextMod.shelfGapMm)) {
touched = true;
return { ...nextMod, shelfGapMm: g };
}
return nextMod;
});
if (!touched) return c;
return { ...c, modules };
});
}, [config.modules, config.heightMm, config.plinthHeightMm]);
// Enforce UI constraints directly in state so downstream services always receive valid values.
useEffect(() => {
setConfig((c) => {
let next = c;
const sanitizeStep = (value, min, max, step) => {
const n = Number(value);
if (!Number.isFinite(n)) return min;
const clamped = Math.max(min, Math.min(max, n));
return Math.round(clamped / step) * step;
};
// Sliding doors are currently disabled in UI; keep old configs stable.
if (next.doorType === 'sliding') {
next = { ...next, doorType: 'hinged', slidingPanelsCount: 2 };
}
const modules = Array.isArray(next.modules) ? [...next.modules] : [];
const rawDrawerType = next.hardware?.drawerType || 'visible';
const drawerType =
rawDrawerType === 'hidden' || rawDrawerType === 'hidden_with_handle' ? 'hidden' : 'visible';
const handleType = next.hardware?.handleType || 'standard';
// Drawers constraints: from bottom, depth 400..650 by 50, and drawer columns max 1000 mm.
const constrainedModules = modules.map((mod) => {
const hasDrawers = (mod.drawers || 0) > 0 || mod.type === 'tiroirs';
if (!hasDrawers) return mod;
const nextMaxWidth = Math.min(1000, mod.maxWidth || 1200);
// Keep same reference when nothing changes to avoid render loops/flicker.
if (mod.maxWidth === nextMaxWidth) return mod;
return {
...mod,
maxWidth: nextMaxWidth
};
});
const modulesChanged = constrainedModules.some((m, i) => m !== modules[i]);
const sanitizedHandleBore = sanitizeStep(next.hardware?.handleBoreHeightMm, 600, 1600, 10);
const sanitizedDrawerHeights = (next.hardware?.drawerHeightsMm || [90, 128, 177, 240]).slice(0, 4);
const allowedHeights = [60, 90, 128, 177, 240];
const fixedDrawerHeights = sanitizedDrawerHeights.map((h) => {
return allowedHeights.includes(Number(h)) ? Number(h) : 128;
});
const drawerDepthStepMm = sanitizeStep(next.depthMm - 10, 400, 650, 50);
// Hidden drawers: depth reserve until poignée is modelled separately.
const depthReductionMm = drawerType === 'hidden' ? 40 : 0;
const effectiveDrawerDepthMm = Math.max(350, drawerDepthStepMm - depthReductionMm);
const niche = next.nicheMeasurements || {};
const minNicheWidth = Math.min(niche.widthTopMm || next.widthMm, niche.widthMidMm || next.widthMm, niche.widthBottomMm || next.widthMm);
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);
let widthMm = next.widthMm;
let depthMm = next.depthMm;
let heightMm = next.heightMm;
if (next.installationType === 'niche') {
const gap = Number(next.sanitaryGapMm || 30);
widthMm = Math.max(400, minNicheWidth - gap * 2);
depthMm = Math.max(400, minNicheDepth - gap * 2);
heightMm = Math.max(1800, minNicheHeight - gap * 2);
}
const hardware = {
...next.hardware,
handleType,
drawerType,
handleBoreHeightMm: sanitizedHandleBore,
drawerHeightsMm: fixedDrawerHeights
};
const changed =
modulesChanged ||
widthMm !== next.widthMm ||
depthMm !== next.depthMm ||
heightMm !== next.heightMm ||
sanitizedHandleBore !== (next.hardware?.handleBoreHeightMm || 1000) ||
effectiveDrawerDepthMm !== next.effectiveDrawerDepthMm ||
handleType !== (next.hardware?.handleType || 'standard') ||
(next.hardware?.drawerType != null && drawerType !== next.hardware.drawerType) ||
next.hardware?.drawerType === 'hidden_with_handle';
if (!changed) return c;
return {
...next,
modules: constrainedModules,
widthMm,
depthMm,
heightMm,
hardware,
effectiveDrawerDepthMm
};
});
}, [config.installationType, config.nicheMeasurements, config.sanitaryGapMm, config.doorType, config.depthMm, config.heightMm, config.hardware]);
// Fetch initial data
useEffect(() => {
Promise.all([fetchFinishes(), fetchServiceOptions()])
.then(([fin, opt]) => {
setFinishes(fin);
setServiceOptions(opt);
// Only apply default finish if none was saved
if (fin.length) {
setConfig((c) => {
if (c.finishId) return c; // already has a saved finish
const firstMaterial = fin[0];
const defaultDim = firstMaterial.dimensions?.find(d => d.is_default) || firstMaterial.dimensions?.[0];
return { ...c, finishId: firstMaterial.id, dimensionId: defaultDim?.id || null };
});
}
})
.catch(console.error)
.finally(() => setLoading(false));
fetch(`${API_BASE}/api/handles`).then(r => r.json()).then(setHandleVariants).catch(console.error);
fetch(`${API_BASE}/api/hinges`).then(r => r.json()).then(setHingeVariants).catch(console.error);
fetch(`${API_BASE}/api/hardware`).then(r => r.json()).then(setBlumHardware).catch(console.error);
}, []);
// Price calculation
const updatePrice = useCallback(() => {
const totalW = config.modules.reduce((s, m) => s + (m.widthMm || 0), 0) || config.widthMm;
const payload = {
...config,
widthMm: totalW,
blocks: config.modules.map((m) => ({ blockTypeId: 1, widthMm: m.widthMm }))
};
calculatePrice(payload)
.then(setPriceResult)
.catch((err) => setPriceResult({ totalChf: 0, errors: [{ message: err.message }] }));
}, [config]);
useEffect(() => {
if (!loading) updatePrice();
}, [config, loading, updatePrice]);
// Simple setter helper
const set = useCallback((key, value) => setConfig((c) => ({ ...c, [key]: value })), []);
const setDimension = useCallback((key, valueMm) => {
const ranges = {
widthMm: { min: 400, max: 5000, step: 50 },
heightMm: { min: 1800, max: 2800, step: 50 },
depthMm: { min: 400, max: 800, step: 10 },
};
const rules = ranges[key];
if (!rules) return;
const nextRaw = Number(valueMm);
if (!Number.isFinite(nextRaw)) return;
const nextStepped = Math.round(nextRaw / rules.step) * rules.step;
const next = Math.min(rules.max, Math.max(rules.min, nextStepped));
setConfig((c) => {
const patch = { [key]: next };
if (c.installationType === 'niche') {
const gap = Number(c.sanitaryGapMm || 30);
const nicheOuter = next + gap * 2;
const niche = { ...(c.nicheMeasurements || {}) };
if (key === 'widthMm') {
niche.widthTopMm = nicheOuter;
niche.widthMidMm = nicheOuter;
niche.widthBottomMm = nicheOuter;
} else if (key === 'depthMm') {
niche.depthTopMm = nicheOuter;
niche.depthMidMm = nicheOuter;
niche.depthBottomMm = nicheOuter;
} else if (key === 'heightMm') {
niche.heightLeftMm = nicheOuter;
niche.heightRightMm = nicheOuter;
}
patch.nicheMeasurements = niche;
}
if (next === c[key] && !patch.nicheMeasurements) return c;
return { ...c, ...patch };
});
}, []);
// Column generation
const generateColumns = useCallback((nCols, totalW, existingModules = []) => {
const lockedWidth = existingModules
.filter((m, i) => i < nCols && m?.locked)
.reduce((s, m) => s + (m.widthMm || 0), 0);
const lockedCount = existingModules
.filter((m, i) => i < nCols && m?.locked).length;
const unlockedCount = nCols - lockedCount;
const remainingW = totalW - lockedWidth;
const colWidth = unlockedCount > 0 ? Math.round(remainingW / unlockedCount) : Math.round(totalW / nCols);
return Array.from({ length: nCols }, (_, i) => {
const existing = existingModules[i];
if (existing?.locked) {
return { ...existing, position: i };
}
const typeKey = existing?.type || DEFAULT_CYCLE[i % DEFAULT_CYCLE.length];
const typeDef = COLUMN_TYPES.find(t => t.key === typeKey) || COLUMN_TYPES[0];
return {
id: existing?.id || `col-${Date.now()}-${i}`,
type: typeDef.key,
moduleCode: typeDef.key,
widthMm: colWidth,
minWidth: 300,
maxWidth: 1200,
position: i,
locked: false,
shelves: typeDef.shelves,
drawers: typeDef.drawers,
hasRod: typeDef.hasRod
};
});
}, []);
const handleNumColumnsChange = useCallback((newNum) => {
setNumColumns(newNum);
const modules = generateColumns(newNum, config.widthMm, config.modules);
setConfig(c => ({ ...c, modules }));
}, [config.widthMm, config.modules, generateColumns]);
// AI layout optimization
const handleAIOptimize = useCallback(async () => {
setAiOptimizing(true);
setAiOptimizeMsg('');
try {
const res = await fetch(`${API_BASE}/api/layout/optimize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
totalWidth: config.totalWidth || config.widthMm,
height: config.height,
depth: config.depth,
installationType: config.installationType,
modules: config.modules.map(m => ({
type: m.type || m.moduleCode,
width: m.width,
locked: m.locked || false,
shelves: m.shelves,
drawers: m.drawers,
hasRod: m.hasRod
}))
})
});
if (!res.ok) throw new Error('API error');
const data = await res.json();
if (data.modules && Array.isArray(data.modules)) {
setConfig(c => {
const newModules = data.modules.map((m, i) => ({
...c.modules[i],
...m,
id: c.modules[i]?.id || `col-${i}`
}));
return { ...c, modules: newModules };
});
setNumColumns(data.modules.length);
}
if (data.reasoning) {
setAiOptimizeMsg(data.reasoning);
}
} catch (err) {
setAiOptimizeMsg('Impossible d\'optimiser. V\u00e9rifiez la configuration IA.');
} finally {
setAiOptimizing(false);
}
}, [config]);
const handleColumnTypeChange = useCallback((index, typeKey) => {
const typeDef = COLUMN_TYPES.find(t => t.key === typeKey) || COLUMN_TYPES[0];
setConfig(c => {
const modules = [...c.modules];
const minS = minShelvesForStructuralHeight(c.heightMm);
const shelves = Math.max(minS, typeDef.shelves);
modules[index] = {
...modules[index],
type: typeDef.key,
moduleCode: typeDef.key,
shelves,
drawers: typeDef.drawers,
hasRod: typeDef.hasRod
};
return { ...c, modules };
});
}, []);
const handleColumnLock = useCallback((index) => {
setConfig(c => {
const modules = [...c.modules];
modules[index] = { ...modules[index], locked: !modules[index].locked };
return { ...c, modules };
});
}, []);
const redistributeWidths = useCallback(() => {
setConfig(c => {
const modules = [...c.modules];
const lockedWidth = modules.filter(m => m.locked).reduce((s, m) => s + m.widthMm, 0);
const unlockedCount = modules.filter(m => !m.locked).length;
const remainingW = c.widthMm - lockedWidth;
const colWidth = unlockedCount > 0 ? Math.round(remainingW / unlockedCount) : 0;
return {
...c,
modules: modules.map(m => m.locked ? m : { ...m, widthMm: colWidth })
};
});
}, []);
// Re-distribute when width changes
useEffect(() => {
if (config.modules.length > 0) {
redistributeWidths();
}
}, [config.widthMm]);
// Initialize columns on first load
useEffect(() => {
if (config.modules.length === 0) {
const modules = generateColumns(numColumns, config.widthMm, []);
setConfig(c => ({ ...c, modules }));
}
}, []);
const handleColumnUpdate = useCallback((index, updates) => {
setConfig(c => {
const modules = [...c.modules];
const prev = modules[index];
const merged = { ...prev, ...updates };
const minS = minShelvesForStructuralHeight(c.heightMm);
const shelves = Math.max(minS, merged.shelves ?? 0);
const drawers = merged.drawers ?? 0;
const withShelves = {
...merged,
shelves,
shelfPositionsMm: resolveShelfPositionsMm({
shelfCount: shelves,
heightMm: c.heightMm,
plinthHeightMm: c.plinthHeightMm ?? 100,
shelfPositionsMm: merged.shelfPositionsMm
})
};
const needsMixte = shelves > 0 && drawers > 0 && withShelves.type !== 'mixte';
modules[index] = needsMixte
? { ...withShelves, type: 'mixte', moduleCode: 'mixte' }
: withShelves;
return { ...c, modules };
});
}, []);
const reorderModules = useCallback((fromIndex, toIndex) => {
if (fromIndex === toIndex) return;
setConfig((c) => {
const list = [...c.modules];
const n = list.length;
if (fromIndex < 0 || fromIndex >= n || toIndex < 0 || toIndex >= n) return c;
const [item] = list.splice(fromIndex, 1);
list.splice(toIndex, 0, item);
return {
...c,
modules: list.map((m, idx) => ({ ...m, position: idx })),
};
});
}, []);
const handleResizeModules = useCallback((separatorIndex, deltaXmm) => {
setConfig((c) => {
const modules = [...c.modules];
const left = { ...modules[separatorIndex] };
const right = { ...modules[separatorIndex + 1] };
const leftMin = left.minWidth || 300;
const leftMax = left.maxWidth || 1200;
const rightMin = right.minWidth || 300;
const rightMax = right.maxWidth || 1200;
let newLeftW = left.widthMm + deltaXmm;
let newRightW = right.widthMm - deltaXmm;
if (newLeftW < leftMin) { newRightW += (newLeftW - leftMin); newLeftW = leftMin; }
if (newLeftW > leftMax) { newRightW += (newLeftW - leftMax); newLeftW = leftMax; }
if (newRightW < rightMin) { newLeftW += (newRightW - rightMin); newRightW = rightMin; }
if (newRightW > rightMax) { newLeftW += (newRightW - rightMax); newRightW = rightMax; }
left.widthMm = Math.round(newLeftW);
right.widthMm = Math.round(newRightW);
modules[separatorIndex] = left;
modules[separatorIndex + 1] = right;
return { ...c, modules };
});
}, []);
// Color management
const handleElementClick = useCallback((elementType, label) => {
return { type: elementType, label };
}, []);
const handleColorSelect = useCallback((elementType, finish) => {
setConfig((c) => ({
...c,
colors: {
...c.colors,
[elementType]: {
finishId: finish.id,
label: finish.label || finish.name,
hexColor: finish.hexColor || '#c4a574',
imageUrl: finish.image_url || null
}
}
}));
}, []);
const handleColorApplyToAll = useCallback((finish) => {
const colorEntry = {
finishId: finish.id,
label: finish.label || finish.name,
hexColor: finish.hexColor || '#c4a574',
imageUrl: finish.image_url || null
};
setConfig((c) => {
const newColors = {};
Object.keys(DEFAULT_COLORS).forEach(key => {
newColors[key] = { ...colorEntry };
});
Object.keys(c.colors).forEach(key => {
if (!newColors[key]) newColors[key] = { ...colorEntry };
});
return { ...c, colors: newColors };
});
}, []);
// Hardware
const setHardware = useCallback((key, value) => {
setConfig((c) => ({
...c,
hardware: { ...c.hardware, [key]: value }
}));
}, []);
// Material management
const handleMaterialConfirm = useCallback((materialId, dimensionId) => {
setConfig((c) => {
const material = finishes.find(f => f.id === materialId);
const updated = { ...c, finishId: materialId, dimensionId };
if (material) {
const colorEntry = {
finishId: material.id,
label: material.label || material.name,
hexColor: material.hexColor || '#c4a574',
imageUrl: material.image_url || null
};
const newColors = {};
Object.keys(DEFAULT_COLORS).forEach(key => {
newColors[key] = { ...colorEntry };
});
Object.keys(c.colors).forEach(key => {
if (!newColors[key]) newColors[key] = { ...colorEntry };
});
updated.colors = newColors;
}
return updated;
});
}, [finishes]);
// Derived values
const totalModuleWidth = useMemo(() =>
config.modules.reduce((sum, m) => sum + (m.widthMm || 0), 0),
[config.modules]
);
const selectedFinish = useMemo(() =>
finishes.find(f => f.id === config.finishId),
[finishes, config.finishId]
);
const materialsByCollection = useMemo(() =>
finishes.reduce((acc, mat) => {
const coll = mat.collection || 'Autres';
if (!acc[coll]) acc[coll] = [];
acc[coll].push(mat);
return acc;
}, {}),
[finishes]
);
return {
config, setConfig, set, setDimension,
finishes, serviceOptions,
priceResult,
loading,
numColumns, setNumColumns,
handleVariants, hingeVariants, blumHardware,
aiOptimizing, aiOptimizeMsg, setAiOptimizeMsg,
generateColumns,
handleNumColumnsChange,
handleAIOptimize,
handleColumnTypeChange,
handleColumnLock,
redistributeWidths,
handleColumnUpdate,
reorderModules,
handleResizeModules,
handleElementClick,
handleColorSelect,
handleColorApplyToAll,
setHardware,
handleMaterialConfirm,
totalModuleWidth,
selectedFinish,
materialsByCollection,
canUndo: undoPastRef.current.length > 0,
canRedo: undoFutureRef.current.length > 0,
pushUndoSnapshot,
undo,
redo,
};
}
+707 -21
View File
@@ -1,7 +1,21 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { fetchOrders, fetchSyncLogs, getFormexSyncStreamUrl } from '../api/client';
import {
fetchOrders,
fetchSyncLogs,
getFormexSyncStreamUrl,
getHaefeleSyncStreamUrl,
fetchHaefeleMaterials,
fetchOrderSupplierVouchers,
fetchOrderPlanExports,
generateOrderPlanExports,
fetchOrderProductionDetails,
getOrderPlanExportDownloadUrl,
getOrderPlanExportViewUrl,
deleteOrderPlanExport
} from '../api/client';
import Icon from '../components/ui/Icon';
import styles from './Admin.module.css';
export default function Admin() {
@@ -10,6 +24,19 @@ export default function Admin() {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [expandedOrderId, setExpandedOrderId] = useState(null);
const [supplierVouchersByOrder, setSupplierVouchersByOrder] = useState({});
const [voucherLoadingByOrder, setVoucherLoadingByOrder] = useState({});
const [voucherErrorByOrder, setVoucherErrorByOrder] = useState({});
const [planExportsByOrder, setPlanExportsByOrder] = useState({});
const [planExportsLoadingByOrder, setPlanExportsLoadingByOrder] = useState({});
const [planExportsErrorByOrder, setPlanExportsErrorByOrder] = useState({});
const [planExportsGeneratingByOrder, setPlanExportsGeneratingByOrder] = useState({});
const [planExportDeletingById, setPlanExportDeletingById] = useState({});
const [productionDetailsByOrder, setProductionDetailsByOrder] = useState({});
const [productionLoadingByOrder, setProductionLoadingByOrder] = useState({});
const [productionErrorByOrder, setProductionErrorByOrder] = useState({});
const [productionVisibleByOrder, setProductionVisibleByOrder] = useState({});
// Formex sync state
const [syncLogs, setSyncLogs] = useState([]);
@@ -19,18 +46,32 @@ export default function Admin() {
// SSE live progress state
const [liveMessages, setLiveMessages] = useState([]);
const [progress, setProgress] = useState({ current: 0, total: 0, collection: '' });
const [forceOverride, setForceOverride] = useState(false);
const logsEndRef = useRef(null);
// Häfele sync state
const [haefeleUrlsInput, setHaefeleUrlsInput] = useState('');
const [haefeleSyncing, setHaefeleSyncing] = useState(false);
const [haefeleSyncResult, setHaefeleSyncResult] = useState(null);
const [haefeleLiveMessages, setHaefeleLiveMessages] = useState([]);
const [haefeleProgress, setHaefeleProgress] = useState({ current: 0, total: 0, collection: '' });
const [haefeleSearch, setHaefeleSearch] = useState('');
const [haefeleMaterials, setHaefeleMaterials] = useState([]);
const [haefeleLoading, setHaefeleLoading] = useState(false);
const [haefeleError, setHaefeleError] = useState('');
const haefeleLogsEndRef = useRef(null);
useEffect(() => {
if (!isAdmin) {
navigate('/login?next=/admin', { replace: true });
return;
}
// Fetch orders and sync logs in parallel
Promise.all([fetchOrders(), fetchSyncLogs(10)])
.then(([ordersData, logsData]) => {
Promise.all([fetchOrders(), fetchSyncLogs(10), fetchHaefeleMaterials({ limit: 50 })])
.then(([ordersData, logsData, haefeleData]) => {
setOrders(ordersData);
setSyncLogs(logsData);
setHaefeleMaterials(haefeleData?.materials || []);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
@@ -43,6 +84,17 @@ export default function Admin() {
}
}, [liveMessages]);
useEffect(() => {
if (haefeleLogsEndRef.current) {
haefeleLogsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [haefeleLiveMessages]);
useEffect(() => {
const saved = window.localStorage.getItem('admin.haefele.urls');
if (saved) setHaefeleUrlsInput(saved);
}, []);
const handleLogout = () => {
logout();
navigate('/', { replace: true });
@@ -54,14 +106,23 @@ export default function Admin() {
setLiveMessages([]);
setProgress({ current: 0, total: 0, collection: '' });
const eventSource = new EventSource(getFormexSyncStreamUrl());
const eventSource = new EventSource(getFormexSyncStreamUrl(forceOverride));
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'log') {
setLiveMessages(prev => [...prev, data.message]);
const msg = data.message;
const lower = msg.toLowerCase();
const kind = lower.includes('erreur') || lower.includes('error') || lower.includes('failed') || lower.includes('échou')
? 'error'
: lower.includes('warning') || lower.includes('non connecté') || lower.includes('pas de prix')
? 'warn'
: lower.includes('succès') || lower.includes('connecté') || lower.includes('synchronis')
? 'ok'
: 'info';
setLiveMessages(prev => [...prev, { msg, kind }]);
} else if (data.type === 'progress') {
setProgress({
current: data.current,
@@ -72,9 +133,9 @@ export default function Admin() {
setSyncResult(data.result);
setSyncing(false);
eventSource.close();
// Refresh sync logs
fetchSyncLogs(10).then(setSyncLogs).catch(() => {});
} else if (data.type === 'error') {
setLiveMessages(prev => [...prev, { msg: `ERREUR: ${data.error}`, kind: 'error' }]);
setSyncResult({ success: false, error: data.error });
setSyncing(false);
eventSource.close();
@@ -85,17 +146,211 @@ export default function Admin() {
};
eventSource.onerror = () => {
if (syncing) {
setLiveMessages(prev => [...prev, { msg: 'Connexion SSE perdue', kind: 'error' }]);
setSyncResult({ success: false, error: 'Connexion perdue' });
setSyncing(false);
}
eventSource.close();
};
};
const handleClearLogs = () => {
setLiveMessages([]);
setSyncResult(null);
setProgress({ current: 0, total: 0, collection: '' });
};
const parseHaefeleUrls = () =>
[...new Set(
haefeleUrlsInput
.split(/[\n,;]+/g)
.map((v) => v.trim())
.filter((v) => v && v.includes('haefele.ch'))
)];
const persistHaefeleUrls = (value) => {
setHaefeleUrlsInput(value);
window.localStorage.setItem('admin.haefele.urls', value);
};
const refreshHaefeleMaterials = async (search = haefeleSearch) => {
setHaefeleLoading(true);
setHaefeleError('');
try {
const data = await fetchHaefeleMaterials({ search, limit: 100 });
setHaefeleMaterials(data?.materials || []);
} catch (err) {
setHaefeleError(err.message || 'Erreur de chargement Häfele');
} finally {
setHaefeleLoading(false);
}
};
const handleHaefeleSync = () => {
const urls = parseHaefeleUrls();
if (urls.length === 0) {
setHaefeleSyncResult({ success: false, error: 'Ajoutez au moins une URL Häfele valide.' });
return;
}
setHaefeleSyncing(true);
setHaefeleSyncResult(null);
setHaefeleLiveMessages([]);
setHaefeleProgress({ current: 0, total: urls.length, collection: 'Hafele' });
persistHaefeleUrls(urls.join('\n'));
const eventSource = new EventSource(getHaefeleSyncStreamUrl(urls));
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('failed') || lower.includes('✗')
? 'error'
: lower.includes('warning') || lower.includes('non connecté') || lower.includes('pas de prix')
? 'warn'
: lower.includes('✓') || lower.includes('succès') || lower.includes('connecté') || lower.includes('synchronis')
? 'ok'
: 'info';
setHaefeleLiveMessages((prev) => [...prev, { msg, kind }]);
} else if (data.type === 'progress') {
setHaefeleProgress({
current: data.current,
total: data.total,
collection: data.collection || 'Hafele'
});
} else if (data.type === 'complete') {
setHaefeleSyncResult(data.result);
setHaefeleSyncing(false);
eventSource.close();
fetchSyncLogs(10).then(setSyncLogs).catch(() => {});
refreshHaefeleMaterials().catch(() => {});
} else if (data.type === 'error') {
setHaefeleLiveMessages((prev) => [...prev, { msg: `ERREUR: ${data.error}`, kind: 'error' }]);
setHaefeleSyncResult({ success: false, error: data.error });
setHaefeleSyncing(false);
eventSource.close();
fetchSyncLogs(10).then(setSyncLogs).catch(() => {});
refreshHaefeleMaterials().catch(() => {});
}
} catch (e) {
console.error('Hafele SSE parse error:', e);
}
};
eventSource.onerror = () => {
setHaefeleLiveMessages((prev) => [...prev, { msg: 'Connexion SSE Häfele perdue', kind: 'error' }]);
setHaefeleSyncResult({ success: false, error: 'Connexion perdue' });
setHaefeleSyncing(false);
eventSource.close();
fetchSyncLogs(10).then(setSyncLogs).catch(() => {});
refreshHaefeleMaterials().catch(() => {});
};
};
const handleClearHaefeleLogs = () => {
setHaefeleLiveMessages([]);
setHaefeleSyncResult(null);
setHaefeleProgress({ current: 0, total: 0, collection: '' });
};
const progressPercent = progress.total > 0
? Math.round((progress.current / progress.total) * 100)
: 0;
const haefeleProgressPercent = haefeleProgress.total > 0
? Math.round((haefeleProgress.current / haefeleProgress.total) * 100)
: 0;
const handleToggleSupplierDetails = async (orderId) => {
if (expandedOrderId === orderId) {
setExpandedOrderId(null);
return;
}
setExpandedOrderId(orderId);
const shouldLoadVouchers = !supplierVouchersByOrder[orderId] && !voucherLoadingByOrder[orderId];
const shouldLoadPlanExports = !planExportsByOrder[orderId] && !planExportsLoadingByOrder[orderId];
if (!shouldLoadVouchers && !shouldLoadPlanExports) return;
if (shouldLoadVouchers) {
setVoucherErrorByOrder(prev => ({ ...prev, [orderId]: '' }));
setVoucherLoadingByOrder(prev => ({ ...prev, [orderId]: true }));
}
if (shouldLoadPlanExports) {
setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: '' }));
setPlanExportsLoadingByOrder(prev => ({ ...prev, [orderId]: true }));
}
await Promise.all([
shouldLoadVouchers
? fetchOrderSupplierVouchers(orderId)
.then((data) => setSupplierVouchersByOrder(prev => ({ ...prev, [orderId]: data })))
.catch((err) => setVoucherErrorByOrder(prev => ({ ...prev, [orderId]: err.message || 'Erreur de chargement' })))
.finally(() => setVoucherLoadingByOrder(prev => ({ ...prev, [orderId]: false })))
: Promise.resolve(),
shouldLoadPlanExports
? fetchOrderPlanExports(orderId)
.then((data) => setPlanExportsByOrder(prev => ({ ...prev, [orderId]: data })))
.catch((err) => setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: err.message || 'Erreur de chargement' })))
.finally(() => setPlanExportsLoadingByOrder(prev => ({ ...prev, [orderId]: false })))
: Promise.resolve()
]);
};
const handleGeneratePlanExports = async (orderId) => {
setPlanExportsGeneratingByOrder(prev => ({ ...prev, [orderId]: true }));
setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: '' }));
try {
await generateOrderPlanExports(orderId);
const data = await fetchOrderPlanExports(orderId);
setPlanExportsByOrder(prev => ({ ...prev, [orderId]: data }));
} catch (err) {
setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: err.message || 'Erreur de génération' }));
} finally {
setPlanExportsGeneratingByOrder(prev => ({ ...prev, [orderId]: false }));
}
};
const handleToggleProductionDetails = async (orderId) => {
const isVisible = !!productionVisibleByOrder[orderId];
if (isVisible) {
setProductionVisibleByOrder(prev => ({ ...prev, [orderId]: false }));
return;
}
setProductionVisibleByOrder(prev => ({ ...prev, [orderId]: true }));
if (productionDetailsByOrder[orderId] || productionLoadingByOrder[orderId]) return;
setProductionErrorByOrder(prev => ({ ...prev, [orderId]: '' }));
setProductionLoadingByOrder(prev => ({ ...prev, [orderId]: true }));
try {
const data = await fetchOrderProductionDetails(orderId);
setProductionDetailsByOrder(prev => ({ ...prev, [orderId]: data }));
} catch (err) {
setProductionErrorByOrder(prev => ({ ...prev, [orderId]: err.message || 'Erreur de chargement du dossier' }));
} finally {
setProductionLoadingByOrder(prev => ({ ...prev, [orderId]: false }));
}
};
const handleDeletePlanExport = async (orderId, exportId) => {
const confirmed = window.confirm('Supprimer cet export ?');
if (!confirmed) return;
setPlanExportDeletingById(prev => ({ ...prev, [exportId]: true }));
setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: '' }));
try {
await deleteOrderPlanExport(orderId, exportId);
const data = await fetchOrderPlanExports(orderId);
setPlanExportsByOrder(prev => ({ ...prev, [orderId]: data }));
} catch (err) {
setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: err.message || 'Erreur de suppression' }));
} finally {
setPlanExportDeletingById(prev => ({ ...prev, [exportId]: false }));
}
};
if (!isAdmin) return null;
@@ -104,6 +359,7 @@ export default function Admin() {
<div className={styles.header}>
<h1>Administration</h1>
<button type="button" onClick={handleLogout} className={styles.logout}>
<Icon name="logout" size={15} />
Déconnexion
</button>
</div>
@@ -121,8 +377,18 @@ export default function Admin() {
disabled={syncing}
className={styles.syncBtn}
>
<Icon name="sync" size={15} />
{syncing ? 'Synchronisation en cours…' : 'Synchroniser Formex'}
</button>
<label className={styles.forceLabel}>
<input
type="checkbox"
checked={forceOverride}
onChange={e => setForceOverride(e.target.checked)}
disabled={syncing}
/>
Forcer la resynchronisation complète
</label>
{syncResult && !syncing && (
<span className={syncResult.success ? styles.success : styles.error}>
{syncResult.success
@@ -132,28 +398,36 @@ export default function Admin() {
)}
</div>
{/* Progress bar and live logs during sync */}
{syncing && (
{/* Progress bar and live logs — stays visible after sync ends */}
{(syncing || liveMessages.length > 0) && (
<div className={styles.liveSync}>
<div className={styles.progressWrap}>
<div className={styles.progressInfo}>
<span>{progress.collection || 'Initialisation…'}</span>
<span>{progressPercent}%</span>
<span>{progress.collection || (syncing ? 'Initialisation…' : 'Terminé')}</span>
<span>{syncing ? `${progressPercent}%` : (syncResult?.success ? '✓ Succès' : '✗ Échec')}</span>
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${progressPercent}%` }}
className={`${styles.progressFill} ${!syncing && syncResult ? (syncResult.success ? styles.progressDone : styles.progressError) : ''}`}
style={{ width: `${syncing ? progressPercent : 100}%` }}
/>
</div>
<div className={styles.progressStats}>
{progress.current} / {progress.total} collections
{progress.total > 0 && `${progress.current} / ${progress.total} collections`}
</div>
</div>
<div className={styles.liveLogsPanel}>
<div className={styles.liveLogsHeader}>
<span>Journal ({liveMessages.length} lignes)</span>
{!syncing && (
<button type="button" onClick={handleClearLogs} className={styles.clearBtn}>
Effacer
</button>
)}
</div>
<div className={styles.liveLogsContent}>
{liveMessages.map((msg, idx) => (
<div key={idx} className={styles.logLine}>{msg}</div>
{liveMessages.map(({ msg, kind }, idx) => (
<div key={idx} className={`${styles.logLine} ${styles[`logLine_${kind}`]}`}>{msg}</div>
))}
<div ref={logsEndRef} />
</div>
@@ -161,7 +435,7 @@ export default function Admin() {
</div>
)}
{syncLogs.length > 0 && !syncing && (
{syncLogs.length > 0 && !syncing && liveMessages.length === 0 && (
<div className={styles.logsWrap}>
<h3>Historique des synchronisations</h3>
<table className={styles.logsTable}>
@@ -193,6 +467,146 @@ export default function Admin() {
</section>
)}
{!loading && !error && (
<section className={styles.section}>
<h2>Synchronisation Häfele (URLs ciblées)</h2>
<p className={styles.muted}>
Collez une URL par ligne (ou séparées par virgule). Seules les URL Häfele seront traitées.
</p>
<div className={styles.haefeleInputWrap}>
<textarea
className={styles.urlTextarea}
value={haefeleUrlsInput}
onChange={(e) => persistHaefeleUrls(e.target.value)}
placeholder={`https://www.haefele.ch/fr/produkt/.../P-01319387/\nhttps://www.haefele.ch/fr/produkt/.../P-xxxxxxx/`}
rows={6}
disabled={haefeleSyncing}
/>
<div className={styles.syncActions}>
<button
type="button"
onClick={handleHaefeleSync}
disabled={haefeleSyncing}
className={styles.syncBtn}
>
<Icon name="sync" size={15} />
{haefeleSyncing ? 'Synchronisation Häfele en cours…' : 'Synchroniser URLs Häfele'}
</button>
{haefeleSyncResult && !haefeleSyncing && (
<span className={haefeleSyncResult.success ? styles.success : styles.error}>
{haefeleSyncResult.success
? `${haefeleSyncResult.itemsCount} produits synchronisés (${haefeleSyncResult.duration}s)`
: `Erreur: ${haefeleSyncResult.error}`}
</span>
)}
</div>
</div>
{(haefeleSyncing || haefeleLiveMessages.length > 0) && (
<div className={styles.liveSync}>
<div className={styles.progressWrap}>
<div className={styles.progressInfo}>
<span>{haefeleProgress.collection || (haefeleSyncing ? 'Initialisation…' : 'Terminé')}</span>
<span>
{haefeleSyncing
? `${haefeleProgressPercent}%`
: (haefeleSyncResult?.success ? '✓ Succès' : '✗ Échec')}
</span>
</div>
<div className={styles.progressBar}>
<div
className={`${styles.progressFill} ${!haefeleSyncing && haefeleSyncResult ? (haefeleSyncResult.success ? styles.progressDone : styles.progressError) : ''}`}
style={{ width: `${haefeleSyncing ? haefeleProgressPercent : 100}%` }}
/>
</div>
<div className={styles.progressStats}>
{haefeleProgress.total > 0 && `${haefeleProgress.current} / ${haefeleProgress.total} URLs`}
</div>
</div>
<div className={styles.liveLogsPanel}>
<div className={styles.liveLogsHeader}>
<span>Journal Häfele ({haefeleLiveMessages.length} lignes)</span>
{!haefeleSyncing && (
<button type="button" onClick={handleClearHaefeleLogs} className={styles.clearBtn}>
Effacer
</button>
)}
</div>
<div className={styles.liveLogsContent}>
{haefeleLiveMessages.map(({ msg, kind }, idx) => (
<div key={idx} className={`${styles.logLine} ${styles[`logLine_${kind}`]}`}>{msg}</div>
))}
<div ref={haefeleLogsEndRef} />
</div>
</div>
</div>
)}
<div className={styles.haefeleListHeader}>
<h3>Articles Häfele récupérés</h3>
<div className={styles.haefeleListActions}>
<input
type="text"
className={styles.searchInput}
value={haefeleSearch}
onChange={(e) => setHaefeleSearch(e.target.value)}
placeholder="Rechercher (nom, code, URL)"
/>
<button
type="button"
className={styles.detailsBtn}
onClick={() => refreshHaefeleMaterials()}
disabled={haefeleLoading}
>
<Icon name="sync" size={14} />
{haefeleLoading ? 'Chargement…' : 'Actualiser'}
</button>
</div>
</div>
{haefeleError && <p className={styles.error}>{haefeleError}</p>}
<div className={styles.tableWrap}>
<table className={styles.logsTable}>
<thead>
<tr>
<th>Code</th>
<th>Nom</th>
<th>Prix</th>
<th>Épaisseur</th>
<th>Dernière sync</th>
<th>URL</th>
</tr>
</thead>
<tbody>
{haefeleMaterials.length === 0 ? (
<tr>
<td colSpan={6} className={styles.empty}>
{haefeleLoading ? 'Chargement…' : 'Aucun article Häfele synchronisé.'}
</td>
</tr>
) : (
haefeleMaterials.map((item) => (
<tr key={item.id}>
<td>{item.formex_code || '—'}</td>
<td>{item.name || '—'}</td>
<td>{item.price_chf != null ? `${Number(item.price_chf).toFixed(2)} CHF` : '—'}</td>
<td>{item.thickness_mm != null ? `${item.thickness_mm} mm` : '—'}</td>
<td>{item.last_synced_at ? new Date(item.last_synced_at).toLocaleString('fr-CH') : '—'}</td>
<td>
{item.formex_url ? (
<a href={item.formex_url} target="_blank" rel="noreferrer" className={styles.downloadLink}>
Voir page
</a>
) : '—'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
)}
{/* Orders Section */}
{!loading && !error && (
<section className={styles.section}>
@@ -208,17 +622,33 @@ export default function Admin() {
<th>Email</th>
<th>Téléphone</th>
<th>Remarques</th>
<th>Fournisseurs</th>
</tr>
</thead>
<tbody>
{orders.length === 0 ? (
<tr>
<td colSpan={7} className={styles.empty}>
<td colSpan={8} className={styles.empty}>
Aucune commande.
</td>
</tr>
) : (
orders.map((o) => (
orders.flatMap((o) => {
const isExpanded = expandedOrderId === o.id;
const vouchersData = supplierVouchersByOrder[o.id];
const supplierRows = vouchersData?.suppliers || [];
const isLoadingVouchers = !!voucherLoadingByOrder[o.id];
const vouchersError = voucherErrorByOrder[o.id];
const planExports = planExportsByOrder[o.id] || [];
const isLoadingPlanExports = !!planExportsLoadingByOrder[o.id];
const planExportsError = planExportsErrorByOrder[o.id];
const isGeneratingPlanExports = !!planExportsGeneratingByOrder[o.id];
const productionDetails = productionDetailsByOrder[o.id];
const isProductionLoading = !!productionLoadingByOrder[o.id];
const productionError = productionErrorByOrder[o.id];
const isProductionVisible = !!productionVisibleByOrder[o.id];
const summaryRow = (
<tr key={o.id}>
<td>{o.id}</td>
<td>{o.created_at ? new Date(o.created_at).toLocaleString('fr-CH') : '—'}</td>
@@ -227,8 +657,264 @@ export default function Admin() {
<td>{o.email || '—'}</td>
<td>{o.phone || '—'}</td>
<td>{o.notes || '—'}</td>
<td>
<button
type="button"
className={styles.detailsBtn}
onClick={() => handleToggleSupplierDetails(o.id)}
>
<Icon name="eye" size={14} />
{isExpanded ? 'Masquer' : 'Voir éléments'}
</button>
</td>
</tr>
))
);
if (!isExpanded) return [summaryRow];
return [
summaryRow,
<tr key={`details-${o.id}`} className={styles.detailsRow}>
<td colSpan={8}>
<div className={styles.supplierDetails}>
<div className={styles.detailsSection}>
<h3 className={styles.detailsSectionTitle}>Exports plans / débitage</h3>
<div className={styles.planExportActions}>
<button
type="button"
className={styles.generateBtn}
onClick={() => handleGeneratePlanExports(o.id)}
disabled={isGeneratingPlanExports}
>
<Icon name="file" size={14} />
{isGeneratingPlanExports ? 'Génération…' : 'Générer les exports'}
</button>
</div>
{isLoadingPlanExports && (
<p className={styles.muted}>Chargement des exports enregistrés…</p>
)}
{!isLoadingPlanExports && planExportsError && (
<p className={styles.error}>{planExportsError}</p>
)}
{!isLoadingPlanExports && !planExportsError && planExports.length === 0 && (
<p className={styles.muted}>Aucun export enregistré pour cette commande.</p>
)}
{!isLoadingPlanExports && !planExportsError && planExports.length > 0 && (
<table className={styles.planExportsTable}>
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>Fichier</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{planExports.map((item) => (
<tr key={item.id}>
<td>{item.created_at ? new Date(item.created_at).toLocaleString('fr-CH') : '—'}</td>
<td>{item.export_type || '—'}</td>
<td>{item.file_name || '—'}</td>
<td>
<a
href={getOrderPlanExportViewUrl(o.id, item.id)}
className={styles.downloadLink}
target="_blank"
rel="noreferrer"
>
<Icon name="eye" size={13} />
Voir
</a>
{' · '}
<a
href={getOrderPlanExportDownloadUrl(o.id, item.id)}
className={styles.downloadLink}
target="_blank"
rel="noreferrer"
>
<Icon name="download" size={13} />
Télécharger
</a>
{' · '}
<button
type="button"
className={styles.deleteLink}
disabled={!!planExportDeletingById[item.id]}
onClick={() => handleDeletePlanExport(o.id, item.id)}
>
<Icon name="trash" size={13} />
{planExportDeletingById[item.id] ? 'Suppression…' : 'Supprimer'}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.detailsSection}>
<h3 className={styles.detailsSectionTitle}>Dossier technique complet</h3>
<div className={styles.planExportActions}>
<button
type="button"
className={styles.generateBtn}
onClick={() => handleToggleProductionDetails(o.id)}
>
<Icon name="blueprint" size={14} />
{isProductionVisible ? 'Masquer le dossier' : 'Afficher le dossier'}
</button>
</div>
{isProductionVisible && isProductionLoading && (
<p className={styles.muted}>Génération du dossier technique…</p>
)}
{isProductionVisible && !isProductionLoading && productionError && (
<p className={styles.error}>{productionError}</p>
)}
{isProductionVisible && !isProductionLoading && !productionError && productionDetails && (
<div className={styles.productionWrap}>
<div className={styles.productionSummary}>
<span>{productionDetails.config.widthMm} × {productionDetails.config.heightMm} × {productionDetails.config.depthMm} mm</span>
<span>•</span>
<span>{productionDetails.config.installationType === 'niche' ? 'Encastrée' : 'Libre'}</span>
<span>•</span>
<span>{productionDetails.config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</span>
<span>•</span>
<span>{productionDetails.config.moduleCount} modules</span>
</div>
<div className={styles.planGrid}>
<div className={styles.planCard}>
<h4>Vue de face</h4>
<div dangerouslySetInnerHTML={{ __html: productionDetails.plans?.assembly || '' }} />
</div>
<div className={styles.planCard}>
<h4>Vue de côté</h4>
<div dangerouslySetInnerHTML={{ __html: productionDetails.plans?.side || '' }} />
</div>
<div className={styles.planCard}>
<h4>Vue de dessus</h4>
<div dangerouslySetInnerHTML={{ __html: productionDetails.plans?.top || '' }} />
</div>
<div className={styles.planCard}>
<h4>Détail usinage</h4>
<div dangerouslySetInnerHTML={{ __html: productionDetails.plans?.detail || '' }} />
</div>
</div>
<div className={styles.productionTables}>
<h4>Liste de découpage</h4>
<table className={styles.planExportsTable}>
<thead>
<tr>
<th>Pièce</th>
<th>L × H × Ép.</th>
<th>Qté</th>
<th>Finition</th>
</tr>
</thead>
<tbody>
{(productionDetails.cuttingList?.pieces || []).map((piece) => (
<tr key={piece.id}>
<td>{piece.description}</td>
<td>{piece.widthMm} × {piece.heightMm} × {piece.thicknessMm} mm</td>
<td>{piece.quantity}</td>
<td>{piece.finishLabel || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className={styles.productionTables}>
<h4>Colisage</h4>
<table className={styles.planExportsTable}>
<thead>
<tr>
<th>Colis</th>
<th>Niveau</th>
<th>Poids</th>
<th>Pièces</th>
</tr>
</thead>
<tbody>
{(productionDetails.packing?.boxes || []).map((box) => (
<tr key={box.id}>
<td>{box.id}</td>
<td>{box.level}</td>
<td>{Number(box.totalWeightKg || 0).toFixed(1)} kg</td>
<td>{box.pieces?.length || 0}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
<div className={styles.detailsSection}>
<h3 className={styles.detailsSectionTitle}>Éléments envoyés fournisseurs</h3>
{isLoadingVouchers && (
<p className={styles.muted}>Chargement des éléments fournisseurs…</p>
)}
{!isLoadingVouchers && vouchersError && (
<p className={styles.error}>{vouchersError}</p>
)}
{!isLoadingVouchers && !vouchersError && supplierRows.length === 0 && (
<p className={styles.muted}>Aucun élément fournisseur résolu pour cette commande.</p>
)}
{!isLoadingVouchers && !vouchersError && supplierRows.length > 0 && (
<div className={styles.supplierGrid}>
{supplierRows.map((supplier) => (
<div key={supplier.supplierId} className={styles.supplierCard}>
<div className={styles.supplierHeader}>
<strong>{supplier.supplierName}</strong>
<span>{supplier.supplierEmail}</span>
</div>
<table className={styles.supplierItemsTable}>
<thead>
<tr>
<th>Code</th>
<th>Désignation</th>
<th>Qté</th>
<th>PU</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{supplier.items.map((item, idx) => (
<tr key={`${supplier.supplierId}-${idx}`}>
<td>{item.code || '—'}</td>
<td>{item.name || '—'}</td>
<td>{item.qty}</td>
<td>{item.unitPrice != null ? `${Number(item.unitPrice).toFixed(2)} CHF` : '—'}</td>
<td>
{item.unitPrice != null
? `${(Number(item.unitPrice) * Number(item.qty || 0)).toFixed(2)} CHF`
: '—'}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={4}>Sous-total</td>
<td>{Number(supplier.subtotal || 0).toFixed(2)} CHF</td>
</tr>
</tfoot>
</table>
</div>
))}
</div>
)}
</div>
</div>
</td>
</tr>
];
})
)}
</tbody>
</table>
+376 -14
View File
@@ -19,18 +19,24 @@
}
.logout {
padding: 0.5rem 1rem;
background: transparent;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 0.95rem;
background: #fff;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: 10px;
color: var(--text-muted);
font-size: 0.9375rem;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.15s ease;
}
.logout:hover {
border-color: var(--accent);
color: var(--primary);
border-color: var(--action);
color: var(--action);
box-shadow: 0 6px 18px rgba(79, 70, 229, 0.15);
transform: translateY(-1px);
}
.muted {
@@ -112,19 +118,22 @@
}
.syncBtn {
padding: 0.625rem 1.25rem;
background: var(--accent);
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.625rem 1rem;
background: linear-gradient(135deg, var(--action), var(--action-hover));
border: none;
border-radius: 6px;
border-radius: 10px;
color: #fff;
font-size: 0.9375rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
transition: all 0.15s ease;
}
.syncBtn:hover:not(:disabled) {
background: var(--primary);
box-shadow: 0 10px 22px rgba(79, 70, 229, 0.26);
transform: translateY(-1px);
}
.syncBtn:disabled {
@@ -132,6 +141,25 @@
cursor: not-allowed;
}
.forceLabel {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--text-secondary, #666);
cursor: pointer;
user-select: none;
}
.forceLabel input[type="checkbox"] {
accent-color: var(--accent, #e67e22);
}
.forceLabel:has(input:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
.success {
color: #16a34a;
}
@@ -233,8 +261,342 @@
line-height: 1.5;
}
.liveLogsHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 1rem;
background: #2d2d2d;
border-bottom: 1px solid #444;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.75rem;
color: #888;
}
.clearBtn {
background: transparent;
border: 1px solid #555;
border-radius: 4px;
color: #aaa;
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
cursor: pointer;
}
.clearBtn:hover {
border-color: #888;
color: #ddd;
}
.progressDone {
background: #16a34a;
}
.progressError {
background: #b91c1c;
}
.haefeleInputWrap {
margin-top: 0.75rem;
}
.urlTextarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 0.86rem;
line-height: 1.4;
resize: vertical;
margin-bottom: 0.75rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.urlTextarea:focus {
outline: none;
border-color: var(--action);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.14);
}
.haefeleListHeader {
margin-top: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.haefeleListActions {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.searchInput {
min-width: 280px;
padding: 0.45rem 0.6rem;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.82rem;
}
.searchInput:focus {
outline: none;
border-color: var(--action);
}
.logLine {
color: #d4d4d4;
white-space: pre-wrap;
word-break: break-word;
}
.logLine_error {
color: #f87171;
}
.logLine_warn {
color: #fbbf24;
}
.logLine_ok {
color: #4ade80;
}
.logLine_info {
color: #d4d4d4;
}
.detailsBtn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.7rem;
border: 1px solid var(--action);
border-radius: 9px;
background: #fff;
color: var(--action);
font-size: 0.78rem;
font-weight: 600;
transition: all 0.15s ease;
}
.detailsBtn:hover {
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.15);
transform: translateY(-1px);
}
.detailsRow td {
background: #fcfcfd;
}
.supplierDetails {
padding: 0.5rem 0;
}
.detailsSection {
margin-bottom: 1rem;
}
.detailsSectionTitle {
margin: 0 0 0.5rem;
font-size: 0.9rem;
color: var(--primary);
}
.planExportActions {
margin-bottom: 0.5rem;
}
.generateBtn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.42rem 0.72rem;
border: 1px solid var(--accent);
border-radius: 9px;
background: #fff;
color: var(--accent);
font-size: 0.78rem;
font-weight: 600;
transition: all 0.15s ease;
}
.generateBtn:hover:not(:disabled) {
border-color: var(--action);
color: var(--action);
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.15);
transform: translateY(-1px);
}
.generateBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.supplierGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 0.75rem;
}
.supplierCard {
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.supplierHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.supplierHeader strong {
color: var(--primary);
font-size: 0.875rem;
}
.supplierHeader span {
color: var(--text-muted);
font-size: 0.75rem;
}
.supplierItemsTable {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.supplierItemsTable th,
.supplierItemsTable td {
padding: 0.4rem 0.5rem;
border-bottom: 1px solid var(--border);
text-align: left;
}
.supplierItemsTable th {
background: var(--bg);
color: var(--text-muted);
font-weight: 600;
}
.supplierItemsTable tfoot td {
font-weight: 600;
}
.planExportsTable {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.planExportsTable th,
.planExportsTable td {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--border);
text-align: left;
}
.planExportsTable th {
background: var(--bg);
color: var(--text-muted);
font-weight: 600;
}
.planExportsTable tbody tr:last-child td {
border-bottom: none;
}
.downloadLink {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: var(--accent);
text-decoration: none;
font-weight: 600;
}
.downloadLink:hover {
text-decoration: underline;
}
.deleteLink {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: transparent;
border: none;
color: #b91c1c;
text-decoration: underline;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
padding: 0;
}
.deleteLink:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.productionWrap {
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
padding: 0.75rem;
}
.productionSummary {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
color: var(--text-muted);
font-size: 0.8rem;
margin-bottom: 0.75rem;
}
.planGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.planCard {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem;
background: var(--bg);
overflow: auto;
}
.planCard h4 {
margin: 0 0 0.4rem;
font-size: 0.8rem;
color: var(--primary);
}
.planCard svg {
max-width: 100%;
height: auto;
}
.productionTables {
margin-top: 0.75rem;
}
.productionTables h4 {
margin: 0 0 0.4rem;
font-size: 0.8rem;
color: var(--primary);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+50 -14
View File
@@ -4,6 +4,7 @@ import { useAuth } from '../context/AuthContext';
import {
fetchFormexMaterials,
fetchFormexCollections,
fetchMaterialSuppliers,
deleteFormexMaterial
} from '../api/client';
import MaterialModal from '../components/MaterialModal';
@@ -16,6 +17,7 @@ export default function Materials() {
// Data state
const [materials, setMaterials] = useState([]);
const [collections, setCollections] = useState([]);
const [suppliers, setSuppliers] = useState([]);
const [pagination, setPagination] = useState({ page: 1, limit: 25, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -23,6 +25,7 @@ export default function Materials() {
// Filter state
const [search, setSearch] = useState('');
const [collection, setCollection] = useState('');
const [supplier, setSupplier] = useState('');
const [searchInput, setSearchInput] = useState('');
// Modal state
@@ -36,7 +39,7 @@ export default function Materials() {
setLoading(true);
setError('');
try {
const result = await fetchFormexMaterials({ search, collection, page, limit: 25 });
const result = await fetchFormexMaterials({ search, collection, supplier, page, limit: 25 });
setMaterials(result.materials);
setPagination(result.pagination);
} catch (err) {
@@ -44,18 +47,15 @@ export default function Materials() {
} finally {
setLoading(false);
}
}, [search, collection]);
}, [search, collection, supplier]);
useEffect(() => {
if (!isAdmin) {
navigate('/login?next=/materiaux', { replace: true });
return;
}
// Load collections for filter dropdown
fetchFormexCollections()
.then(setCollections)
.catch(() => {});
// Load materials
fetchFormexCollections().then(setCollections).catch(() => {});
fetchMaterialSuppliers().then(setSuppliers).catch(() => {});
loadMaterials(1);
}, [isAdmin, navigate, loadMaterials]);
@@ -68,10 +68,9 @@ export default function Materials() {
setCollection(e.target.value);
};
// Reload when filters change
useEffect(() => {
loadMaterials(1);
}, [search, collection, loadMaterials]);
}, [search, collection, supplier, loadMaterials]);
const handlePageChange = (newPage) => {
loadMaterials(newPage);
@@ -123,13 +122,12 @@ export default function Materials() {
return (
<div className={styles.page}>
<div className={styles.header}>
<h1>Matériaux Formex</h1>
<h1>Matériaux</h1>
<button type="button" onClick={handleCreate} className={styles.createBtn}>
+ Nouveau matériau
</button>
</div>
{/* Filters */}
<div className={styles.filters}>
<form onSubmit={handleSearch} className={styles.searchForm}>
<input
@@ -141,6 +139,16 @@ export default function Materials() {
/>
<button type="submit" className={styles.searchBtn}>Rechercher</button>
</form>
<select
value={supplier}
onChange={(e) => setSupplier(e.target.value)}
className={styles.collectionSelect}
>
<option value="">Tous les fournisseurs</option>
{suppliers.map((s) => (
<option key={s.code} value={s.code}>{s.name}</option>
))}
</select>
<select
value={collection}
onChange={handleCollectionChange}
@@ -163,30 +171,58 @@ export default function Materials() {
<table className={styles.table}>
<thead>
<tr>
<th>Fournisseur</th>
<th>Code</th>
<th>Nom</th>
<th>Collection</th>
<th>Dimensions (mm)</th>
<th>Perçage</th>
<th>Prix CHF</th>
<th>Unité</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{materials.length === 0 ? (
<tr>
<td colSpan={5} className={styles.empty}>
<td colSpan={9} className={styles.empty}>
Aucun matériau trouvé.
</td>
</tr>
) : (
materials.map((m) => (
<tr key={m.id}>
<td><span className={styles.supplierBadge}>{m.supplier_name || '—'}</span></td>
<td className={styles.code}>{m.formex_code}</td>
<td>{m.name}</td>
<td>{m.collection}</td>
<td>
{m.width_mm && m.height_mm && m.thickness_mm
{m.diameter_mm
? `Ø ${m.diameter_mm}${m.length_mm ? ` × ${m.length_mm}` : ''}`
: m.width_mm && m.height_mm && m.thickness_mm
? `${m.width_mm} × ${m.height_mm} × ${m.thickness_mm}`
: '—'}
: m.thickness_mm ? `ép. ${m.thickness_mm}` : '—'}
</td>
<td className={styles.boreCell}>
{(m.bore_diameter_mm != null || m.bore_depth_base_mm != null || m.bore_depth_piece_mm != null) ? (
<div className={styles.boreInfo}>
{m.bore_diameter_mm != null && <span className={styles.boreBadge}>Ø {m.bore_diameter_mm}</span>}
{m.bore_depth_base_mm != null && <span className={styles.boreDepth} title="Profondeur perçage fond">fond: {m.bore_depth_base_mm}</span>}
{m.bore_depth_piece_mm != null && <span className={styles.boreDepth} title="Profondeur perçage pièce">pièce: {m.bore_depth_piece_mm}</span>}
</div>
) : <span className={styles.priceNone}>—</span>}
</td>
<td>
{m.price_chf != null
? <span className={styles.priceOk}>{Number(m.price_chf).toFixed(2)}</span>
: <span className={styles.priceNone}>—</span>
}
</td>
<td>
{m.price_unit
? <span className={styles.priceUnit}>{m.price_unit}</span>
: <span className={styles.priceNone}>—</span>
}
</td>
<td className={styles.actions}>
<button
+66
View File
@@ -129,6 +129,72 @@
font-weight: 500;
}
.supplierBadge {
display: inline-block;
padding: 0.15rem 0.5rem;
background: #eff6ff;
color: #1d4ed8;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: capitalize;
white-space: nowrap;
}
.boreCell {
white-space: nowrap;
}
.boreInfo {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
}
.boreBadge {
display: inline-block;
padding: 0.1rem 0.4rem;
background: #fef3c7;
color: #92400e;
border-radius: 4px;
font-weight: 600;
white-space: nowrap;
}
.boreDepth {
display: inline-block;
padding: 0.1rem 0.4rem;
background: #f0fdf4;
color: #166534;
border-radius: 4px;
font-weight: 500;
white-space: nowrap;
}
.priceOk {
display: inline-block;
padding: 0.175rem 0.5rem;
background: #dcfce7;
color: #15803d;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.priceNone {
color: var(--text-muted);
font-size: 0.8125rem;
}
.priceUnit {
font-size: 0.8125rem;
color: var(--text-muted);
font-style: italic;
}
.actions {
display: flex;
gap: 0.5rem;
+627
View File
@@ -0,0 +1,627 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import {
fetchSettings, updateSettings,
fetchSuppliers, createSupplier, updateSupplier, deleteSupplier,
fetchEngineeringRules, createEngineeringRule, updateEngineeringRule, deleteEngineeringRule
} from '../api/client';
import styles from './Settings.module.css';
// Human-readable labels for settings keys
const SETTINGS_META = [
{ key: 'kit_preparation_fee_chf', label: 'Forfait préparation kit (CHF)', type: 'number', min: 0, step: 10, unit: 'CHF' },
{ key: 'panel_thickness_mm', label: 'Épaisseur panneaux', type: 'number', min: 1, step: 1, unit: 'mm' },
{ key: 'plinth_height_min_mm', label: 'Hauteur plinthe minimale', type: 'number', min: 0, step: 5, unit: 'mm' },
{ key: 'plinth_height_max_mm', label: 'Hauteur plinthe maximale', type: 'number', min: 0, step: 5, unit: 'mm' },
{ key: 'sliding_doors_min_width_mm', label: 'Largeur min. portes coulissantes', type: 'number', min: 0, step: 50, unit: 'mm' },
{ key: 'allow_sliding_doors', label: 'Autoriser portes coulissantes (1/0)', type: 'number', min: 0, step: 1, unit: '' },
{ key: 'sanitary_gap_mm', label: 'Vide sanitaire niche', type: 'number', min: 0, step: 1, unit: 'mm' },
{ key: 'drawer_depth_min_mm', label: 'Tiroir profondeur min', type: 'number', min: 0, step: 10, unit: 'mm' },
{ key: 'drawer_depth_max_mm', label: 'Tiroir profondeur max', type: 'number', min: 0, step: 10, unit: 'mm' },
{ key: 'drawer_depth_step_mm', label: 'Tiroir pas profondeur', type: 'number', min: 1, step: 1, unit: 'mm' },
{ key: 'drawer_back_gap_mm', label: 'Vide sanitaire derrière tiroir', type: 'number', min: 0, step: 1, unit: 'mm' },
{ key: 'drawer_hidden_handle_reduction_mm', label: 'Réduction profondeur tiroir caché', type: 'number', min: 0, step: 1, unit: 'mm' },
{ key: 'standard_handle_axis_mm', label: 'Axe perçage poignée standard', type: 'number', min: 0, step: 10, unit: 'mm' },
{ key: 'standard_handle_tolerance_mm', label: 'Tolérance axe poignée', type: 'number', min: 0, step: 10, unit: 'mm' },
{ key: 'groove_width_mm', label: 'Rainure largeur', type: 'number', min: 0, step: 0.5, unit: 'mm' },
{ key: 'groove_depth_mm', label: 'Rainure profondeur', type: 'number', min: 0, step: 0.5, unit: 'mm' },
{ key: 'groove_edge_offset_mm', label: 'Rainure distance au bord', type: 'number', min: 0, step: 0.5, unit: 'mm' },
{ key: 'niche_clearance_multiplier', label: 'Multiplicateur jeu niche', type: 'number', min: 1, step: 0.5, unit: '' },
{ key: 'min_clearance_mm', label: 'Jeu minimum', type: 'number', min: 0, step: 1, unit: 'mm' },
];
const SUPPLIER_TYPES = [
{ value: 'panels', label: 'Panneaux / Matériaux' },
{ value: 'hardware', label: 'Quincaillerie' },
{ value: 'handles', label: 'Poignées' },
{ value: 'other', label: 'Autre' },
];
const EMPTY_SUPPLIER = { code: '', name: '', email: '', type: 'panels', active: 1 };
const EMPTY_RULE = { sort_order: 9999, chapter: '', title: '', details: '' };
export default function Settings() {
const { isAdmin } = useAuth();
const navigate = useNavigate();
// App settings state
const [settings, setSettings] = useState({});
const [settingsDraft, setSettingsDraft] = useState({});
const [settingsSaving, setSettingsSaving] = useState(false);
const [settingsMsg, setSettingsMsg] = useState(null); // { ok: bool, text: string }
// Suppliers state
const [suppliers, setSuppliers] = useState([]);
const [editingSupplier, setEditingSupplier] = useState(null); // null | supplier object | 'new'
const [supplierDraft, setSupplierDraft] = useState(EMPTY_SUPPLIER);
const [supplierSaving, setSupplierSaving] = useState(false);
const [supplierMsg, setSupplierMsg] = useState(null);
const [deleteConfirm, setDeleteConfirm] = useState(null); // supplier id to confirm delete
// Engineering rules state
const [engineeringRules, setEngineeringRules] = useState([]);
const [editingRule, setEditingRule] = useState(null); // null | rule object | 'new'
const [ruleDraft, setRuleDraft] = useState(EMPTY_RULE);
const [ruleSaving, setRuleSaving] = useState(false);
const [ruleMsg, setRuleMsg] = useState(null);
const [ruleDeleteConfirm, setRuleDeleteConfirm] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
if (!isAdmin) {
navigate('/login?next=/parametres', { replace: true });
return;
}
Promise.all([fetchSettings(), fetchSuppliers(), fetchEngineeringRules()])
.then(([s, sup, rules]) => {
setSettings(s);
setSettingsDraft(s);
setSuppliers(sup);
setEngineeringRules(rules);
})
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [isAdmin, navigate]);
if (!isAdmin) return null;
// ─── App Settings ──────────────────────────────────────────────────────────
function handleSettingChange(key, value) {
setSettingsDraft(prev => ({ ...prev, [key]: value }));
}
async function handleSettingsSave(e) {
e.preventDefault();
setSettingsSaving(true);
setSettingsMsg(null);
try {
const saved = await updateSettings(settingsDraft);
setSettings(saved);
setSettingsDraft(saved);
setSettingsMsg({ ok: true, text: 'Paramètres sauvegardés.' });
} catch (err) {
setSettingsMsg({ ok: false, text: err.message });
} finally {
setSettingsSaving(false);
setTimeout(() => setSettingsMsg(null), 4000);
}
}
function handleSettingsReset() {
setSettingsDraft(settings);
setSettingsMsg(null);
}
// ─── Suppliers ─────────────────────────────────────────────────────────────
function openNewSupplier() {
setEditingSupplier('new');
setSupplierDraft(EMPTY_SUPPLIER);
setSupplierMsg(null);
}
function openEditSupplier(supplier) {
setEditingSupplier(supplier);
setSupplierDraft({ ...supplier });
setSupplierMsg(null);
}
function cancelSupplierEdit() {
setEditingSupplier(null);
setSupplierDraft(EMPTY_SUPPLIER);
setSupplierMsg(null);
}
function handleSupplierDraftChange(field, value) {
setSupplierDraft(prev => ({ ...prev, [field]: value }));
}
async function handleSupplierSave(e) {
e.preventDefault();
setSupplierSaving(true);
setSupplierMsg(null);
try {
if (editingSupplier === 'new') {
const created = await createSupplier(supplierDraft);
setSuppliers(prev => [...prev, created]);
} else {
const updated = await updateSupplier(editingSupplier.id, supplierDraft);
setSuppliers(prev => prev.map(s => s.id === updated.id ? updated : s));
}
setEditingSupplier(null);
setSupplierDraft(EMPTY_SUPPLIER);
setSupplierMsg({ ok: true, text: 'Fournisseur sauvegardé.' });
setTimeout(() => setSupplierMsg(null), 3000);
} catch (err) {
setSupplierMsg({ ok: false, text: err.message });
} finally {
setSupplierSaving(false);
}
}
async function handleSupplierDelete(id) {
try {
await deleteSupplier(id);
setSuppliers(prev => prev.filter(s => s.id !== id));
setDeleteConfirm(null);
} catch (err) {
setSupplierMsg({ ok: false, text: err.message });
}
}
const typeLabel = v => SUPPLIER_TYPES.find(t => t.value === v)?.label ?? v;
// ─── Engineering Rules ─────────────────────────────────────────────────────
function openNewRule() {
setEditingRule('new');
setRuleDraft(EMPTY_RULE);
setRuleMsg(null);
}
function openEditRule(rule) {
setEditingRule(rule);
setRuleDraft({
sort_order: rule.sort_order ?? 9999,
chapter: rule.chapter ?? '',
title: rule.title ?? '',
details: rule.details ?? ''
});
setRuleMsg(null);
}
function cancelRuleEdit() {
setEditingRule(null);
setRuleDraft(EMPTY_RULE);
setRuleMsg(null);
}
function handleRuleDraftChange(field, value) {
setRuleDraft(prev => ({ ...prev, [field]: value }));
}
async function handleRuleSave(e) {
e.preventDefault();
setRuleSaving(true);
setRuleMsg(null);
try {
const payload = {
sort_order: Number(ruleDraft.sort_order),
chapter: ruleDraft.chapter,
title: ruleDraft.title,
details: ruleDraft.details
};
if (editingRule === 'new') {
const created = await createEngineeringRule(payload);
setEngineeringRules(prev =>
[...prev, created].sort((a, b) => (a.sort_order - b.sort_order) || (a.id - b.id))
);
} else {
const updated = await updateEngineeringRule(editingRule.id, payload);
setEngineeringRules(prev =>
prev
.map(r => r.id === updated.id ? updated : r)
.sort((a, b) => (a.sort_order - b.sort_order) || (a.id - b.id))
);
}
setEditingRule(null);
setRuleDraft(EMPTY_RULE);
setRuleMsg({ ok: true, text: 'Règle sauvegardée.' });
setTimeout(() => setRuleMsg(null), 3000);
} catch (err) {
setRuleMsg({ ok: false, text: err.message });
} finally {
setRuleSaving(false);
}
}
async function handleRuleDelete(id) {
try {
await deleteEngineeringRule(id);
setEngineeringRules(prev => prev.filter(r => r.id !== id));
setRuleDeleteConfirm(null);
} catch (err) {
setRuleMsg({ ok: false, text: err.message });
}
}
return (
<div className={styles.page}>
<div className={styles.header}>
<h1>Paramètres</h1>
</div>
{loading && <p className={styles.muted}>Chargement…</p>}
{error && <p className={styles.error}>{error}</p>}
{!loading && !error && (
<>
{/* ── App Settings ─────────────────────────────────────── */}
<section className={styles.section}>
<h2>Paramètres généraux</h2>
<form onSubmit={handleSettingsSave} className={styles.settingsForm}>
<div className={styles.settingsGrid}>
{SETTINGS_META.map(({ key, label, type, min, step, unit }) => (
<div className={styles.field} key={key}>
<label className={styles.label} htmlFor={`setting-${key}`}>
{label}
{unit && <span className={styles.unit}>{unit}</span>}
</label>
<input
id={`setting-${key}`}
className={styles.input}
type={type}
min={min}
step={step}
value={settingsDraft[key] ?? ''}
onChange={e => handleSettingChange(key, e.target.value)}
/>
</div>
))}
</div>
<div className={styles.formActions}>
<button type="submit" className={styles.btnPrimary} disabled={settingsSaving}>
{settingsSaving ? 'Sauvegarde…' : 'Sauvegarder'}
</button>
<button type="button" className={styles.btnGhost} onClick={handleSettingsReset}>
Annuler
</button>
{settingsMsg && (
<span className={settingsMsg.ok ? styles.success : styles.error}>
{settingsMsg.text}
</span>
)}
</div>
</form>
</section>
{/* ── Engineering Rules ───────────────────────────────────── */}
<section className={styles.section}>
<div className={styles.sectionHeader}>
<h2>Règles APEX 4.0 (ingénierie, ergonomie, légalité)</h2>
<button type="button" className={styles.btnPrimary} onClick={openNewRule}>
+ Nouvelle règle
</button>
</div>
<p className={styles.muted}>
Liste de référence triée par ordre. Chaque règle est modifiable pour ajuster les contraintes du configurateur.
</p>
{ruleMsg && !editingRule && (
<p className={ruleMsg.ok ? styles.success : styles.error}>{ruleMsg.text}</p>
)}
{editingRule && (
<form onSubmit={handleRuleSave} className={styles.supplierForm}>
<h3>{editingRule === 'new' ? 'Nouvelle règle' : `Modifier — ${editingRule.title}`}</h3>
<div className={styles.ruleFormGrid}>
<div className={styles.field}>
<label className={styles.label}>Ordre *</label>
<input
className={styles.input}
type="number"
required
value={ruleDraft.sort_order}
onChange={e => handleRuleDraftChange('sort_order', e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Chapitre *</label>
<input
className={styles.input}
required
placeholder="ex. 5.C"
value={ruleDraft.chapter}
onChange={e => handleRuleDraftChange('chapter', e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Titre *</label>
<input
className={styles.input}
required
placeholder="Titre court de la règle"
value={ruleDraft.title}
onChange={e => handleRuleDraftChange('title', e.target.value)}
/>
</div>
<div className={styles.fieldWide}>
<label className={styles.label}>Détails *</label>
<textarea
className={styles.textarea}
required
rows={6}
placeholder="Contenu détaillé de la règle"
value={ruleDraft.details}
onChange={e => handleRuleDraftChange('details', e.target.value)}
/>
</div>
</div>
<div className={styles.formActions}>
<button type="submit" className={styles.btnPrimary} disabled={ruleSaving}>
{ruleSaving ? 'Sauvegarde…' : 'Sauvegarder'}
</button>
<button type="button" className={styles.btnGhost} onClick={cancelRuleEdit}>
Annuler
</button>
{ruleMsg && (
<span className={ruleMsg.ok ? styles.success : styles.error}>
{ruleMsg.text}
</span>
)}
</div>
</form>
)}
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Ordre</th>
<th>Chapitre</th>
<th>Titre</th>
<th>Détails</th>
<th></th>
</tr>
</thead>
<tbody>
{engineeringRules.length === 0 ? (
<tr>
<td colSpan={5} className={styles.empty}>Aucune règle.</td>
</tr>
) : (
engineeringRules.map(rule => (
<tr key={rule.id} className={editingRule?.id === rule.id ? styles.rowEditing : ''}>
<td>{rule.sort_order}</td>
<td><code>{rule.chapter}</code></td>
<td>{rule.title}</td>
<td className={styles.ruleDetailsCell}>{rule.details}</td>
<td className={styles.actions}>
{ruleDeleteConfirm === rule.id ? (
<>
<span className={styles.confirmText}>Supprimer&nbsp;?</span>
<button
type="button"
className={styles.btnDanger}
onClick={() => handleRuleDelete(rule.id)}
>Oui</button>
<button
type="button"
className={styles.btnGhost}
onClick={() => setRuleDeleteConfirm(null)}
>Non</button>
</>
) : (
<>
<button
type="button"
className={styles.btnGhost}
onClick={() => openEditRule(rule)}
>Modifier</button>
<button
type="button"
className={styles.btnDangerGhost}
onClick={() => setRuleDeleteConfirm(rule.id)}
>Supprimer</button>
</>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
{/* ── SMTP / Email info ─────────────────────────────────── */}
<section className={styles.section}>
<h2>Configuration email (SMTP)</h2>
<p className={styles.muted}>
Ces valeurs sont définies dans le fichier <code>.env</code> du serveur. Redémarrez le serveur après modification.
</p>
<div className={styles.smtpGrid}>
{[
{ var: 'SMTP_HOST', desc: 'Serveur SMTP', example: 'smtp.gmail.com' },
{ var: 'SMTP_PORT', desc: 'Port', example: '587' },
{ var: 'SMTP_USER', desc: 'Utilisateur / adresse expéditeur', example: '[email protected]' },
{ var: 'SMTP_PASS', desc: 'Mot de passe / App Password', example: '••••••••••••' },
{ var: 'SMTP_FROM', desc: 'Nom et adresse affichés', example: '"Apex Configurateur <[email protected]>"' },
].map(({ var: v, desc, example }) => (
<div key={v} className={styles.smtpRow}>
<code className={styles.smtpVar}>{v}</code>
<span className={styles.smtpDesc}>{desc}</span>
<span className={styles.smtpExample}>{example}</span>
</div>
))}
</div>
</section>
{/* ── Suppliers ─────────────────────────────────────────── */}
<section className={styles.section}>
<div className={styles.sectionHeader}>
<h2>Fournisseurs</h2>
<button type="button" className={styles.btnPrimary} onClick={openNewSupplier}>
+ Nouveau fournisseur
</button>
</div>
{supplierMsg && !editingSupplier && (
<p className={supplierMsg.ok ? styles.success : styles.error}>{supplierMsg.text}</p>
)}
{/* Supplier form (inline) */}
{editingSupplier && (
<form onSubmit={handleSupplierSave} className={styles.supplierForm}>
<h3>{editingSupplier === 'new' ? 'Nouveau fournisseur' : `Modifier — ${editingSupplier.name}`}</h3>
<div className={styles.supplierFormGrid}>
<div className={styles.field}>
<label className={styles.label}>Code *</label>
<input
className={styles.input}
required
placeholder="ex. formex"
value={supplierDraft.code}
onChange={e => handleSupplierDraftChange('code', e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Nom *</label>
<input
className={styles.input}
required
placeholder="ex. Formex SA"
value={supplierDraft.name}
onChange={e => handleSupplierDraftChange('name', e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Email commandes *</label>
<input
className={styles.input}
type="email"
required
placeholder="[email protected]"
value={supplierDraft.email}
onChange={e => handleSupplierDraftChange('email', e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Type *</label>
<select
className={styles.input}
value={supplierDraft.type}
onChange={e => handleSupplierDraftChange('type', e.target.value)}
>
{SUPPLIER_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div className={styles.field}>
<label className={styles.label}>Statut</label>
<select
className={styles.input}
value={supplierDraft.active}
onChange={e => handleSupplierDraftChange('active', Number(e.target.value))}
>
<option value={1}>Actif</option>
<option value={0}>Inactif</option>
</select>
</div>
</div>
<div className={styles.formActions}>
<button type="submit" className={styles.btnPrimary} disabled={supplierSaving}>
{supplierSaving ? 'Sauvegarde…' : 'Sauvegarder'}
</button>
<button type="button" className={styles.btnGhost} onClick={cancelSupplierEdit}>
Annuler
</button>
{supplierMsg && (
<span className={supplierMsg.ok ? styles.success : styles.error}>
{supplierMsg.text}
</span>
)}
</div>
</form>
)}
{/* Suppliers table */}
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Code</th>
<th>Nom</th>
<th>Email commandes</th>
<th>Type</th>
<th>Statut</th>
<th></th>
</tr>
</thead>
<tbody>
{suppliers.length === 0 ? (
<tr>
<td colSpan={6} className={styles.empty}>Aucun fournisseur.</td>
</tr>
) : (
suppliers.map(s => (
<tr key={s.id} className={editingSupplier?.id === s.id ? styles.rowEditing : ''}>
<td><code>{s.code}</code></td>
<td>{s.name}</td>
<td>{s.email}</td>
<td>{typeLabel(s.type)}</td>
<td>
<span className={s.active ? styles.badgeActive : styles.badgeInactive}>
{s.active ? 'Actif' : 'Inactif'}
</span>
</td>
<td className={styles.actions}>
{deleteConfirm === s.id ? (
<>
<span className={styles.confirmText}>Supprimer&nbsp;?</span>
<button
type="button"
className={styles.btnDanger}
onClick={() => handleSupplierDelete(s.id)}
>Oui</button>
<button
type="button"
className={styles.btnGhost}
onClick={() => setDeleteConfirm(null)}
>Non</button>
</>
) : (
<>
<button
type="button"
className={styles.btnGhost}
onClick={() => openEditSupplier(s)}
>Modifier</button>
<button
type="button"
className={styles.btnDangerGhost}
onClick={() => setDeleteConfirm(s.id)}
>Supprimer</button>
</>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</>
)}
</div>
);
}
+384
View File
@@ -0,0 +1,384 @@
.page {
padding: 1.5rem 0 4rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
}
.header h1 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: var(--primary);
}
.muted {
color: var(--text-muted);
font-size: 0.9375rem;
margin: 0 0 1rem;
}
.error {
color: #b91c1c;
font-size: 0.9375rem;
margin: 0 0 1rem;
}
.success {
color: #16a34a;
font-size: 0.9375rem;
}
/* ── Sections ─────────────────────────────────── */
.section {
margin-bottom: 3rem;
}
.section h2 {
font-size: 1.125rem;
font-weight: 600;
color: var(--primary);
margin: 0 0 1.25rem;
}
.section h3 {
font-size: 0.9375rem;
font-weight: 600;
color: var(--primary);
margin: 0 0 1rem;
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
}
.sectionHeader h2 {
margin: 0;
}
/* ── Settings Form ────────────────────────────── */
.settingsForm {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.5rem;
}
.settingsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1.25rem 2rem;
margin-bottom: 1.5rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.label {
font-size: 0.875rem;
font-weight: 500;
color: var(--primary);
display: flex;
align-items: baseline;
gap: 0.375rem;
}
.unit {
font-weight: 400;
font-size: 0.75rem;
color: var(--text-muted);
}
.input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.9375rem;
color: var(--primary);
background: #fff;
transition: border-color 0.15s;
width: 100%;
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--accent);
}
/* ── SMTP Info Grid ───────────────────────────── */
.smtpGrid {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
margin-top: 0.75rem;
}
.smtpRow {
display: grid;
grid-template-columns: 160px 1fr 1fr;
align-items: center;
gap: 1rem;
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.875rem;
}
.smtpRow:last-child {
border-bottom: none;
}
.smtpRow:nth-child(odd) {
background: var(--bg);
}
.smtpVar {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.8125rem;
color: var(--accent);
background: transparent;
}
.smtpDesc {
color: var(--primary);
font-weight: 500;
}
.smtpExample {
color: var(--text-muted);
font-style: italic;
}
/* ── Supplier Form ────────────────────────────── */
.supplierForm {
background: var(--bg);
border: 1px solid var(--accent);
border-radius: 10px;
padding: 1.25rem 1.5rem;
margin-bottom: 1.25rem;
}
.supplierFormGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem 2rem;
margin-bottom: 1rem;
}
.ruleFormGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem 2rem;
margin-bottom: 1rem;
}
.fieldWide {
display: flex;
flex-direction: column;
gap: 0.375rem;
grid-column: 1 / -1;
}
.textarea {
padding: 0.625rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.9375rem;
line-height: 1.4;
color: var(--primary);
background: #fff;
resize: vertical;
min-height: 120px;
box-sizing: border-box;
}
.textarea:focus {
outline: none;
border-color: var(--accent);
}
/* ── Form Actions ─────────────────────────────── */
.formActions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
/* ── Buttons ──────────────────────────────────── */
.btnPrimary {
padding: 0.5625rem 1.25rem;
background: var(--accent);
border: none;
border-radius: 6px;
color: #fff;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.btnPrimary:hover:not(:disabled) {
background: var(--primary);
}
.btnPrimary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btnGhost {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
font-size: 0.9375rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
white-space: nowrap;
}
.btnGhost:hover {
border-color: var(--accent);
color: var(--primary);
}
.btnDanger {
padding: 0.4375rem 0.875rem;
background: #b91c1c;
border: none;
border-radius: 6px;
color: #fff;
font-size: 0.875rem;
cursor: pointer;
white-space: nowrap;
}
.btnDanger:hover {
background: #991b1b;
}
.btnDangerGhost {
padding: 0.4375rem 0.875rem;
background: transparent;
border: 1px solid #fca5a5;
border-radius: 6px;
color: #b91c1c;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.btnDangerGhost:hover {
background: #fef2f2;
}
/* ── Suppliers Table ──────────────────────────── */
.tableWrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 8px;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.table th,
.table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.table th {
background: var(--bg);
font-weight: 600;
color: var(--primary);
white-space: nowrap;
}
.table tbody tr:last-child td {
border-bottom: none;
}
.table tbody tr:hover {
background: var(--bg);
}
.rowEditing {
background: #eff6ff !important;
}
.empty {
color: var(--text-muted);
text-align: center;
padding: 2rem;
}
.actions {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: flex-end;
flex-wrap: nowrap;
white-space: nowrap;
}
.confirmText {
font-size: 0.8125rem;
color: #b91c1c;
font-weight: 500;
}
.ruleDetailsCell {
white-space: pre-wrap;
min-width: 360px;
}
/* ── Status Badges ────────────────────────────── */
.badgeActive {
display: inline-block;
padding: 0.2rem 0.6rem;
background: #dcfce7;
color: #15803d;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.badgeInactive {
display: inline-block;
padding: 0.2rem 0.6rem;
background: #f1f5f9;
color: var(--text-muted);
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
+9
View File
@@ -1,8 +1,17 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@shared': path.resolve(__dirname, '../shared'),
},
},
server: {
port: 5173,
proxy: {
+325 -2
View File
@@ -1,6 +1,329 @@
{
"name": "apex",
"name": "configurateur-armoires",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {}
"packages": {
"": {
"name": "configurateur-armoires",
"version": "1.0.0",
"devDependencies": {
"concurrently": "^9.2.1"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}
+6 -1
View File
@@ -1,15 +1,20 @@
{
"name": "configurateur-armoires",
"version": "1.0.0",
"version": "2.0.0",
"private": true,
"type": "module",
"description": "Configurateur d'armoires sur mesure — SIGNATURE",
"scripts": {
"install:all": "npm install --prefix server && npm install --prefix client",
"db:init": "npm run db:init --prefix server",
"db:seed": "npm run db:seed --prefix server",
"dev": "concurrently -n server,client -c blue,green \"npm run dev --prefix server\" \"npm run dev --prefix client\"",
"server": "npm start --prefix server",
"client": "npm run dev --prefix client",
"build": "npm run build --prefix client",
"start": "npm run build --prefix client && npm start --prefix server"
},
"devDependencies": {
"concurrently": "^9.2.1"
}
}
+17
View File
@@ -2,10 +2,27 @@
# DATABASE_PATH=./data/configurateur.db
PORT=3001
# Formex account credentials (required for price scraping)
FORMEX_EMAIL=[email protected]
FORMEX_PASSWORD=[email protected]
# Formex sync schedule (cron expression, default: 3:00 AM daily)
#FORMEX_SYNC_CRON=0 3 * * *
# Delay between requests in ms (to avoid rate limiting)
#FORMEX_CRAWL_DELAY_MS=500
# Häfele account credentials (optional; needed if product price requires authentication)
#HAFELE_USERNAME=CHxxxxxxx
#HAFELE_PASSWORD=your-password
# Optional explicit customer number (defaults to HAFELE_USERNAME)
#HAFELE_CUSTOMER_NO=CHxxxxxxx
# Delay between Häfele requests in ms
#HAFELE_CRAWL_DELAY_MS=500
# Browser fallback for Cloudflare-protected pages (Playwright)
# Set to 0 to run visible browser, 1 (default) for headless
#HAFELE_BROWSER_HEADLESS=1
# Browser timeout in ms
#HAFELE_BROWSER_TIMEOUT_MS=90000
# OpenAI API Key for room photo analysis (GPT-4 Vision)
OPENAI_API_KEY=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
+50
View File
@@ -7,9 +7,47 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.join(__dirname, '..', 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db');
const schemaPath = path.join(__dirname, 'schema.sql');
let db = null;
/** Création idempotente — bases anciennes sans ces tables (avant migration 014). */
const HARDWARE_CATALOG_DDL = `
CREATE TABLE IF NOT EXISTS blum_hardware (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
variant TEXT,
price_chf REAL NOT NULL,
specs_json TEXT,
image_url TEXT,
active INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS handle_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
style TEXT,
length_mm INTEGER,
price_chf REAL NOT NULL,
image_url TEXT,
active INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS hinge_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
brand TEXT DEFAULT 'Blum',
price_chf REAL NOT NULL,
active INTEGER DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_blum_hardware_type ON blum_hardware(type);
CREATE INDEX IF NOT EXISTS idx_handle_variants_style ON handle_variants(style);
CREATE INDEX IF NOT EXISTS idx_hinge_variants_type ON hinge_variants(type);
`;
function toSqlitePlaceholders(sql) {
return sql.replace(/\$(\d+)/g, '?');
}
@@ -31,6 +69,18 @@ async function getDb() {
buffer = fs.readFileSync(dbPath);
}
db = new SQL.Database(buffer || null);
try {
// Ensure full schema exists for legacy/partial DB files.
if (fs.existsSync(schemaPath)) {
const schemaSql = fs.readFileSync(schemaPath, 'utf8');
db.exec(schemaSql);
}
db.exec(HARDWARE_CATALOG_DDL);
const data = db.export();
fs.writeFileSync(dbPath, Buffer.from(data));
} catch (e) {
console.error('[db] ensureHardwareCatalogTables:', e.message);
}
return db;
}
@@ -0,0 +1,2 @@
-- Add price_chf to formex_dimensions so each size variant can have its own price
ALTER TABLE formex_dimensions ADD COLUMN price_chf REAL;
+16
View File
@@ -0,0 +1,16 @@
-- Migration 007: Suppliers table for order voucher emails
CREATE TABLE IF NOT EXISTS suppliers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
type TEXT NOT NULL, -- 'panels', 'hardware', 'handles', 'other'
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
-- Seed default suppliers
INSERT OR IGNORE INTO suppliers (code, name, email, type) VALUES
('formex', 'Formex', '[email protected]', 'panels'),
('blum', 'Blum Switzerland', '[email protected]', 'hardware');
+14
View File
@@ -91,6 +91,7 @@ CREATE TABLE IF NOT EXISTS formex_dimensions (
width_mm INTEGER NOT NULL,
height_mm INTEGER NOT NULL,
thickness_mm INTEGER NOT NULL,
price_chf REAL,
is_default INTEGER DEFAULT 0
);
@@ -200,3 +201,16 @@ CREATE INDEX IF NOT EXISTS idx_blum_hardware_type ON blum_hardware(type);
CREATE INDEX IF NOT EXISTS idx_handle_variants_style ON handle_variants(style);
CREATE INDEX IF NOT EXISTS idx_hinge_variants_type ON hinge_variants(type);
CREATE INDEX IF NOT EXISTS idx_element_colors_order ON element_colors(order_id);
-- Exports de plans/debitage enregistrés par commande
CREATE TABLE IF NOT EXISTS plan_exports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
export_type TEXT NOT NULL,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
mime_type TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_plan_exports_order ON plan_exports(order_id);
+233 -5
View File
@@ -4,7 +4,7 @@ import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import cors from 'cors';
import { dbReady } from './db/index.js';
import { dbReady, run, query } from './db/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.join(__dirname, '.env') });
@@ -12,12 +12,32 @@ import { getProducts } from './routes/products.js';
import { getBlockTypes } from './routes/blockTypes.js';
import { getFinishes } from './routes/finishes.js';
import { getServiceOptions } from './routes/serviceOptions.js';
import { getSettings } from './routes/settings.js';
import { getSettings, updateSettings } from './routes/settings.js';
import {
ensureEngineeringRulesSeed,
getEngineeringRules,
createEngineeringRule,
updateEngineeringRule,
deleteEngineeringRule
} from './routes/engineeringRules.js';
import { postPrice } from './routes/price.js';
import { getOrders, postOrder } from './routes/orders.js';
import {
getOrders,
postOrder,
getOrderSupplierVouchers,
listOrderPlanExports,
createOrderPlanExport,
downloadOrderPlanExport,
generateOrderPlanExports,
getOrderProductionDetails,
deleteOrderPlanExport
} from './routes/orders.js';
import { getSuppliers, createSupplier, updateSupplier, deleteSupplier } from './routes/suppliers.js';
import {
triggerFormexSync,
streamFormexSync,
triggerHaefeleSync,
streamHaefeleSync,
getLogs,
getStatus,
getFormexMaterials,
@@ -26,7 +46,9 @@ import {
updateFormexMaterial,
deleteFormexMaterial,
getFormexCollections,
getFormexCategories
getFormexCategories,
getFormexSuppliers,
getHaefeleMaterials
} from './routes/sync.js';
import {
uploadMiddleware,
@@ -53,7 +75,11 @@ import {
generatePlansRoute,
generateCuttingListRoute,
exportCuttingListText,
exportPDFHtml
exportPDFHtml,
exportCncRoute,
exportMprRoute,
exportMprPieceRoute,
generateHardwareListRoute
} from './routes/plans.js';
import {
designTipsRoute,
@@ -76,13 +102,33 @@ app.get('/api/block-types', getBlockTypes);
app.get('/api/finishes', getFinishes);
app.get('/api/service-options', getServiceOptions);
app.get('/api/settings', getSettings);
app.put('/api/settings', updateSettings);
app.get('/api/engineering-rules', getEngineeringRules);
app.post('/api/engineering-rules', createEngineeringRule);
app.put('/api/engineering-rules/:id', updateEngineeringRule);
app.delete('/api/engineering-rules/:id', deleteEngineeringRule);
app.post('/api/price', postPrice);
app.get('/api/orders', getOrders);
app.post('/api/orders', postOrder);
app.get('/api/orders/:id/supplier-vouchers', getOrderSupplierVouchers);
app.get('/api/orders/:id/plan-exports', listOrderPlanExports);
app.post('/api/orders/:id/plan-exports', createOrderPlanExport);
app.post('/api/orders/:id/plan-exports/generate', generateOrderPlanExports);
app.get('/api/orders/:id/plan-exports/:exportId/download', downloadOrderPlanExport);
app.delete('/api/orders/:id/plan-exports/:exportId', deleteOrderPlanExport);
app.get('/api/orders/:id/production-details', getOrderProductionDetails);
// Supplier routes
app.get('/api/suppliers', getSuppliers);
app.post('/api/suppliers', createSupplier);
app.put('/api/suppliers/:id', updateSupplier);
app.delete('/api/suppliers/:id', deleteSupplier);
// Sync routes (Formex crawler)
app.post('/api/sync/formex', triggerFormexSync);
app.get('/api/sync/formex/stream', streamFormexSync);
app.post('/api/sync/haefele', triggerHaefeleSync);
app.get('/api/sync/haefele/stream', streamHaefeleSync);
app.get('/api/sync/logs', getLogs);
app.get('/api/sync/status', getStatus);
@@ -94,6 +140,8 @@ app.put('/api/formex/materials/:id', updateFormexMaterial);
app.delete('/api/formex/materials/:id', deleteFormexMaterial);
app.get('/api/formex/collections', getFormexCollections);
app.get('/api/formex/categories', getFormexCategories);
app.get('/api/formex/suppliers', getFormexSuppliers);
app.get('/api/haefele/materials', getHaefeleMaterials);
// Room analysis routes (AI-powered)
app.post('/api/room/analyze', uploadMiddleware, analyzeRoom);
@@ -125,8 +173,12 @@ app.post('/api/ai/render', renderRoute);
// Plans and cutting list routes
app.post('/api/plans/generate', generatePlansRoute);
app.post('/api/plans/cutting-list', generateCuttingListRoute);
app.post('/api/plans/hardware-list', generateHardwareListRoute);
app.post('/api/plans/export-text', exportCuttingListText);
app.post('/api/plans/export-pdf', exportPDFHtml);
app.post('/api/plans/export-cnc', exportCncRoute);
app.post('/api/plans/export-mpr', exportMprRoute);
app.post('/api/plans/export-mpr-piece', exportMprPieceRoute);
if (hasClientBuild) {
app.use(express.static(clientDist));
@@ -152,6 +204,182 @@ if (hasClientBuild) {
await dbReady;
// Apply migration 007: suppliers table + supplier_id columns
async function applyMigration007() {
try {
await run(`CREATE TABLE IF NOT EXISTS suppliers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
type TEXT NOT NULL,
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
)`);
// Add supplier_id to formex_materials if missing
const fmCols = (await query("PRAGMA table_info(formex_materials)")).rows;
if (!fmCols.find(c => c.name === 'supplier_id')) {
await run('ALTER TABLE formex_materials ADD COLUMN supplier_id INTEGER REFERENCES suppliers(id)');
}
// Add supplier_id to blum_hardware if missing
const bhCols = (await query("PRAGMA table_info(blum_hardware)")).rows;
if (!bhCols.find(c => c.name === 'supplier_id')) {
await run('ALTER TABLE blum_hardware ADD COLUMN supplier_id INTEGER REFERENCES suppliers(id)');
}
// Add supplier_id to handle_variants if missing
const hvCols = (await query("PRAGMA table_info(handle_variants)")).rows;
if (!hvCols.find(c => c.name === 'supplier_id')) {
await run('ALTER TABLE handle_variants ADD COLUMN supplier_id INTEGER REFERENCES suppliers(id)');
}
// Add supplier_id to hinge_variants if missing
const hingeCols = (await query("PRAGMA table_info(hinge_variants)")).rows;
if (!hingeCols.find(c => c.name === 'supplier_id')) {
await run('ALTER TABLE hinge_variants ADD COLUMN supplier_id INTEGER REFERENCES suppliers(id)');
}
// Seed default suppliers
await run(`INSERT OR IGNORE INTO suppliers (code, name, email, type) VALUES
('formex', 'Formex', '[email protected]', 'panels'),
('blum', 'Blum Switzerland', '[email protected]', 'hardware'),
('haefele', 'Hafele', '[email protected]', 'materials')`);
// 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 blum_hardware SET supplier_id = (SELECT id FROM suppliers WHERE code = 'blum') WHERE supplier_id IS NULL`);
await run(`UPDATE handle_variants SET supplier_id = (SELECT id FROM suppliers WHERE code = 'blum') WHERE supplier_id IS NULL`);
await run(`UPDATE hinge_variants SET supplier_id = (SELECT id FROM suppliers WHERE code = 'blum') WHERE supplier_id IS NULL`);
console.log('[migration-007] Suppliers table ready.');
} catch (err) {
console.error('[migration-007] Error:', err.message);
}
}
await applyMigration007();
// Apply migration 008: price_unit column on formex_materials
async function applyMigration008() {
try {
const cols = (await query("PRAGMA table_info(formex_materials)")).rows;
if (!cols.find(c => c.name === 'price_unit')) {
await run('ALTER TABLE formex_materials ADD COLUMN price_unit TEXT');
console.log('[migration-008] Added price_unit column to formex_materials.');
}
} catch (err) {
console.error('[migration-008] Error:', err.message);
}
}
await applyMigration008();
// Apply migration 009: persist generated plan exports
async function applyMigration009() {
try {
await run(`CREATE TABLE IF NOT EXISTS plan_exports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
export_type TEXT NOT NULL,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
mime_type TEXT,
created_at TEXT DEFAULT (datetime('now'))
)`);
await run('CREATE INDEX IF NOT EXISTS idx_plan_exports_order ON plan_exports(order_id)');
console.log('[migration-009] Plan exports table ready.');
} catch (err) {
console.error('[migration-009] Error:', err.message);
}
}
await applyMigration009();
async function applyMigration010() {
try {
const cols = (await query("PRAGMA table_info(formex_materials)")).rows;
if (!cols.find(c => c.name === 'bore_diameter_mm')) {
await run('ALTER TABLE formex_materials ADD COLUMN bore_diameter_mm REAL');
console.log('[migration-010] Added bore_diameter_mm to formex_materials.');
}
} catch (err) {
console.error('[migration-010] Error:', err.message);
}
}
await applyMigration010();
async function applyMigration011() {
try {
const cols = (await query("PRAGMA table_info(formex_materials)")).rows;
const colNames = cols.map(c => c.name);
if (!colNames.includes('bore_depth_base_mm')) {
await run('ALTER TABLE formex_materials ADD COLUMN bore_depth_base_mm REAL');
console.log('[migration-011] Added bore_depth_base_mm to formex_materials.');
}
if (!colNames.includes('bore_depth_piece_mm')) {
await run('ALTER TABLE formex_materials ADD COLUMN bore_depth_piece_mm REAL');
console.log('[migration-011] Added bore_depth_piece_mm to formex_materials.');
}
} catch (err) {
console.error('[migration-011] Error:', err.message);
}
}
await applyMigration011();
async function applyMigration012() {
try {
const cols = (await query("PRAGMA table_info(formex_materials)")).rows;
const colNames = cols.map(c => c.name);
if (!colNames.includes('diameter_mm')) {
await run('ALTER TABLE formex_materials ADD COLUMN diameter_mm REAL');
console.log('[migration-012] Added diameter_mm to formex_materials.');
}
if (!colNames.includes('length_mm')) {
await run('ALTER TABLE formex_materials ADD COLUMN length_mm REAL');
console.log('[migration-012] Added length_mm to formex_materials.');
}
} catch (err) {
console.error('[migration-012] Error:', err.message);
}
}
await applyMigration012();
async function applyMigration013() {
try {
await run(`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)`);
const defaults = [
['allow_sliding_doors', '0'],
['sanitary_gap_mm', '30'],
['drawer_depth_min_mm', '400'],
['drawer_depth_max_mm', '650'],
['drawer_depth_step_mm', '50'],
['drawer_back_gap_mm', '10'],
['drawer_hidden_handle_reduction_mm', '40'],
['standard_handle_axis_mm', '1000'],
['standard_handle_tolerance_mm', '200'],
['groove_width_mm', '9'],
['groove_depth_mm', '10.5'],
['groove_edge_offset_mm', '16']
];
for (const [key, value] of defaults) {
await run('INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2)', [key, value]);
}
console.log('[migration-013] Rule settings defaults ready.');
} catch (err) {
console.error('[migration-013] Error:', err.message);
}
}
await applyMigration013();
await ensureEngineeringRulesSeed();
// Initialize the scheduler for automated sync
initScheduler();
+266
View File
@@ -12,12 +12,25 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"jszip": "^3.10.1",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"nodemailer": "^8.0.1",
"openai": "^4.28.0",
"pdfkit": "^0.17.2",
"playwright": "^1.58.2",
"sql.js": "^1.11.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
@@ -92,6 +105,26 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -122,6 +155,15 @@
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -219,6 +261,15 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -305,6 +356,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@@ -370,6 +427,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -618,6 +681,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -636,6 +705,23 @@
"node": ">= 0.8"
}
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -689,6 +775,20 @@
"node": ">= 0.6"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -858,6 +958,12 @@
"node": ">=0.10.0"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -879,6 +985,59 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jpeg-exif": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1055,6 +1214,15 @@
}
}
},
"node_modules/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -1130,6 +1298,12 @@
}
}
},
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -1194,6 +1368,54 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pdfkit": {
"version": "0.17.2",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
"license": "MIT",
"dependencies": {
"crypto-js": "^4.2.0",
"fontkit": "^2.0.4",
"jpeg-exif": "^1.1.4",
"linebreak": "^1.1.0",
"png-js": "^1.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/png-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -1273,6 +1495,12 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1344,6 +1572,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -1460,6 +1694,12 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1475,6 +1715,12 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1509,6 +1755,26 @@
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "configurateur-armoires-server",
"version": "1.0.0",
"version": "2.0.0",
"description": "API Node/Express pour le configurateur d'armoires sur mesure",
"type": "module",
"main": "index.js",
@@ -16,9 +16,13 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"jszip": "^3.10.1",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"nodemailer": "^8.0.1",
"openai": "^4.28.0",
"pdfkit": "^0.17.2",
"playwright": "^1.58.2",
"sql.js": "^1.11.0"
}
}
+4 -3
View File
@@ -29,15 +29,16 @@ export async function designTipsRoute(req, res) {
/**
* POST /api/ai/recommend-materials
* Body: { config, finishes: [{ id, name, collection }], roomAnalysis? }
* Body: { config, finishes: [{ id, name, collection, price_chf? }], roomAnalysis?, priorities? }
* priorities: { price: 1-5, quality: 1-5, durability: 1-5, aesthetics: 1-5 }
*/
export async function recommendMaterialsRoute(req, res) {
try {
const { config, finishes, roomAnalysis } = req.body;
const { config, finishes, roomAnalysis, priorities } = req.body;
if (!config || !finishes) {
return res.status(400).json({ error: 'Missing config or finishes in body' });
}
const result = await recommendMaterials(config, finishes, roomAnalysis);
const result = await recommendMaterials(config, finishes, roomAnalysis, priorities);
res.json(result);
} catch (err) {
console.error('[AI recommend-materials]', err.message);
+384
View File
@@ -0,0 +1,384 @@
import pool, { run } from '../db/index.js';
const DEFAULT_ENGINEERING_RULES = [
{
sort_order: 10,
chapter: '1.A',
title: "Hiérarchie d'assemblage (Côtés filants vs chapeau)",
details:
"Si hauteur >= 1200 mm: montage en Côtés filants (montants verticaux enferment fond et dessus).\n" +
"Si hauteur < 1200 mm: montage en Chapeau (panneau dessus recouvre les montants)."
},
{
sort_order: 20,
chapter: '1.B',
title: 'Sens du fil du bois (variable sens_du_fil)',
details:
"Le matériau doit porter une variable booléenne sens_du_fil.\n" +
'Forcé vertical: portes, montants, joues.\n' +
'Forcé horizontal: tiroirs, tablettes/rayons, fonds/dessus.'
},
{
sort_order: 30,
chapter: '2.A',
title: 'Portée étagères anti-flexion',
details:
"Rayon libre mélaminé 19 mm <= 1200 mm.\n" +
"Si > 1200 mm: forcer une séparation verticale OU imposer épaisseur 25 mm."
},
{
sort_order: 40,
chapter: '2.A',
title: 'Portes et gauchissement',
details:
'Porte monobloc <= 2500 mm (hauteur) et <= 1000 mm (largeur).\n' +
'Au-delà: imposer automatiquement des tendeurs de redressement encastrés.'
},
{
sort_order: 50,
chapter: '2.A',
title: 'Socle et passage de pieds',
details:
"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' +
"Avec plinthe 19 mm: centre pied Axilo à 69 mm des bords avant/arrière."
},
{
sort_order: 60,
chapter: '2.B',
title: "Tolérance jeu périphérique des façades",
details: 'Façade (porte/tiroir) plus petite de 1.5 mm à 2 mm sur chaque bord.'
},
{
sort_order: 70,
chapter: '2.B',
title: "Tolérance jeu entre façades (fuge)",
details: 'Jeu entre deux portes/tiroirs: 2 mm à 3 mm.'
},
{
sort_order: 80,
chapter: '2.B',
title: 'Jeu au sol porte descendante',
details: 'Sans socle visible: déduire 10 mm à 15 mm en bas de la hauteur de porte.'
},
{
sort_order: 90,
chapter: '2.C',
title: 'Surcote de tablettage (doublage)',
details:
'Prévoir du gras pour ajustement mur.\n' +
'Profondeur = ép. porte 19 + jeu butée 3 + profondeur caisse + surcote tablettage 20.'
},
{
sort_order: 100,
chapter: '3',
title: 'Placage chants - déduction CNC',
details: 'La machine doit soustraire 2 mm par chant appliqué sur la coupe brute.'
},
{
sort_order: 110,
chapter: '3',
title: 'Placage chants - côté caisse / porte / face tiroir / plinthe',
details: '4 chants. Ordre plaqueuse: 2 largeurs puis 2 longueurs.'
},
{
sort_order: 120,
chapter: '3',
title: 'Placage chants - fonds et dessus structure',
details: '2 chants: longueurs avant et arrière.'
},
{
sort_order: 130,
chapter: '3',
title: 'Placage chants - rayons et séparations',
details: '1 chant: bord avant.'
},
{
sort_order: 140,
chapter: '3',
title: 'Placage chants - joues de doublage',
details: '3 chants: 2 largeurs puis chant avant sur toute la hauteur.'
},
{
sort_order: 150,
chapter: '3',
title: "Placage chants - dessus d'habillage (chapeau)",
details:
'3 chants: 2 largeurs puis chant avant sur la longueur.\n' +
"Coupe à 45° avec petit plat 2x2 mm sur l'angle extérieur."
},
{
sort_order: 160,
chapter: '3',
title: 'Pièces sans chants',
details: 'Dos 8 mm et fonds de tiroirs: 0 chant.'
},
{
sort_order: 170,
chapter: '4.A',
title: 'Mode accessibilité SIA 500',
details:
'Option UI "Conception SIA 500".\n' +
"Si activée: verrouiller la hauteur des éléments interactifs entre 800 mm et 1100 mm du sol."
},
{
sort_order: 180,
chapter: '4.B',
title: 'Penderie - profondeur cintres',
details:
'Profondeur intérieure utile >= 600 mm (meuble total env. 647 mm).\n' +
'Si profondeur < 400 mm: désactiver le bouton "Ajouter penderie".'
},
{
sort_order: 190,
chapter: '4.B',
title: 'Penderie - hauteurs libres',
details: 'Vestes courtes >= 1100 mm. Manteaux longs >= 1600 mm.'
},
{
sort_order: 200,
chapter: '4.C',
title: 'Tiroirs intérieurs - hauteur maximale',
details: "Interdire un module tiroir au-delà de 1400 mm du sol (ligne d'épaule)."
},
{
sort_order: 210,
chapter: '4.C',
title: 'Tiroirs intérieurs - hauteurs de façades',
details: 'Variantes autorisées: 60, 90, 128, 177, 240 mm.'
},
{
sort_order: 220,
chapter: '4.C',
title: 'Calepinage tiroirs',
details: 'H façade = [H ouverture - (nb jeux * dimension jeu)] / nb tiroirs.'
},
{
sort_order: 230,
chapter: '4.C',
title: "Tiroirs à l'anglaise (cales de compensation)",
details:
"Si tiroir derrière porte à charnières: générer une cale (ép. 25 mm x prof. int., hauteur = hauteur bloc tiroirs).\n" +
"La cale doit être dans la même couleur que l'intérieur."
},
{
sort_order: 240,
chapter: '5.A',
title: 'Mode cuisine - standards de largeur',
details:
'Snapping des largeurs sur multiples de 60 cm (EN) ou 55 cm (SMS).\n' +
'Meubles bas: caisse 720/780 mm, plan fini 860-920 mm.\n' +
'Meubles hauts: profondeur 350 mm, crédence 500-600 mm (650 mm gaz), hauteurs 360/480/600/720/900 mm.\n' +
'Colonnes alignées aux meubles hauts: 2070/2150/2330 mm.'
},
{
sort_order: 250,
chapter: '5.B',
title: 'Meubles hauts suspendus - quincaillerie et usinages',
details:
'Ferrure droite 290.54.900 et gauche 290.54.901.\n' +
'Entaille haute: 120x12 mm, prof. 25 mm, à 50 mm depuis le haut (bord arrière).\n' +
'Entaille basse: 120x12 mm, prof. 68 mm, à 50 mm du bord arrière.\n' +
'Perçages réglage face intérieure: axe 110 mm; +22 mm et -10 mm; profondeur 10 mm; à 60 mm du bord arrière.'
},
{
sort_order: 260,
chapter: '5.C',
title: 'Gestion thermique fours/frigos - règle absolue du dos',
details:
'Ne jamais générer de dos 8 mm ni rainure derrière four/réfrigérateur.\n' +
"Prévoir un vide arrière d'au moins 50 mm."
},
{
sort_order: 270,
chapter: '5.C',
title: 'Aération réfrigérateurs',
details:
'Plinthe >= 100 mm pour la grille.\n' +
'Générer 1 grille basse (plinthe) + 1 grille haute (bandeau), découpe 457x92 mm.\n' +
'Références: Inox 959.10.000, Blanc 959.10.001, Pin 959.10.002, Acajou 959.10.003, Noyer 959.10.004.'
},
{
sort_order: 280,
chapter: '5.C',
title: 'Porte frigo - règle logistique',
details:
"Ne pas générer ni facturer les accessoires de liaison porte armoire/porte frigo (fournis avec l'électroménager)."
},
{
sort_order: 290,
chapter: '5.D',
title: 'Dimensions de niches appareils',
details:
'Fours EN 600: standard 590/560/550, compact 450/560/550.\n' +
'Fours SMS 550: standard 762/512/550, compact 381 ou 508 / 512 / 550.\n' +
'Doubles fours: séparation fixe 19 mm obligatoire.\n' +
'Plaque cuisson: protéger le tiroir inférieur avec panneau horizontal + vide ventilé 20 mm.\n' +
"Sous-évier: dos partiel (traverses 150 mm haut/bas) + option fond de protection aluminium."
},
{
sort_order: 300,
chapter: '6.A',
title: 'Système 32 - axe charnière à 103 mm',
details:
"Pour réversibilité: centre embase à 105 mm, déduction jeu façade 2 mm => axe perçage porte à 103 mm du haut/bas.\n" +
'Répartition intermédiaire: arrondir au multiple de 32 mm.'
},
{
sort_order: 310,
chapter: '6.B',
title: 'Matrice quantité charnières (épaisseur 19 mm)',
details:
'<=900 mm ou <=6 kg: 2 charnières.\n' +
'901-1600 mm ou <=17 kg: 3.\n' +
'1601-2000 mm ou <=20 kg: 4.\n' +
'2001-2400 mm ou <=22 kg: 5.\n' +
'2401-2600 mm: 6.'
},
{
sort_order: 320,
chapter: '7.A',
title: 'Sécurité LRFP - risque de basculement',
details:
"Si hauteur > 3 x profondeur: générer la ferrure murale obligatoire (Réf 988.98.010) et lever une alerte UI."
},
{
sort_order: 330,
chapter: '7.A',
title: 'Éclairage LED (NIN)',
details:
'Le transformateur ne doit pas être enfermé derrière le dos.\n' +
'Usinage profilés LED: rainure 14x9 mm + perçage traversant Ø8 mm en extrémité.'
},
{
sort_order: 340,
chapter: '7.A',
title: 'Accès technique et pièces humides',
details:
'Option "caisson amovible": découpe fenêtre dans le dos 8 mm selon cotes.\n' +
'Pièces humides: imposer colle PU étanche pour chants ABS et électricité IP44.'
},
{
sort_order: 350,
chapter: '7.B',
title: 'E-commerce Suisse - conformité légale',
details:
'OChim: matériaux codés E1/CARB2 + badge "Swiss Quality / Faible émission E1".\n' +
'ODoB: stocker Nom commercial, Essence (nom latin), Pays, Label et afficher sur facture.\n' +
'OIP: affichage prix TTC 8.1% en direct.\n' +
'TAR: si électronique incluse, afficher "TVA et TAR incluses".\n' +
'LCD: afficher Impressum complet en pied de page.\n' +
'PCI-DSS: passerelle certifiée (Datatrans/Twint/Stripe), aucune donnée bancaire hébergée par APEX.'
}
];
async function ensureEngineeringRulesTable() {
await run(`CREATE TABLE IF NOT EXISTS engineering_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sort_order INTEGER NOT NULL,
chapter TEXT NOT NULL,
title TEXT NOT NULL,
details TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)`);
}
export async function ensureEngineeringRulesSeed() {
await ensureEngineeringRulesTable();
const { rows } = await pool.query('SELECT COUNT(*) AS total FROM engineering_rules');
const total = Number(rows?.[0]?.total || 0);
if (total > 0) return;
for (const rule of DEFAULT_ENGINEERING_RULES) {
await run(
'INSERT INTO engineering_rules (sort_order, chapter, title, details) VALUES (?, ?, ?, ?)',
[rule.sort_order, rule.chapter, rule.title, rule.details]
);
}
}
export async function getEngineeringRules(req, res) {
try {
await ensureEngineeringRulesSeed();
const { rows } = await pool.query(
'SELECT id, sort_order, chapter, title, details, created_at, updated_at FROM engineering_rules ORDER BY sort_order ASC, id ASC'
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function createEngineeringRule(req, res) {
try {
await ensureEngineeringRulesTable();
const { sort_order, chapter, title, details } = req.body || {};
if (!chapter || !title || !details) {
return res.status(400).json({ error: 'chapter, title et details sont requis' });
}
const normalizedSort = Number.isFinite(Number(sort_order)) ? Number(sort_order) : 9999;
await run(
'INSERT INTO engineering_rules (sort_order, chapter, title, details) VALUES (?, ?, ?, ?)',
[normalizedSort, String(chapter).trim(), String(title).trim(), String(details).trim()]
);
const { rows } = await pool.query(
'SELECT id, sort_order, chapter, title, details, created_at, updated_at FROM engineering_rules ORDER BY id DESC LIMIT 1'
);
res.status(201).json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function updateEngineeringRule(req, res) {
try {
await ensureEngineeringRulesTable();
const { id } = req.params;
const { sort_order, chapter, title, details } = req.body || {};
const existing = await pool.query('SELECT id FROM engineering_rules WHERE id = ?', [id]);
if (existing.rows.length === 0) {
return res.status(404).json({ error: 'Règle non trouvée' });
}
if (!chapter || !title || !details) {
return res.status(400).json({ error: 'chapter, title et details sont requis' });
}
const normalizedSort = Number.isFinite(Number(sort_order)) ? Number(sort_order) : 9999;
await run(
`UPDATE engineering_rules
SET sort_order = ?, chapter = ?, title = ?, details = ?, updated_at = datetime('now')
WHERE id = ?`,
[normalizedSort, String(chapter).trim(), String(title).trim(), String(details).trim(), id]
);
const { rows } = await pool.query(
'SELECT id, sort_order, chapter, title, details, created_at, updated_at FROM engineering_rules WHERE id = ?',
[id]
);
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function deleteEngineeringRule(req, res) {
try {
await ensureEngineeringRulesTable();
const { id } = req.params;
const existing = await pool.query('SELECT id FROM engineering_rules WHERE id = ?', [id]);
if (existing.rows.length === 0) {
return res.status(404).json({ error: 'Règle non trouvée' });
}
await run('DELETE FROM engineering_rules WHERE id = ?', [id]);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+322 -1
View File
@@ -1,4 +1,59 @@
import pool, { run } from '../db/index.js';
import { sendSupplierVouchers, buildSupplierVoucherPayload } from '../services/supplierVoucher.js';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { generateCuttingList, formatCuttingListForExport } from '../services/cuttingEngine.js';
import { generatePackingList } from '../services/packingEngine.js';
import { generateAllPlans } from '../services/planGenerator.js';
import { generateOrderDossierPdf } from '../services/exportPdf.js';
import { validateConfig } from '../services/configValidation.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const planExportsRoot = path.join(__dirname, '..', 'data', 'plan-exports');
function ensurePlanExportDir(orderId) {
const dir = path.join(planExportsRoot, `order-${orderId}`);
fs.mkdirSync(dir, { recursive: true });
return dir;
}
async function saveOrderPlanExportRecord(orderId, exportType, content, mimeType = 'text/plain', fileExtension = 'txt') {
const safeExt = (fileExtension || 'txt').toLowerCase().replace(/[^a-z0-9]/g, '') || 'txt';
const safeType = String(exportType || '').toLowerCase().replace(/[^a-z0-9_-]/g, '');
if (!safeType) throw new Error('exportType invalide.');
if (!(typeof content === 'string' || Buffer.isBuffer(content))) throw new Error('content requis.');
if ((typeof content === 'string' && content.length === 0) || (Buffer.isBuffer(content) && content.length === 0)) {
throw new Error('content requis.');
}
const exportDir = ensurePlanExportDir(orderId);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const fileName = `${stamp}-${safeType}.${safeExt}`;
const absolutePath = path.join(exportDir, fileName);
const relativePath = path.join('plan-exports', `order-${orderId}`, fileName).replace(/\\/g, '/');
if (Buffer.isBuffer(content)) {
fs.writeFileSync(absolutePath, content);
} else {
fs.writeFileSync(absolutePath, content, 'utf8');
}
await run(
`INSERT INTO plan_exports (order_id, export_type, file_name, file_path, mime_type)
VALUES ($1, $2, $3, $4, $5)`,
[orderId, safeType, fileName, relativePath, mimeType || 'text/plain']
);
const { rows } = await pool.query(
`SELECT id, order_id, export_type, file_name, mime_type, created_at
FROM plan_exports
WHERE order_id = $1
ORDER BY id DESC
LIMIT 1`,
[orderId]
);
return rows[0];
}
export async function getOrders(req, res) {
try {
@@ -14,14 +69,280 @@ export async function getOrders(req, res) {
export async function postOrder(req, res) {
try {
const { config, totalChf, email, name, phone, notes } = req.body || {};
const settingsRows = await pool.query('SELECT key, value FROM settings');
const settings = Object.fromEntries((settingsRows.rows || []).map((r) => [r.key, r.value]));
const validation = validateConfig(config || {}, settings);
if (validation.errors.length > 0) {
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
}
await run(
`INSERT INTO orders (config, total_chf, email, name, phone, notes)
VALUES ($1, $2, $3, $4, $5, $6)`,
[JSON.stringify(config || {}), totalChf ?? null, email ?? null, name ?? null, phone ?? null, notes ?? null]
);
const { rows } = await pool.query('SELECT id, created_at FROM orders ORDER BY id DESC LIMIT 1');
res.status(201).json(rows[0]);
const order = rows[0];
// Fire-and-forget: send supplier vouchers without blocking the HTTP response
sendSupplierVouchers(order.id, config || {}, name ?? '', order.created_at).catch(err => {
console.error('[supplier-voucher] Email error:', err.message);
});
res.status(201).json(order);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function getOrderSupplierVouchers(req, res) {
try {
const { id } = req.params;
const { rows } = await pool.query(
'SELECT id, config, name, created_at FROM orders WHERE id = $1 LIMIT 1',
[id]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Commande introuvable.' });
}
const order = rows[0];
let config = {};
try {
config = typeof order.config === 'string' ? JSON.parse(order.config) : (order.config || {});
} catch {
config = {};
}
const suppliers = await buildSupplierVoucherPayload(config);
return res.json({
orderId: order.id,
customerName: order.name || null,
createdAt: order.created_at,
suppliers
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
export async function listOrderPlanExports(req, res) {
try {
const { id } = req.params;
const { rows: orderRows } = await pool.query('SELECT id FROM orders WHERE id = $1 LIMIT 1', [id]);
if (orderRows.length === 0) {
return res.status(404).json({ error: 'Commande introuvable.' });
}
const { rows } = await pool.query(
`SELECT id, order_id, export_type, file_name, mime_type, created_at
FROM plan_exports
WHERE order_id = $1
ORDER BY created_at DESC, id DESC`,
[id]
);
return res.json(rows);
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
export async function createOrderPlanExport(req, res) {
try {
const { id } = req.params;
const { exportType, content, mimeType, fileExtension } = req.body || {};
if (!exportType || typeof exportType !== 'string') {
return res.status(400).json({ error: 'exportType requis.' });
}
if (typeof content !== 'string' || content.length === 0) {
return res.status(400).json({ error: 'content requis.' });
}
const { rows: orderRows } = await pool.query('SELECT id FROM orders WHERE id = $1 LIMIT 1', [id]);
if (orderRows.length === 0) {
return res.status(404).json({ error: 'Commande introuvable.' });
}
const created = await saveOrderPlanExportRecord(id, exportType, content, mimeType, fileExtension);
return res.status(201).json(created);
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
export async function generateOrderPlanExports(req, res) {
try {
const { id } = req.params;
const { rows } = await pool.query(
'SELECT id, config FROM orders WHERE id = $1 LIMIT 1',
[id]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Commande introuvable.' });
}
let config = {};
try {
config = typeof rows[0].config === 'string' ? JSON.parse(rows[0].config) : (rows[0].config || {});
} catch {
config = {};
}
if (!config || typeof config !== 'object' || !config.widthMm || !config.heightMm || !config.depthMm) {
return res.status(400).json({ error: 'Configuration commande invalide pour générer les plans.' });
}
{
const settingsRows = await pool.query('SELECT key, value FROM settings');
const settings = Object.fromEntries((settingsRows.rows || []).map((r) => [r.key, r.value]));
const validation = validateConfig(config, settings);
if (validation.errors.length > 0) {
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
}
}
const modules = Array.isArray(config.modules) ? config.modules : [];
const colors = (config.colors && typeof config.colors === 'object') ? config.colors : {};
const cuttingList = generateCuttingList(config, colors, modules);
const packing = generatePackingList(cuttingList);
const textContent = formatCuttingListForExport(cuttingList);
const pdfBuffer = await generateOrderDossierPdf({
orderId: id,
config: {
widthMm: config.widthMm,
heightMm: config.heightMm,
depthMm: config.depthMm,
installationType: config.installationType,
doorType: config.doorType,
moduleCount: modules.length
},
cuttingList,
packing
});
const [textExport, pdfExport] = await Promise.all([
saveOrderPlanExportRecord(id, 'cutting_list_text', textContent, 'text/plain; charset=utf-8', 'txt'),
saveOrderPlanExportRecord(id, 'production_dossier_pdf', pdfBuffer, 'application/pdf', 'pdf')
]);
return res.status(201).json({ created: [textExport, pdfExport] });
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
export async function getOrderProductionDetails(req, res) {
try {
const { id } = req.params;
const { rows } = await pool.query(
'SELECT id, config, total_chf, name, created_at FROM orders WHERE id = $1 LIMIT 1',
[id]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Commande introuvable.' });
}
let config = {};
try {
config = typeof rows[0].config === 'string' ? JSON.parse(rows[0].config) : (rows[0].config || {});
} catch {
config = {};
}
if (!config || typeof config !== 'object' || !config.widthMm || !config.heightMm || !config.depthMm) {
return res.status(400).json({ error: 'Configuration commande invalide pour générer le dossier.' });
}
{
const settingsRows = await pool.query('SELECT key, value FROM settings');
const settings = Object.fromEntries((settingsRows.rows || []).map((r) => [r.key, r.value]));
const validation = validateConfig(config, settings);
if (validation.errors.length > 0) {
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
}
}
const modules = Array.isArray(config.modules) ? config.modules : [];
const colors = (config.colors && typeof config.colors === 'object') ? config.colors : {};
const plans = generateAllPlans(config, modules, colors);
const cuttingList = generateCuttingList(config, colors, modules);
const packing = generatePackingList(cuttingList);
return res.json({
orderId: rows[0].id,
customerName: rows[0].name || null,
totalChf: rows[0].total_chf,
createdAt: rows[0].created_at,
config: {
widthMm: config.widthMm,
heightMm: config.heightMm,
depthMm: config.depthMm,
plinthHeightMm: config.plinthHeightMm,
installationType: config.installationType,
doorType: config.doorType,
slidingPanelsCount: config.slidingPanelsCount,
moduleCount: modules.length
},
plans,
cuttingList,
packing
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
export async function downloadOrderPlanExport(req, res) {
try {
const { id, exportId } = req.params;
const disposition = String(req.query.disposition || 'attachment').toLowerCase();
const headerDisposition = disposition === 'inline' ? 'inline' : 'attachment';
const { rows } = await pool.query(
`SELECT id, order_id, file_name, file_path, mime_type
FROM plan_exports
WHERE id = $1 AND order_id = $2
LIMIT 1`,
[exportId, id]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Export introuvable.' });
}
const record = rows[0];
const absolutePath = path.join(__dirname, '..', 'data', record.file_path);
if (!fs.existsSync(absolutePath)) {
return res.status(404).json({ error: 'Fichier export introuvable.' });
}
res.setHeader('Content-Type', record.mime_type || 'application/octet-stream');
res.setHeader('Content-Disposition', `${headerDisposition}; filename="${record.file_name}"`);
return res.sendFile(absolutePath);
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
export async function deleteOrderPlanExport(req, res) {
try {
const { id, exportId } = req.params;
const { rows } = await pool.query(
`SELECT id, order_id, file_path
FROM plan_exports
WHERE id = $1 AND order_id = $2
LIMIT 1`,
[exportId, id]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Export introuvable.' });
}
const record = rows[0];
const absolutePath = path.join(__dirname, '..', 'data', record.file_path);
if (fs.existsSync(absolutePath)) {
fs.unlinkSync(absolutePath);
}
await run('DELETE FROM plan_exports WHERE id = $1 AND order_id = $2', [exportId, id]);
return res.status(204).end();
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
+176 -6
View File
@@ -1,8 +1,13 @@
/**
* Plans and cutting list routes
* Plans, cutting list, and packing routes
*/
import { generateAllPlans, generatePDFHtml } from '../services/planGenerator.js';
import { generateCuttingList, formatCuttingListForExport } from '../services/cuttingEngine.js';
import { generatePackingList } from '../services/packingEngine.js';
import { generateCncProgram } from '../services/cncGenerator.js';
import { validateConfig } from '../services/configValidation.js';
import { generateHardwareList } from '../services/hardwareListEngine.js';
import { generateMprZipBuffer, generateMprPieceDownload } from '../services/mprGenerator.js';
/**
* POST /api/plans/generate
@@ -15,9 +20,13 @@ export async function generatePlansRoute(req, res) {
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 plans = generateAllPlans(config, modules, colors);
res.json({ success: true, plans });
res.json({ success: true, plans, warnings: validation.warnings });
} catch (error) {
console.error('Plan generation error:', error);
@@ -27,7 +36,7 @@ export async function generatePlansRoute(req, res) {
/**
* POST /api/plans/cutting-list
* Generate cutting list for a configuration
* Generate cutting list + packing list for a configuration
*/
export async function generateCuttingListRoute(req, res) {
try {
@@ -36,9 +45,15 @@ export async function generateCuttingListRoute(req, res) {
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);
res.json({ success: true, cuttingList });
const packing = generatePackingList(cuttingList);
res.json({ success: true, cuttingList, packing, warnings: validation.warnings });
} catch (error) {
console.error('Cutting list error:', error);
@@ -57,6 +72,10 @@ export async function exportCuttingListText(req, res) {
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 textContent = formatCuttingListForExport(cuttingList);
@@ -73,7 +92,7 @@ export async function exportCuttingListText(req, res) {
/**
* POST /api/plans/export-pdf
* Export plans and cutting list as HTML (for PDF conversion)
* Export plans, cutting list, and packing as HTML (for PDF conversion)
*/
export async function exportPDFHtml(req, res) {
try {
@@ -82,9 +101,14 @@ export async function exportPDFHtml(req, res) {
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 html = generatePDFHtml(config, modules, cuttingList);
const packing = generatePackingList(cuttingList);
const html = generatePDFHtml(config, modules, cuttingList, packing);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(html);
@@ -94,3 +118,149 @@ export async function exportPDFHtml(req, res) {
res.status(500).json({ error: error.message || 'Erreur d\'export PDF' });
}
}
/**
* POST /api/plans/export-cnc
* Export CNC program as standard ISO G-code (.nc)
*/
export async function exportCncRoute(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 cnc = generateCncProgram(config, modules, colors);
res.setHeader('Content-Type', cnc.mimeType);
res.setHeader('Content-Disposition', `attachment; filename="${cnc.fileName}"`);
res.send(cnc.content);
} catch (error) {
console.error('Export CNC error:', error);
res.status(500).json({ error: error.message || 'Erreur d\'export CNC' });
}
}
/**
* POST /api/plans/export-mpr
* Archive ZIP avec un fichier .mpr Homag/Woodwop par panneau.
*/
export async function exportMprRoute(req, res) {
try {
const { config, modules = [], colors = {}, orderReference = '', stackListKm = '', mprMaterialArticle = '', mprGroupIndex = 1 } = 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 gi = Number(mprGroupIndex);
const groupIndex = Number.isFinite(gi) && gi >= 1 ? Math.floor(gi) : 1;
const { buffer, fileName, mimeType } = await generateMprZipBuffer({
config,
modules,
colors,
orderReference,
stackListKm: typeof stackListKm === 'string' ? stackListKm : String(stackListKm || '01'),
defaultMaterialArticle: typeof mprMaterialArticle === 'string' ? mprMaterialArticle : '',
groupIndex
});
const safeName =
/^[\w\-_.]+$/.test(String(fileName)) ? fileName : 'apex-mpr.zip';
res.setHeader('Content-Type', mimeType);
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
res.send(Buffer.from(buffer));
} catch (error) {
console.error('Export MPR error:', error);
res.status(500).json({ error: error.message || 'Erreur d\'export MPR' });
}
}
/**
* POST /api/plans/export-mpr-piece
* Export MPR pour une ligne de la liste de débitage (fichier unique ou mini-ZIP si plusieurs exemplaires).
*/
export async function exportMprPieceRoute(req, res) {
try {
const {
config,
modules = [],
colors = {},
pieceId,
instanceIndex,
orderReference = '',
stackListKm = '',
mprMaterialArticle = '',
mprGroupIndex = 1
} = req.body || {};
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
if (!pieceId || typeof pieceId !== 'string') {
return res.status(400).json({ error: 'pieceId requis' });
}
const validation = validateConfig(config);
if (validation.errors.length > 0) {
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
}
const gi = Number(mprGroupIndex);
const groupIndex = Number.isFinite(gi) && gi >= 1 ? Math.floor(gi) : 1;
const { buffer, fileName, mimeType } = await generateMprPieceDownload({
config,
modules,
colors,
pieceId,
instanceIndex,
orderReference,
stackListKm: typeof stackListKm === 'string' ? stackListKm : String(stackListKm || '01'),
defaultMaterialArticle: typeof mprMaterialArticle === 'string' ? mprMaterialArticle : '',
groupIndex
});
const safeName = /^[\w\-_.]+$/.test(String(fileName)) ? fileName : 'piece.mpr';
res.setHeader('Content-Type', mimeType);
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
res.send(Buffer.from(buffer));
} catch (error) {
console.error('Export MPR piece error:', error);
const status = /introuvable|requis|invalide/i.test(error.message || '') ? 400 : 500;
res.status(status).json({ error: error.message || 'Erreur d\'export MPR pièce' });
}
}
/**
* POST /api/plans/hardware-list
* Generate bill of materials for hardware/quincaillerie with prices (best-effort).
*/
export async function generateHardwareListRoute(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 result = await generateHardwareList({ config, modules, colors });
res.json({ success: true, ...result, warnings: validation.warnings });
} catch (error) {
console.error('Hardware list error:', error);
res.status(500).json({ error: error.message || 'Erreur de génération de la liste de quincaillerie' });
}
}
+31 -3
View File
@@ -1,13 +1,41 @@
import pool from '../db/index.js';
import { run } from '../db/index.js';
function parseSettingValue(raw) {
if (typeof raw !== 'string') return raw;
if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
if (raw === 'true') return true;
if (raw === 'false') return false;
return raw;
}
export async function getSettings(req, res) {
try {
const { rows } = await pool.query('SELECT key, value FROM settings');
const obj = {};
for (const r of rows) {
let v = r.value;
if (typeof v === 'string' && /^\d+$/.test(v)) v = Number(v);
obj[r.key] = v;
obj[r.key] = parseSettingValue(r.value);
}
res.json(obj);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function updateSettings(req, res) {
try {
const updates = req.body || {};
for (const [key, value] of Object.entries(updates)) {
await run(
'INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
[key, String(value)]
);
}
// Return updated settings
const { rows } = await pool.query('SELECT key, value FROM settings');
const obj = {};
for (const r of rows) {
obj[r.key] = parseSettingValue(r.value);
}
res.json(obj);
} catch (err) {
+55
View File
@@ -0,0 +1,55 @@
import pool, { run } from '../db/index.js';
export async function getSuppliers(req, res) {
try {
const { rows } = await pool.query(
'SELECT id, code, name, email, type, active, created_at FROM suppliers ORDER BY name'
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function createSupplier(req, res) {
try {
const { code, name, email, type, active = 1 } = req.body || {};
if (!code || !name || !email || !type) {
return res.status(400).json({ error: 'code, name, email et type sont requis.' });
}
await run(
'INSERT INTO suppliers (code, name, email, type, active) VALUES ($1, $2, $3, $4, $5)',
[code, name, email, type, active]
);
const { rows } = await pool.query('SELECT * FROM suppliers ORDER BY id DESC LIMIT 1');
res.status(201).json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function updateSupplier(req, res) {
try {
const { id } = req.params;
const { name, email, type, active } = req.body || {};
await run(
'UPDATE suppliers SET name = $1, email = $2, type = $3, active = $4 WHERE id = $5',
[name, email, type, active, id]
);
const { rows } = await pool.query('SELECT * FROM suppliers WHERE id = $1', [id]);
if (rows.length === 0) return res.status(404).json({ error: 'Fournisseur introuvable.' });
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function deleteSupplier(req, res) {
try {
const { id } = req.params;
await run('DELETE FROM suppliers WHERE id = $1', [id]);
res.status(204).end();
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+194 -30
View File
@@ -1,7 +1,5 @@
/**
* Sync routes for Formex crawler
*/
import { sync, getSyncLogs, getMaterials } from '../services/formexCrawler.js';
import { syncHaefeleByUrls } from '../services/haefeleCrawler.js';
import { getSchedulerStatus } from '../services/scheduler.js';
import pool, { run } from '../db/index.js';
@@ -58,9 +56,10 @@ export async function streamFormexSync(req, res) {
// Client disconnected, cleanup if needed
});
const force = req.query.force === '1' || req.query.force === 'true';
try {
// Run sync with emitter
await sync(emit);
await sync(emit, { force });
} catch (err) {
emit({ type: 'error', error: err.message });
}
@@ -69,6 +68,144 @@ export async function streamFormexSync(req, res) {
res.end();
}
/**
* POST /api/sync/haefele
* Trigger Häfele sync for a provided list of product URLs
* Body: { urls: string[] }
*/
export async function triggerHaefeleSync(req, res) {
try {
const urls = Array.isArray(req.body?.urls) ? req.body.urls : [];
const credentials = {
username: req.body?.username,
password: req.body?.password,
customerNo: req.body?.customerNo
};
if (urls.length === 0) {
return res.status(400).json({ error: 'Le champ urls (tableau) est requis.' });
}
const result = await syncHaefeleByUrls(urls, () => {}, { credentials });
if (!result.success) {
return res.status(500).json({
success: false,
error: result.error || 'Sync Häfele échouée',
itemsCount: result.itemsCount || 0
});
}
res.json({
success: true,
message: `Synchronisation Häfele terminée: ${result.itemsCount} produits`,
itemsCount: result.itemsCount,
failedCount: result.failedCount || 0,
duration: result.duration
});
} catch (err) {
res.status(500).json({
success: false,
error: err.message
});
}
}
/**
* GET /api/sync/haefele/stream
* SSE endpoint for Häfele sync progress (URL list from query params)
* Query:
* - url=... (repeatable)
* - urls=... (newline/comma/semicolon separated fallback)
*/
export async function streamHaefeleSync(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 repeatable = req.query.url;
let urls = [];
if (Array.isArray(repeatable)) {
urls = repeatable;
} else if (typeof repeatable === 'string' && repeatable.trim()) {
urls = [repeatable];
} else if (typeof req.query.urls === 'string') {
urls = req.query.urls.split(/[\n,;]+/g);
}
try {
await syncHaefeleByUrls(urls, emit);
} catch (err) {
emit({ type: 'error', error: err.message });
}
res.end();
}
/**
* GET /api/haefele/materials
* List Häfele materials that were fetched/synced.
*/
export async function getHaefeleMaterials(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 = 'haefele') OR collection = 'Hafele' OR formex_code LIKE 'HAF-%')`
];
const params = [];
if (search) {
whereClauses.push('(name LIKE ? OR formex_code LIKE ? OR formex_url 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, category, thickness_mm, price_chf, price_unit, image_url, formex_url, active, last_synced_at
FROM formex_materials
${whereSQL}
ORDER BY last_synced_at DESC, id DESC
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 sync history
@@ -98,47 +235,50 @@ export function getStatus(req, res) {
*/
export async function getFormexMaterials(req, res) {
try {
const { search, collection, category, active, page = '1', limit = '25' } = req.query;
const { search, collection, category, active, supplier, page = '1', limit = '25' } = req.query;
const pageNum = Math.max(1, parseInt(page, 10));
const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10)));
const offset = (pageNum - 1) * limitNum;
// Build query with optional filters
let whereClauses = [];
let params = [];
if (search) {
whereClauses.push('(name LIKE ? OR formex_code LIKE ?)');
whereClauses.push('(m.name LIKE ? OR m.formex_code LIKE ?)');
params.push(`%${search}%`, `%${search}%`);
}
if (collection) {
whereClauses.push('collection = ?');
whereClauses.push('m.collection = ?');
params.push(collection);
}
if (category) {
whereClauses.push('category = ?');
whereClauses.push('m.category = ?');
params.push(category);
}
// For configurator: only active materials
if (active === '1' || active === 'true') {
whereClauses.push('active = 1');
whereClauses.push('m.active = 1');
}
if (supplier) {
whereClauses.push('s.code = ?');
params.push(supplier);
}
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
// Get total count
const countResult = await pool.query(
`SELECT COUNT(*) as total FROM formex_materials ${whereSQL}`,
`SELECT COUNT(*) as total FROM formex_materials m LEFT JOIN suppliers s ON m.supplier_id = s.id ${whereSQL}`,
params
);
const total = countResult.rows[0]?.total || 0;
// Get paginated results
const { rows } = await pool.query(
`SELECT * FROM formex_materials ${whereSQL} ORDER BY collection, name LIMIT ? OFFSET ?`,
`SELECT m.*, s.code AS supplier_code, s.name AS supplier_name
FROM formex_materials m LEFT JOIN suppliers s ON m.supplier_id = s.id
${whereSQL} ORDER BY m.collection, m.name LIMIT ? OFFSET ?`,
[...params, limitNum, offset]
);
@@ -202,14 +342,14 @@ export async function getFormexMaterial(req, res) {
*/
export async function createFormexMaterial(req, res) {
try {
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active } = req.body;
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
diameter_mm, length_mm, bore_diameter_mm, bore_depth_base_mm, bore_depth_piece_mm,
image_url, formex_url, price_chf, price_unit, active } = req.body;
// Validation
if (!formex_code || !name || !collection) {
return res.status(400).json({ error: 'formex_code, name et collection sont requis' });
}
// Check for duplicate code
const existing = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ?',
[formex_code]
@@ -219,13 +359,17 @@ export async function createFormexMaterial(req, res) {
}
await run(
`INSERT INTO formex_materials (formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO formex_materials (formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
diameter_mm, length_mm, bore_diameter_mm, bore_depth_base_mm, bore_depth_piece_mm,
image_url, formex_url, price_chf, price_unit, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[formex_code, name, collection, category || null, width_mm || null, height_mm || null, thickness_mm || null,
image_url || null, formex_url || null, price_chf || null, active !== undefined ? (active ? 1 : 0) : 1]
diameter_mm ?? null, length_mm ?? null,
bore_diameter_mm ?? null, bore_depth_base_mm ?? null, bore_depth_piece_mm ?? null,
image_url || null, formex_url || null, price_chf ?? null, price_unit || null,
active !== undefined ? (active ? 1 : 0) : 1]
);
// Get the created material
const { rows } = await pool.query(
'SELECT * FROM formex_materials WHERE formex_code = ?',
[formex_code]
@@ -244,9 +388,10 @@ export async function createFormexMaterial(req, res) {
export async function updateFormexMaterial(req, res) {
try {
const { id } = req.params;
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active } = req.body;
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
diameter_mm, length_mm, bore_diameter_mm, bore_depth_base_mm, bore_depth_piece_mm,
image_url, formex_url, price_chf, price_unit, active } = req.body;
// Check if material exists
const existing = await pool.query(
'SELECT * FROM formex_materials WHERE id = ?',
[id]
@@ -255,12 +400,10 @@ export async function updateFormexMaterial(req, res) {
return res.status(404).json({ error: 'Matériau non trouvé' });
}
// Validation
if (!formex_code || !name || !collection) {
return res.status(400).json({ error: 'formex_code, name et collection sont requis' });
}
// Check for duplicate code (if changed)
if (formex_code !== existing.rows[0].formex_code) {
const duplicate = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ? AND id != ?',
@@ -274,13 +417,17 @@ export async function updateFormexMaterial(req, res) {
await run(
`UPDATE formex_materials
SET formex_code = ?, name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
image_url = ?, formex_url = ?, price_chf = ?, active = ?
diameter_mm = ?, length_mm = ?,
bore_diameter_mm = ?, bore_depth_base_mm = ?, bore_depth_piece_mm = ?,
image_url = ?, formex_url = ?, price_chf = ?, price_unit = ?, active = ?
WHERE id = ?`,
[formex_code, name, collection, category || null, width_mm || null, height_mm || null, thickness_mm || null,
image_url || null, formex_url || null, price_chf || null, active !== undefined ? (active ? 1 : 0) : 1, id]
diameter_mm ?? null, length_mm ?? null,
bore_diameter_mm ?? null, bore_depth_base_mm ?? null, bore_depth_piece_mm ?? null,
image_url || null, formex_url || null, price_chf ?? null, price_unit || null,
active !== undefined ? (active ? 1 : 0) : 1, id]
);
// Get the updated material
const { rows } = await pool.query(
'SELECT * FROM formex_materials WHERE id = ?',
[id]
@@ -317,6 +464,23 @@ export async function deleteFormexMaterial(req, res) {
}
}
/**
* GET /api/formex/suppliers
* Get suppliers that have at least one material linked
*/
export async function getFormexSuppliers(req, res) {
try {
const { rows } = await pool.query(
`SELECT DISTINCT s.code, s.name FROM suppliers s
INNER JOIN formex_materials m ON m.supplier_id = s.id
ORDER BY s.name`
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* GET /api/formex/collections
* Get distinct collection names for filter dropdown
+24
View File
@@ -0,0 +1,24 @@
import { query } from '../db/index.js';
async function main() {
const tables = (await query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")).rows.map(
(r) => r.name
);
console.log('tables', tables);
const counts = ['formex_materials', 'formex_variants', 'formex_dimensions', 'finishes', 'products', 'suppliers'];
for (const t of counts) {
try {
const r = await query(`SELECT COUNT(*) as c FROM ${t}`);
console.log(t, r.rows[0]?.c);
} catch {
console.log(t, '(missing)');
}
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+38
View File
@@ -0,0 +1,38 @@
import { query } from '../db/index.js';
async function main() {
const countSql = `
SELECT COUNT(*) as c
FROM formex_materials
WHERE (
supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele')
OR collection = 'Hafele'
OR formex_code LIKE 'HAF-%'
)
`;
const count = await query(countSql);
console.log('haefele_count', count.rows[0]?.c ?? 0);
const sampleSql = `
SELECT id, formex_code, name, collection, supplier_id, last_synced_at
FROM formex_materials
WHERE (
supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele')
OR collection = 'Hafele'
OR formex_code LIKE 'HAF-%'
)
ORDER BY last_synced_at DESC, id DESC
LIMIT 10
`;
const sample = await query(sampleSql);
console.log('haefele_sample', sample.rows);
const syncCols = await query("PRAGMA table_info(sync_logs)");
console.log('sync_logs_cols', syncCols.rows.map((c) => c.name));
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+110
View File
@@ -0,0 +1,110 @@
import { generateCuttingList } from './cuttingEngine.js';
import { collectHoleSpecsForPiece } from './holeSpecifications.js';
import { GROOVE, getBackGroovePlanOrientation } from '../../shared/manufacturing.js';
function toSafeNumber(value, fallback = 0) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function toLabel(value) {
return String(value || '')
.replace(/[^a-zA-Z0-9_\-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.toUpperCase();
}
function emitPanelOutline(lines, widthMm, heightMm, safeZ, cutZ, feed) {
lines.push(`G0 Z${safeZ.toFixed(3)}`);
lines.push('G0 X0.000 Y0.000');
lines.push(`G1 Z${cutZ.toFixed(3)} F${Math.round(feed)}`);
lines.push(`G1 X${widthMm.toFixed(3)} Y0.000`);
lines.push(`G1 X${widthMm.toFixed(3)} Y${heightMm.toFixed(3)}`);
lines.push(`G1 X0.000 Y${heightMm.toFixed(3)}`);
lines.push('G1 X0.000 Y0.000');
lines.push(`G0 Z${safeZ.toFixed(3)}`);
}
function emitHole(lines, { xMm, yMm, diameterMm = 5, depthMm = 10, type = 'generic', face = 'face', end = null }) {
let tail = `TYPE=${String(type)} FACE=${face}`;
if (end) tail += ` END=${end}`;
lines.push(
`; HOLE X=${Number(xMm).toFixed(3)} Y=${Number(yMm).toFixed(3)} D=${Number(diameterMm).toFixed(3)} Z=${Number(depthMm).toFixed(3)} ${tail}`
);
}
function emitPieceHoleComments(lines, piece, widthMm, heightMm, partIndex = 0) {
const { specs, thicknessMm: _tm } = collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex);
for (const spec of specs) {
emitHole(lines, spec);
}
const m = piece?.machining || {};
const orient = piece?.elementType ? getBackGroovePlanOrientation(piece.elementType) : null;
if (m.groove && orient) {
const gw = Number(m.groove.width ?? GROOVE.back.width);
const gd = Number(m.groove.depth ?? GROOVE.back.depth);
const ax = Number(m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge);
lines.push(`; GROOVE BACK W=${gw.toFixed(3)} D=${gd.toFixed(3)} AXIS=${ax.toFixed(3)} ORIENT=${orient}`);
}
}
export function generateCncProgram(config, modules = [], colors = {}) {
const cuttingList = generateCuttingList(config, colors, modules);
const safeZ = 10;
const cutDepth = 3;
const feedCut = 1800;
const lines = [
'%',
'; APEX CNC ISO (G-code) - v1',
`; Generated: ${new Date().toISOString()}`,
`; Config: ${config.widthMm}x${config.heightMm}x${config.depthMm} mm`,
'G21 ; metric units',
'G90 ; absolute coordinates',
'G17 ; XY plane',
`G0 Z${safeZ.toFixed(3)}`
];
let programPartCount = 0;
for (const piece of cuttingList.pieces || []) {
const widthMm = toSafeNumber(piece.rawCutWidthMm, piece.widthMm);
const heightMm = toSafeNumber(piece.rawCutHeightMm, piece.heightMm);
const qty = Math.max(1, Math.round(toSafeNumber(piece.quantity, 1)));
const thickness = toSafeNumber(piece.thicknessMm, 19);
const pieceName = toLabel(piece.description || piece.elementType || 'PIECE');
const targetDepth = -Math.min(cutDepth, Math.max(1, thickness * 0.25));
for (let i = 0; i < qty; i++) {
programPartCount += 1;
lines.push('');
lines.push(`; --- PART ${programPartCount}: ${pieceName} #${i + 1} ---`);
lines.push(`; SIZE_RAW: X=${widthMm.toFixed(3)} Y=${heightMm.toFixed(3)} T=${thickness.toFixed(3)}`);
emitPieceHoleComments(lines, piece, widthMm, heightMm, i);
emitPanelOutline(lines, widthMm, heightMm, safeZ, targetDepth, feedCut);
}
}
lines.push('');
lines.push(`G0 Z${safeZ.toFixed(3)}`);
lines.push('G0 X0.000 Y0.000');
lines.push('M30');
lines.push('%');
return {
fileName: 'apex-program.nc',
mimeType: 'text/plain; charset=utf-8',
content: lines.join('\n'),
metadata: {
generatedAt: new Date().toISOString(),
parts: programPartCount
}
};
}
export default {
generateCncProgram
};
+97
View File
@@ -0,0 +1,97 @@
import { DRAWERS, NICHE } from '../../shared/manufacturing.js';
export function validateConfig(config = {}, settings = {}) {
const errors = [];
const warnings = [];
const installationType = config.installationType || 'free';
const doorType = config.doorType || 'hinged';
const widthMm = Number(config.widthMm || 0);
const heightMm = Number(config.heightMm || 0);
const depthMm = Number(config.depthMm || 0);
const modules = Array.isArray(config.modules) ? config.modules : [];
const allowSliding = String(settings.allow_sliding_doors ?? '0') === '1';
const sanitaryGapMm = Number(settings.sanitary_gap_mm || NICHE.sanitaryGapAround);
if (doorType === 'sliding' && !allowSliding) {
errors.push({ field: 'doorType', message: 'Portes coulissantes désactivées.' });
}
if (installationType === 'niche') {
const nm = config.nicheMeasurements || {};
const minW = Math.min(nm.widthTopMm || widthMm, nm.widthMidMm || widthMm, nm.widthBottomMm || widthMm);
const minD = Math.min(nm.depthTopMm || depthMm, nm.depthMidMm || depthMm, nm.depthBottomMm || depthMm);
const minH = Math.min(nm.heightLeftMm || heightMm, nm.heightRightMm || heightMm);
const expectedW = Math.max(400, minW - sanitaryGapMm * 2);
const expectedD = Math.max(400, minD - sanitaryGapMm * 2);
const expectedH = Math.max(1800, minH - sanitaryGapMm * 2);
if (Math.abs(expectedW - widthMm) > 2 || Math.abs(expectedD - depthMm) > 2 || Math.abs(expectedH - heightMm) > 2) {
errors.push({ field: 'nicheMeasurements', message: 'Dimensions niche incohérentes avec les mesures mini et vide sanitaire.' });
}
}
const drawerDepthMin = Number(settings.drawer_depth_min_mm || DRAWERS.minDepth);
const drawerDepthMax = Number(settings.drawer_depth_max_mm || DRAWERS.maxDepth);
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é. */
const inferredEffectiveDrawerDepthMm = () => {
const rawGap = depthMm - NICHE.drawerBackGap;
const clamped = Math.max(drawerDepthMin, Math.min(drawerDepthMax, rawGap));
const steppedMm = Math.round(clamped / drawerDepthStep) * drawerDepthStep;
const rawDt = config.hardware?.drawerType || 'visible';
const depthReductionMm =
rawDt === 'hidden' || rawDt === 'hidden_with_handle' ? DRAWERS.hiddenHandleDepthReduction : 0;
return Math.max(350, steppedMm - depthReductionMm);
};
for (const [index, m] of modules.entries()) {
const isDrawerCol = (m.drawers || 0) > 0 || m.type === 'tiroirs';
if (isDrawerCol && Number(m.widthMm || 0) > DRAWERS.maxColumnWidth) {
errors.push({ field: `modules[${index}]`, message: `Colonne tiroirs > ${DRAWERS.maxColumnWidth} mm.` });
}
if (m.hasRod && heightMm < 1200) {
errors.push({ field: `modules[${index}]`, message: 'Tringle autorisée à partir de 1200 mm de hauteur armoire.' });
}
}
/**
* 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.
*/
const canonicalEffectiveDrawerDepthMm = inferredEffectiveDrawerDepthMm();
const effectiveDrawerMinMm = 350;
if (
canonicalEffectiveDrawerDepthMm < effectiveDrawerMinMm ||
canonicalEffectiveDrawerDepthMm > drawerDepthMax
) {
errors.push({
field: 'effectiveDrawerDepthMm',
message: `Profondeur tiroir utile hors bornes (${effectiveDrawerMinMm}-${drawerDepthMax} mm) pour cette profondeur caisse.`
});
}
const explicitEff = Number(config.effectiveDrawerDepthMm);
if (
Number.isFinite(explicitEff) &&
explicitEff > 0 &&
Math.abs(explicitEff - canonicalEffectiveDrawerDepthMm) > 2
) {
warnings.push({
field: 'effectiveDrawerDepthMm',
message:
`Valeur stockée (${explicitEff} mm) différente du calcul attendu (${canonicalEffectiveDrawerDepthMm} mm) — le serveur utilise le calcul canonique.`
});
}
const handleAxis = Number(config.hardware?.handleBoreHeightMm || 1000);
const stdAxis = Number(settings.standard_handle_axis_mm || 1000);
const tolerance = Number(settings.standard_handle_tolerance_mm || 200);
if (handleAxis < stdAxis - tolerance || handleAxis > stdAxis + tolerance) {
warnings.push({ field: 'handleBoreHeightMm', message: 'Perçage poignée hors plage standard.' });
}
return { errors, warnings };
}
export default { validateConfig };
File diff suppressed because it is too large Load Diff
+89 -18
View File
@@ -64,40 +64,107 @@ export async function getDesignTip(config) {
/* -------------------------------------------------------------------------- */
const MATERIAL_REC_PROMPT = `Tu es un conseiller en matériaux et couleurs pour armoires sur mesure suisses haut de gamme.
À partir de la configuration actuelle et de la liste de matériaux disponibles, recommande 3 à 5 matériaux qui iraient parfaitement ensemble.
À partir de la configuration actuelle, des PRIORITÉS UTILISATEUR (échelle 1–5) et de la liste de matériaux disponibles, recommande 3 à 5 finitions.
Critères de recommandation :
- Harmonie des couleurs avec les matériaux déjà sélectionnés
- Cohérence avec le style d'armoire (dimensions, configuration)
- Tendances actuelles en design d'intérieur suisse
- Si un contexte de pièce est fourni, s'accorder avec le style de la pièce
Règles :
- Les priorités indiquent ce qui compte le PLUS pour le client (5 = très important, 1 = peu important). Tu dois PONDÉRER tes choix en conséquence (ex. : priorité « prix » élevée → privilégier des décors à bon rapport qualité-prix et l'expliquer ; priorité « qualité » élevée → insister sur la noblesse de la finition, la précision du relief, etc.).
- Si un prix indicatif [CHF] est fourni pour un matériau, utilise-le honnêtement pour le critère prix.
- Pour CHAQUE recommandation, explique CLAIREMENT POURQUOI tu proposes CE matériau : lien explicite avec les priorités du client (pas seulement « joli »).
- Remplis les champs criteria avec une phrase courte CHACUN (en français) : ce que ce décor apporte sur ce critère, au regard des priorités.
Réponds UNIQUEMENT en JSON :
Réponds UNIQUEMENT en JSON valide :
{
"recommendations": [
{ "finishId": <number>, "reason": "…raison courte en français…" }
{
"finishId": <number>,
"summary": "2 phrases max : pourquoi ce choix au regard des priorités",
"criteria": {
"price": "phrase sur le rapport prix / gamme / accessibilité",
"quality": "phrase sur la qualité de finition, rendu, sensation",
"durability": "phrase sur tenue dans le temps, entretien, résistance",
"aesthetics": "phrase sur l'harmonie couleur/style avec le projet"
},
"reason": "une ligne très courte pour infobulle (optionnel, peut résumer summary)"
}
]
}`;
function clampPriority(n, fallback = 3) {
const x = Number(n);
if (!Number.isFinite(x)) return fallback;
return Math.min(5, Math.max(1, Math.round(x)));
}
/**
* @param {object} [p] - { price, quality, durability, aesthetics } each 1–5
*/
export function normalizeRecommendationPriorities(p) {
const d = { price: 3, quality: 3, durability: 3, aesthetics: 3 };
if (!p || typeof p !== 'object') return { ...d };
return {
price: clampPriority(p.price, d.price),
quality: clampPriority(p.quality, d.quality),
durability: clampPriority(p.durability, d.durability),
aesthetics: clampPriority(p.aesthetics, d.aesthetics)
};
}
function normalizeMaterialRecommendation(rec) {
if (!rec || rec.finishId == null) return null;
const criteria = rec.criteria && typeof rec.criteria === 'object' ? rec.criteria : {};
const summary = (rec.summary || rec.reason || '').trim();
const reason = (rec.reason || summary).trim();
return {
finishId: Number(rec.finishId),
summary,
reason,
criteria: {
price: String(criteria.price || '').trim(),
quality: String(criteria.quality || '').trim(),
durability: String(criteria.durability || '').trim(),
aesthetics: String(criteria.aesthetics || '').trim()
}
};
}
function buildPrioritiesBlock(priorities) {
const p = normalizeRecommendationPriorities(priorities);
return `PRIORITÉS UTILISATEUR (1 = peu important, 5 = très important — ajuste tes recommandations et tes explications en conséquence) :
- Prix / rapport qualité-prix : ${p.price}
- Qualité perçue & finition : ${p.quality}
- Durabilité & entretien : ${p.durability}
- Esthétique & harmonie avec le projet : ${p.aesthetics}`;
}
/**
* Recommend materials that complement the current configuration
* @param {object} config - Current wardrobe configuration
* @param {Array} availableFinishes - [{ id, name, collection, code }]
* @param {Array} availableFinishes - [{ id, name, collection, code, price_chf? }]
* @param {object} [roomAnalysis] - Optional room photo analysis
* @returns {{ recommendations: Array<{ finishId: number, reason: string }> }}
* @param {object} [priorities] - { price, quality, durability, aesthetics } each 1–5
* @returns {{ recommendations: Array<{ finishId, summary, reason, criteria }> }}
*/
export async function recommendMaterials(config, availableFinishes, roomAnalysis) {
export async function recommendMaterials(config, availableFinishes, roomAnalysis, priorities) {
const client = getOpenAIClient();
const configSummary = buildConfigSummary(config);
const priBlock = buildPrioritiesBlock(priorities);
// Compact finish list (only names + ids to save tokens)
// Compact finish list (names + ids + optional price for fair "prix" explanations)
const finishList = availableFinishes
.slice(0, 80) // limit to avoid token overflow
.map(f => `${f.id}: ${f.name || f.label} (${f.collection || '?'})`)
.map((f) => {
const name = f.name || f.label || '?';
const col = f.collection || '?';
const price =
f.price_chf != null && Number.isFinite(Number(f.price_chf))
? ` — indicatif ${Number(f.price_chf).toFixed(2)} CHF`
: '';
return `${f.id}: ${name} (${col})${price}`;
})
.join('\n');
let userMessage = `CONFIGURATION ACTUELLE:\n${configSummary}\n\nMATÉRIAUX DISPONIBLES:\n${finishList}`;
let userMessage = `${priBlock}\n\nCONFIGURATION ACTUELLE:\n${configSummary}\n\nMATÉRIAUX DISPONIBLES:\n${finishList}`;
if (roomAnalysis) {
userMessage += `\n\nCONTEXTE PIÈCE:\n${JSON.stringify(roomAnalysis, null, 1)}`;
@@ -105,8 +172,8 @@ export async function recommendMaterials(config, availableFinishes, roomAnalysis
const response = await client.chat.completions.create({
model: 'gpt-4o',
temperature: 0.7,
max_tokens: 400,
temperature: 0.65,
max_tokens: 1400,
messages: [
{ role: 'system', content: MATERIAL_REC_PROMPT },
{ role: 'user', content: userMessage }
@@ -117,9 +184,13 @@ export async function recommendMaterials(config, availableFinishes, roomAnalysis
const raw = response.choices[0]?.message?.content;
try {
const parsed = JSON.parse(raw);
return { recommendations: (parsed.recommendations || []).slice(0, 5) };
const rawList = (parsed.recommendations || []).slice(0, 5);
const recommendations = rawList
.map(normalizeMaterialRecommendation)
.filter(Boolean);
return { recommendations, priorities: normalizeRecommendationPriorities(priorities) };
} catch {
return { recommendations: [] };
return { recommendations: [], priorities: normalizeRecommendationPriorities(priorities) };
}
}
+101
View File
@@ -0,0 +1,101 @@
import PDFDocument from 'pdfkit';
function addSectionTitle(doc, title) {
doc.moveDown(0.5);
doc.fontSize(14).font('Helvetica-Bold').text(title);
doc.moveDown(0.3);
doc.fontSize(10).font('Helvetica');
}
function ensurePageSpace(doc, neededHeight = 80) {
if (doc.y + neededHeight > doc.page.height - doc.page.margins.bottom) {
doc.addPage();
}
}
function keyValue(doc, key, value) {
doc.font('Helvetica-Bold').text(`${key}: `, { continued: true });
doc.font('Helvetica').text(String(value ?? '-'));
}
function tableRow(doc, cols, widths, opts = {}) {
const startX = doc.x;
const y = doc.y;
const lineHeight = opts.lineHeight || 14;
let x = startX;
for (let i = 0; i < cols.length; i++) {
const text = cols[i] == null ? '-' : String(cols[i]);
doc.text(text, x, y, {
width: widths[i],
lineBreak: false
});
x += widths[i];
}
doc.y = y + lineHeight;
}
export function generateOrderDossierPdf({ orderId, createdAt, customerName, config, cuttingList, packing }) {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 40 });
const chunks = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
doc.fontSize(18).font('Helvetica-Bold').text(`Dossier technique commande #${orderId}`);
doc.fontSize(10).font('Helvetica').text(`Généré le ${new Date().toLocaleString('fr-CH')}`);
if (createdAt) doc.text(`Commande du ${new Date(createdAt).toLocaleString('fr-CH')}`);
if (customerName) doc.text(`Client: ${customerName}`);
addSectionTitle(doc, 'Configuration');
keyValue(doc, 'Dimensions', `${config.widthMm} x ${config.heightMm} x ${config.depthMm} mm`);
keyValue(doc, 'Installation', config.installationType === 'niche' ? 'Encastree' : 'Libre');
keyValue(doc, 'Portes', config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes');
keyValue(doc, 'Modules', config.moduleCount || 0);
addSectionTitle(doc, 'Liste de decoupage');
const cutWidths = [170, 130, 40, 130];
tableRow(doc, ['Piece', 'L x H x Ep.', 'Qte', 'Finition'], cutWidths, { lineHeight: 16 });
doc.moveTo(doc.x, doc.y).lineTo(doc.page.width - doc.page.margins.right, doc.y).stroke();
doc.moveDown(0.2);
for (const piece of (cuttingList?.pieces || [])) {
ensurePageSpace(doc, 18);
tableRow(
doc,
[
piece.description,
`${piece.widthMm}x${piece.heightMm}x${piece.thicknessMm}`,
piece.quantity,
piece.finishLabel || '-'
],
cutWidths
);
}
addSectionTitle(doc, 'Colisage');
const packWidths = [50, 170, 70, 70];
tableRow(doc, ['Colis', 'Niveau', 'Poids', 'Pieces'], packWidths, { lineHeight: 16 });
doc.moveTo(doc.x, doc.y).lineTo(doc.page.width - doc.page.margins.right, doc.y).stroke();
doc.moveDown(0.2);
for (const box of (packing?.boxes || [])) {
ensurePageSpace(doc, 18);
tableRow(
doc,
[
box.id,
box.level || '-',
`${Number(box.totalWeightKg || 0).toFixed(1)} kg`,
box.pieces?.length || 0
],
packWidths
);
}
doc.end();
});
}
File diff suppressed because it is too large Load Diff
+851
View File
@@ -0,0 +1,851 @@
import * as cheerio from 'cheerio';
import pool, { run } from '../db/index.js';
const BASE_URL = 'https://www.haefele.ch';
const CRAWL_DELAY = parseInt(process.env.HAFELE_CRAWL_DELAY_MS || '500', 10);
const BROWSER_TIMEOUT_MS = parseInt(process.env.HAFELE_BROWSER_TIMEOUT_MS || '90000', 10);
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeSpaces(value) {
return (value || '').replace(/\s+/g, ' ').trim();
}
function absoluteUrl(url) {
if (!url) return null;
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `${BASE_URL}${url.startsWith('/') ? '' : '/'}${url}`;
}
function parsePriceCHF(text) {
const clean = normalizeSpaces(text)
.replace(/[\u00a0\u202f]/g, ' ')
.replace(/[’']/g, '');
const match = clean.match(/([\d]+(?:[.,][\d]+)?)\s*CHF|CHF\s*([\d]+(?:[.,][\d]+)?)/i);
if (!match) return null;
return parseFloat((match[1] || match[2]).replace(',', '.'));
}
function parseFirstMm(text) {
const match = normalizeSpaces(text).match(/(\d+(?:[.,]\d+)?)\s*mm/i);
if (!match) return null;
return parseFloat(match[1].replace(',', '.'));
}
function parseBoreDiameter(text) {
const clean = normalizeSpaces(text);
const patterns = [
/[Øø∅]\s*(\d+(?:[.,]\d+)?)\s*mm/i,
/(?:trou|per[çc]age|diam[eè]tre|boring|bohrung)\s*[:\s]*[Øø∅]?\s*(\d+(?:[.,]\d+)?)\s*mm/i,
/(\d+(?:[.,]\d+)?)\s*mm\s*(?:trou|per[çc]age|boring|bohrung)/i,
];
for (const pat of patterns) {
const m = clean.match(pat);
if (m) {
const val = parseFloat((m[1] || m[2]).replace(',', '.'));
if (val > 0 && val <= 50) return val;
}
}
return null;
}
function parseBoreDepths(specs) {
const baseKeys = [
'profondeur de perçage dans le fond',
'profondeur de perçage fond',
'profondeur perçage fond',
'profondeur percage fond',
'bohrtiefe in boden',
'bohrtiefe boden',
'drilling depth base',
'profondeur de perçage dans la partie inférieure',
];
const pieceKeys = [
'profondeur de perçage dans la pièce',
'profondeur de perçage pièce',
'profondeur perçage pièce',
'profondeur percage piece',
'profondeur de perçage dans le panneau latéral',
'profondeur de perçage dans la pièce fixée',
'bohrtiefe in seitenteil',
'bohrtiefe seitenteil',
'drilling depth piece',
'profondeur de perçage dans la partie latérale',
];
let depthBase = null;
let depthPiece = null;
for (const [key, value] of Object.entries(specs)) {
const k = key.toLowerCase();
if (!depthBase) {
for (const bk of baseKeys) {
if (k.includes(bk)) { depthBase = parseFirstMm(value); break; }
}
}
if (!depthPiece) {
for (const pk of pieceKeys) {
if (k.includes(pk)) { depthPiece = parseFirstMm(value); break; }
}
}
if (depthBase && depthPiece) break;
}
// Fallback: look for a generic "profondeur de perçage" with two values
if (!depthBase && !depthPiece) {
for (const [key, value] of Object.entries(specs)) {
if (key.toLowerCase().includes('profondeur') && key.toLowerCase().includes('per')) {
const allMm = [...value.matchAll(/(\d+(?:[.,]\d+)?)\s*mm/gi)].map(m => parseFloat(m[1].replace(',', '.')));
if (allMm.length >= 2) {
depthBase = allMm[0];
depthPiece = allMm[1];
} else if (allMm.length === 1 && !depthBase) {
depthBase = allMm[0];
}
break;
}
}
}
return { depthBase, depthPiece };
}
function isCloudflareChallenge(html = '') {
const lower = normalizeSpaces(html).toLowerCase();
return lower.includes('just a moment') || lower.includes('cloudflare') || lower.includes('cf-challenge');
}
class HaefeleSession {
constructor() {
this.cookies = new Map();
this.loggedIn = false;
}
captureCookies(response) {
let rawCookies = [];
if (typeof response.headers.getSetCookie === 'function') {
rawCookies = response.headers.getSetCookie();
} else {
const raw = response.headers.get('set-cookie');
if (raw) {
rawCookies = raw.split(/,\s*(?=[A-Za-z0-9_\-]+=)/);
}
}
for (const header of rawCookies) {
const [pair] = header.split(';');
const eqIdx = pair.indexOf('=');
if (eqIdx > 0) {
this.cookies.set(pair.slice(0, eqIdx).trim(), pair.slice(eqIdx + 1).trim());
}
}
}
cookieHeader() {
return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
}
headers(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,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'fr-CH,fr;q=0.9,en;q=0.5',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'sec-ch-ua': '"Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
...(this.cookies.size > 0 ? { Cookie: this.cookieHeader() } : {}),
...extra,
};
}
async login(emit = () => {}, credentials = {}) {
const username = credentials.username || process.env.HAFELE_USERNAME || process.env.HAFELE_USER;
const password = credentials.password || process.env.HAFELE_PASSWORD;
const customerNo = credentials.customerNo || process.env.HAFELE_CUSTOMER_NO || username;
if (!username || !password) {
emit({ type: 'log', message: 'Identifiants Häfele absents (sync publique uniquement).' });
return false;
}
const homeUrl = `${BASE_URL}/fr/`;
const accountStartUrl = `${BASE_URL}/fr/account/start/`;
try {
// 1) Load FR home page (contains header login form + synchronizer token)
const getRes = await fetch(homeUrl, {
headers: this.headers(),
redirect: 'follow',
});
this.captureCookies(getRes);
const html = await getRes.text();
const $ = cheerio.load(html);
// Prefer the known header login form from the provided markup.
let form = $('#loginWithExistingAccountForm').first();
if (!form.length) {
form = $('form').filter((_, el) => {
const id = ($(el).attr('id') || '').toLowerCase();
const action = ($(el).attr('action') || '').toLowerCase();
return id.includes('login') || action.includes('processlogin') || action.includes('/account/login');
}).first();
}
if (!form.length) {
emit({ type: 'log', message: 'Formulaire de login Häfele introuvable, continuation sans session.' });
return false;
}
const action = form.attr('action') || '/fr/account/login/';
const loginAction = absoluteUrl(action);
const payload = new URLSearchParams();
form.find('input').each((_, input) => {
const name = ($(input).attr('name') || '').trim();
const type = (($(input).attr('type') || 'text')).toLowerCase();
const value = ($(input).attr('value') || '').trim();
if (!name) return;
if (type === 'hidden') {
payload.set(name, value);
return;
}
const lowered = name.toLowerCase();
if (lowered.includes('pass')) {
payload.set(name, password);
} else if (lowered.includes('kunden') || lowered.includes('customer')) {
payload.set(name, customerNo);
} else if (lowered.includes('user') || lowered.includes('login') || lowered.includes('mitbenutzer')) {
payload.set(name, username);
}
});
// Explicit Häfele CH header login fields from current markup.
if (!payload.has('ShopLoginForm_Login_headerItemLogin')) {
payload.set('ShopLoginForm_Login_headerItemLogin', customerNo || username);
}
if (!payload.has('ShopLoginForm_Password_headerItemLogin')) {
payload.set('ShopLoginForm_Password_headerItemLogin', password);
}
if (!payload.has('ShopLoginForm_RememberLogin_headerItemLogin')) {
payload.set('ShopLoginForm_RememberLogin_headerItemLogin', 'on');
}
// Optional co-user login flow.
if (username && customerNo && username !== customerNo) {
payload.set('ShopLoginForm_LoginAsJointUser_headerItemLogin', 'on');
if (!payload.has('ShopLoginForm_UserName_headerItemLogin')) {
payload.set('ShopLoginForm_UserName_headerItemLogin', username);
}
}
const postRes = await fetch(loginAction, {
method: 'POST',
headers: this.headers({
'Content-Type': 'application/x-www-form-urlencoded',
Referer: homeUrl,
Origin: BASE_URL,
}),
body: payload.toString(),
redirect: 'follow',
});
this.captureCookies(postRes);
// 2) Verify with account start page (authenticated area)
const checkRes = await fetch(accountStartUrl, {
headers: this.headers(),
redirect: 'follow',
});
this.captureCookies(checkRes);
const finalUrl = checkRes.url || '';
const finalHtml = await checkRes.text();
const pageText = normalizeSpaces(finalHtml).toLowerCase();
const stillLogin =
finalUrl.includes('/account/login') ||
pageText.includes('mot de passe') ||
pageText.includes('passwort') ||
pageText.includes('connexion') && pageText.includes('numéro de client');
this.loggedIn = !stillLogin;
emit({
type: 'log',
message: this.loggedIn
? `Connecté à Häfele (${this.cookies.size} cookies).`
: 'Login Häfele non confirmé, continuation de la sync.',
});
return this.loggedIn;
} catch (err) {
emit({ type: 'log', message: `Erreur login Häfele: ${err.message}` });
return false;
}
}
}
let session = new HaefeleSession();
let currentCredentials = {};
let currentEmit = () => {};
async function waitForCloudflare(page, maxSeconds = 30) {
for (let elapsed = 0; elapsed < maxSeconds; elapsed += 3) {
const title = await page.title().catch(() => '');
const html = await page.content().catch(() => '');
if (!isCloudflareChallenge(html) && !title.toLowerCase().includes('just a moment')) {
return true;
}
currentEmit({ type: 'log', message: ` Cloudflare en cours… (${elapsed}s)` });
await page.waitForTimeout(3000);
}
return false;
}
let browserInstance = null;
let browserContext = null;
async function getBrowserPage() {
if (browserInstance && browserContext) {
return browserContext.newPage();
}
let chromium;
try {
({ chromium } = await import('playwright'));
} catch {
throw new Error('Cloudflare challenge détecté et Playwright indisponible.');
}
const headless = process.env.HAFELE_BROWSER_HEADLESS !== '0';
browserInstance = await chromium.launch({
headless,
args: [
'--disable-blink-features=AutomationControlled',
'--no-sandbox'
]
});
browserContext = await browserInstance.newContext({
locale: 'fr-CH',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
viewport: { width: 1920, height: 1080 }
});
return browserContext.newPage();
}
async function closeBrowser() {
if (browserInstance) {
await browserInstance.close().catch(() => {});
browserInstance = null;
browserContext = null;
}
}
let browserLoggedIn = false;
async function ensureBrowserLogin() {
if (browserLoggedIn) return;
const username = currentCredentials.username || process.env.HAFELE_USERNAME || process.env.HAFELE_USER;
const password = currentCredentials.password || process.env.HAFELE_PASSWORD;
const customerNo = currentCredentials.customerNo || process.env.HAFELE_CUSTOMER_NO || username;
if (!username || !password) {
currentEmit({ type: 'log', message: 'Pas d\'identifiants Häfele, navigation sans login.' });
return;
}
const page = await getBrowserPage();
try {
currentEmit({ type: 'log', message: 'Ouverture de la page de login Häfele…' });
await page.goto(`${BASE_URL}/fr/account/login/`, { waitUntil: 'load', timeout: BROWSER_TIMEOUT_MS });
const cfCleared = await waitForCloudflare(page, 45);
if (!cfCleared) {
currentEmit({ type: 'log', message: 'Cloudflare non résolu sur la page login.' });
const title = await page.title().catch(() => '?');
currentEmit({ type: 'log', message: ` Page titre: "${title}", URL: ${page.url()}` });
return;
}
const currentUrl = page.url();
const title = await page.title().catch(() => '');
currentEmit({ type: 'log', message: `Page login chargée: "${title}" (${currentUrl})` });
// Use exact ID selectors — the form has #ShopLoginForm_Login (text input)
// and #ShopLoginForm_LoginAsJointUser (hidden checkbox). Substring selectors
// like [name*=] would match the checkbox first and break .first().
const customerField = page.locator('#ShopLoginForm_Login');
const pwField = page.locator('#ShopLoginForm_Password');
try {
await customerField.waitFor({ state: 'visible', timeout: 15000 });
} catch {
currentEmit({ type: 'log', message: 'Champ #ShopLoginForm_Login non visible après attente.' });
const bodySnippet = normalizeSpaces(await page.content().catch(() => '')).slice(0, 500);
currentEmit({ type: 'log', message: ` Début HTML: ${bodySnippet}` });
return;
}
currentEmit({ type: 'log', message: 'Formulaire de login trouvé, saisie des identifiants…' });
await customerField.fill(customerNo || username);
await pwField.fill(password);
// Handle co-user mode if customer number differs from username.
if (username && customerNo && username !== customerNo) {
const coUserCheck = page.locator('#ShopLoginForm_LoginAsJointUser');
const coUserCheckDiv = page.locator('#divShopLoginForm_LoginAsJointUser');
// The real checkbox is hidden; the visible <div> acts as the toggle.
if (await coUserCheckDiv.count()) {
await coUserCheckDiv.click().catch(() => {});
await page.waitForTimeout(800);
} else if (await coUserCheck.count()) {
await page.evaluate(() => {
document.querySelector('#ShopLoginForm_LoginAsJointUser').checked = true;
document.querySelector('#ShopLoginForm_LoginAsJointUser').dispatchEvent(new Event('change', { bubbles: true }));
});
await page.waitForTimeout(800);
}
const coUserName = page.locator('#ShopLoginForm_UserName');
if (await coUserName.isVisible({ timeout: 2000 }).catch(() => false)) {
await coUserName.fill(username);
}
}
const submitBtn = page.locator('button[data-testid="accountLoginFormBtn"]');
await Promise.all([
page.waitForNavigation({ waitUntil: 'load', timeout: 30000 }).catch(() => {}),
submitBtn.click()
]);
await page.waitForTimeout(3000);
const afterUrl = page.url();
browserLoggedIn = !afterUrl.includes('/account/login');
currentEmit({
type: 'log',
message: browserLoggedIn
? `Connecté à Häfele via navigateur (${afterUrl}).`
: `Login navigateur non confirmé (${afterUrl}), continuation sans prix.`
});
} finally {
await page.close();
}
}
async function fetchPageWithBrowser(url) {
await ensureBrowserLogin();
const page = await getBrowserPage();
try {
currentEmit({ type: 'log', message: `Navigation vers ${url}…` });
await page.goto(url, { waitUntil: 'load', timeout: BROWSER_TIMEOUT_MS });
const cfCleared = await waitForCloudflare(page, 45);
if (!cfCleared) {
throw new Error('Cloudflare challenge non résolu automatiquement dans le navigateur.');
}
// Wait for product content to appear.
try {
await page.locator('.productDetailPage, .productHeadline, h1').first()
.waitFor({ state: 'visible', timeout: 15000 });
} catch {
// Page loaded but no product selector found — continue anyway.
}
// Price is loaded via AJAX after page load — wait for it.
try {
await page.locator('#productPriceInformation span.price').first()
.waitFor({ state: 'visible', timeout: 15000 });
currentEmit({ type: 'log', message: 'Prix détecté dans la page.' });
} catch {
currentEmit({ type: 'log', message: 'Prix non chargé après 15s, récupération du contenu actuel.' });
}
const html = await page.content();
currentEmit({ type: 'log', message: 'Contenu récupéré via navigateur (Playwright).' });
return html;
} finally {
await page.close();
}
}
async function fetchPage(url) {
const referer = `${BASE_URL}/fr/`;
let response = await fetch(url, {
headers: session.headers({
Referer: referer,
Origin: BASE_URL,
'Sec-Fetch-Site': 'same-origin'
}),
redirect: 'follow',
});
session.captureCookies(response);
let firstBody = '';
if (response.status === 403) {
firstBody = await response.text().catch(() => '');
if (isCloudflareChallenge(firstBody)) {
currentEmit({ type: 'log', message: 'Cloudflare détecté, tentative de fallback navigateur…' });
return fetchPageWithBrowser(url);
}
}
// Retry once after refreshing home page cookies when anti-bot blocks first hit.
if (response.status === 403) {
const warmup = await fetch(referer, {
headers: session.headers({
Referer: referer,
Origin: BASE_URL,
'Sec-Fetch-Site': 'same-origin'
}),
redirect: 'follow',
});
session.captureCookies(warmup);
response = await fetch(url, {
headers: session.headers({
Referer: referer,
Origin: BASE_URL,
'Sec-Fetch-Site': 'same-origin'
}),
redirect: 'follow',
});
session.captureCookies(response);
if (response.status === 403) {
const secondBody = await response.text().catch(() => '');
if (isCloudflareChallenge(secondBody)) {
currentEmit({ type: 'log', message: 'Challenge persistant, fallback navigateur…' });
return fetchPageWithBrowser(url);
}
throw new Error(`Failed to fetch ${url}: 403`);
}
}
if (!response.ok) {
const body = await response.text().catch(() => '');
const sample = normalizeSpaces(body).slice(0, 180);
throw new Error(`Failed to fetch ${url}: ${response.status}${sample ? ` (${sample})` : ''}`);
}
return response.text();
}
function extractKeyValueSpecs($) {
const specs = {};
$('.productPropertiesSection.details-product-body').each((_, el) => {
const key = normalizeSpaces($(el).find('.productPropertiesSectionHeader').first().text()).toLowerCase();
const value = normalizeSpaces($(el).find('.productPropertiesSectionBody').first().text());
if (key && value) specs[key] = value;
});
return specs;
}
function extractArticleNumber($) {
const text = normalizeSpaces($('.product-sku-title').first().text() || $.text());
const match = text.match(/N°\s*d['’]art\.\s*([\d.]+)/i);
return match ? match[1] : null;
}
function buildMaterialCode(url, articleNumber) {
if (articleNumber) {
return `HAF-${articleNumber.replace(/[^\dA-Za-z]/g, '')}`;
}
const skuMatch = url.match(/\/(P-\d+)\//i);
if (skuMatch) return `HAF-${skuMatch[1].toUpperCase()}`;
const slug = (url.split('/').filter(Boolean).pop() || 'unknown').replace(/[^\dA-Za-z]/g, '').slice(0, 30);
return `HAF-${slug.toUpperCase()}`;
}
function parseProductPage(html, url) {
const $ = cheerio.load(html);
const name = normalizeSpaces($('h1.productHeadline[itemprop="name"]').first().text() || $('h1.productHeadline').first().text());
const description = normalizeSpaces($('h2.productSubline[itemprop="description"]').first().text() || $('h2.productSubline').first().text());
const articleNumber = extractArticleNumber($);
const priceText =
normalizeSpaces($('#productPriceInformation .CUSTOMER_PRICE_Display .price span.price').first().text()) ||
normalizeSpaces($('#productPriceInformation .pricedisplay:not(.hidden) span.price').first().text()) ||
normalizeSpaces($('#productPriceInformation span.price').first().text()) ||
normalizeSpaces($('[id^="selectedArticlePriceInfo"] span.price').first().text()) ||
normalizeSpaces($('.priceContainer .price').first().text());
const priceCHF = parsePriceCHF(priceText);
if (priceText) {
currentEmit({ type: 'log', message: ` Prix trouvé: "${priceText}" → ${priceCHF} CHF` });
} else {
currentEmit({ type: 'log', message: ' Aucun prix trouvé dans le HTML.' });
}
const priceUnit = normalizeSpaces($('#productPriceInformation span.perUnit').first().text()) ||
normalizeSpaces($('[id^="selectedArticlePriceInfo"] span.perUnit').first().text()) || null;
const availabilityText = normalizeSpaces($('#productAvailabilityInformation .availability-flag').first().text());
const inStock = availabilityText
? (availabilityText.toLowerCase().includes('disponible') || availabilityText.toLowerCase().includes('stock')) &&
!availabilityText.toLowerCase().includes('indisponible')
: null;
// Deterministic image pick: prefer "primary" image, not the last diapo slide.
const pickImg = (sel) => {
const el = $(sel).first();
if (!el?.length) return null;
const src = el.attr('data-fullsrc') || el.attr('data-src') || el.attr('src') || '';
return absoluteUrl(String(src).trim());
};
// 1) Category/tile view markup (your snippet) — prefer when present.
const tileImg = pickImg('div.product_picture a.shopItemImage img, a.shopItemImage img');
// 2) OpenGraph is often the canonical hero image.
const ogImg = (
$('meta[property="og:image"]').attr('content') ||
$('meta[name="og:image"]').attr('content') ||
''
).trim();
const ogFull = absoluteUrl(ogImg);
// 3) Product detail hero image (if present).
const heroImg =
pickImg('.productImagePanelInner img[itemprop="image"]') ||
pickImg('.productImagePanelInner img');
// 4) First thumbnail (diapo) image.
const thumbImg = pickImg('.thumbsList img');
const imageUrl = tileImg || ogFull || heroImg || thumbImg || null;
const specs = extractKeyValueSpecs($);
if (Object.keys(specs).length > 0) {
const specsSummary = Object.entries(specs).map(([k, v]) => `${k}: ${v}`).join(' | ');
currentEmit({ type: 'log', message: ` Specs: ${specsSummary}` });
}
const category = normalizeSpaces($('.breadcrumb li:last-child').text()) || 'Hafele';
const thickness = parseFirstMm(specs['pour épaisseur du bois'] || specs['epaisseur'] || specs['épaisseur'] || '');
const allText = `${description} ${Object.values(specs).join(' ')}`;
const boreDiameter = parseBoreDiameter(allText);
const { depthBase, depthPiece } = parseBoreDepths(specs);
// Parse physical dimensions: diameter + length for cylindrical parts
let diameter = null;
let length = null;
const dimKeys = Object.keys(specs).map(k => k.toLowerCase());
for (const [key, value] of Object.entries(specs)) {
const k = key.toLowerCase();
if (!diameter && (k.includes('diamètre') || k.includes('diametre') || k.includes('durchmesser') || k.includes('diameter'))) {
diameter = parseFirstMm(value);
}
if (!length && (k.includes('longueur') || k.includes('länge') || k.includes('length') || k.includes('lang'))) {
length = parseFirstMm(value);
}
}
// Fallback: parse "Ø 8/60" or "8/60" pattern from the product name (e.g. SC 8/60 = Ø8mm, 60mm long)
if (!diameter) {
const nameMatch = (name || '').match(/(\d+(?:[.,]\d+)?)\s*\/\s*(\d+(?:[.,]\d+)?)/);
if (nameMatch) {
const d = parseFloat(nameMatch[1].replace(',', '.'));
const l = parseFloat(nameMatch[2].replace(',', '.'));
if (d > 0 && d <= 30) diameter = d;
if (l > 0 && l <= 200 && !length) length = l;
}
}
return {
materialCode: buildMaterialCode(url, articleNumber),
articleNumber,
name: name || articleNumber || url,
description,
collection: 'Hafele',
category,
width_mm: null,
height_mm: null,
thickness_mm: thickness,
diameter_mm: diameter,
length_mm: length,
bore_diameter_mm: boreDiameter,
bore_depth_base_mm: depthBase,
bore_depth_piece_mm: depthPiece,
price_chf: priceCHF,
price_unit: priceUnit,
image_url: imageUrl,
formex_url: url,
in_stock: inStock,
};
}
async function ensureSupplier() {
const existing = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['haefele']);
if (existing.rows.length > 0) return existing.rows[0].id;
await run(
'INSERT INTO suppliers (code, name, email, type, active) VALUES (?, ?, ?, ?, ?)',
['haefele', 'Hafele', '[email protected]', 'materials', 1]
);
const created = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['haefele']);
return created.rows[0]?.id || null;
}
async function upsertMaterial(material, supplierId) {
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 = ?,
diameter_mm = COALESCE(?, diameter_mm), length_mm = COALESCE(?, length_mm),
bore_diameter_mm = COALESCE(?, bore_diameter_mm),
bore_depth_base_mm = COALESCE(?, bore_depth_base_mm),
bore_depth_piece_mm = COALESCE(?, bore_depth_piece_mm),
image_url = ?, formex_url = ?, price_chf = COALESCE(?, price_chf), price_unit = COALESCE(?, price_unit),
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.diameter_mm ?? null,
material.length_mm ?? null,
material.bore_diameter_mm ?? null,
material.bore_depth_base_mm ?? null,
material.bore_depth_piece_mm ?? null,
material.image_url,
material.formex_url,
material.price_chf ?? null,
material.price_unit ?? null,
supplierId,
material.materialCode,
]
);
return existing.rows[0].id;
}
await run(
`INSERT INTO formex_materials
(formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
diameter_mm, length_mm, bore_diameter_mm, bore_depth_base_mm, bore_depth_piece_mm,
image_url, formex_url, price_chf, price_unit, supplier_id, last_synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
[
material.materialCode,
material.name,
material.collection,
material.category,
material.width_mm,
material.height_mm,
material.thickness_mm,
material.diameter_mm ?? null,
material.length_mm ?? null,
material.bore_diameter_mm ?? null,
material.bore_depth_base_mm ?? null,
material.bore_depth_piece_mm ?? null,
material.image_url,
material.formex_url,
material.price_chf ?? null,
material.price_unit ?? null,
supplierId,
]
);
const created = await pool.query('SELECT id FROM formex_materials WHERE formex_code = ?', [material.materialCode]);
return created.rows[0]?.id || null;
}
async function logSync(status, itemsCount, error = null) {
await run(
'INSERT INTO sync_logs (source, status, items_count, error) VALUES (?, ?, ?, ?)',
['haefele', status, itemsCount, error]
);
}
export async function syncHaefeleByUrls(urls = [], emit = () => {}, options = {}) {
const credentials = options.credentials || {};
currentCredentials = credentials;
currentEmit = emit;
const validUrls = [...new Set((urls || [])
.map((v) => (typeof v === 'string' ? v.trim() : ''))
.filter(Boolean)
.filter((v) => v.includes('haefele.ch')))];
if (validUrls.length === 0) {
throw new Error('Aucune URL Häfele valide fournie.');
}
session = new HaefeleSession();
browserLoggedIn = false;
const supplierId = await ensureSupplier();
await session.login(emit, credentials);
let synced = 0;
let failed = 0;
const start = Date.now();
try {
emit({ type: 'log', message: `${validUrls.length} URL(s) Häfele à traiter.` });
emit({ type: 'progress', current: 0, total: validUrls.length, collection: 'Hafele' });
for (let i = 0; i < validUrls.length; i++) {
const url = validUrls[i];
emit({ type: 'log', message: `Traitement [${i + 1}/${validUrls.length}] ${url}` });
emit({ type: 'progress', current: i, total: validUrls.length, collection: 'Hafele' });
if (i > 0) await sleep(CRAWL_DELAY);
try {
const html = await fetchPage(url);
const parsed = parseProductPage(html, url);
await upsertMaterial(parsed, supplierId);
synced++;
const pricePart = parsed.price_chf != null ? ` - ${parsed.price_chf} CHF` : '';
const boreParts = [
parsed.bore_diameter_mm != null ? `Ø${parsed.bore_diameter_mm}` : null,
parsed.bore_depth_base_mm != null ? `fond:${parsed.bore_depth_base_mm}` : null,
parsed.bore_depth_piece_mm != null ? `pièce:${parsed.bore_depth_piece_mm}` : null,
].filter(Boolean);
const boreStr = boreParts.length ? ` [${boreParts.join(', ')}mm]` : '';
emit({ type: 'log', message: ` ✓ ${parsed.name}${pricePart}${boreStr}` });
} catch (err) {
failed++;
emit({ type: 'log', message: ` ✗ Erreur: ${err.message}` });
}
}
const duration = ((Date.now() - start) / 1000).toFixed(1);
const status = failed > 0 && synced === 0 ? 'error' : 'success';
await logSync(status, synced, failed > 0 ? `${failed} url(s) failed` : null);
const result = {
success: status === 'success',
itemsCount: synced,
failedCount: failed,
duration,
error: status === 'error' ? 'Aucune URL Häfele n\'a pu être synchronisée.' : null
};
emit({ type: 'progress', current: validUrls.length, total: validUrls.length, collection: 'Hafele' });
if (result.success) emit({ type: 'complete', result });
else emit({ type: 'error', error: result.error });
return result;
} catch (err) {
await logSync('error', synced, err.message);
emit({ type: 'error', error: err.message });
return { success: false, itemsCount: synced, error: err.message };
} finally {
await closeBrowser();
}
}
+478
View File
@@ -0,0 +1,478 @@
import pool from '../db/index.js';
import { generateCuttingList } from './cuttingEngine.js';
import { PLINTH, CARCASS_FASTENERS } from '../../shared/manufacturing.js';
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',
back_panel_fastener_ixconnect_rpc_g_13_20:
'https://www.haefele.ch/fr/produkt/ferrure-d-assemblage-pour-paroi-arri-re-haefele-ixconnect-rpc-g-13-20-enficher-dans-la-rainure/P-00861578/',
};
function toMoney(n) {
const x = Number(n);
if (!Number.isFinite(x)) return 0;
return Math.round(x * 100) / 100;
}
function addQty(map, key, row) {
const prev = map.get(key);
if (!prev) {
map.set(key, { ...row, quantity: Math.max(0, Number(row.quantity) || 0) });
return;
}
map.set(key, {
...prev,
quantity: (Number(prev.quantity) || 0) + (Number(row.quantity) || 0),
});
}
function pricePackSizeFromUnitLabel(unitLabel, unit) {
const u = String(unit || '').trim().toLowerCase();
if (u !== 'pc') return null;
const s = String(unitLabel || '').trim();
if (!s) return null;
// Examples we see in Häfele sync:
// - "par 100 Pièce (PC)"
// - "par 10 pièce"
// - "Par 25 PC"
// - "par 100 Pieces"
const m = s.match(/\bpar\s+(\d+)\s*(pi[eè]ce?s?|pc)\b/i);
const n = m ? Number(m[1]) : NaN;
if (!Number.isFinite(n) || n <= 1) return null;
return n;
}
async function fetchOne(sql, params) {
const r = await pool.query(sql, params);
return r?.rows?.[0] || null;
}
async function priceById(table, id) {
if (!id) return null;
const row = await fetchOne(`SELECT id, code, name, price_chf FROM ${table} 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: table,
};
}
async function findHaefeleMaterialForModel(model) {
const m = String(model || '').trim();
if (!m) return null;
// Prefer strict matches (code contains model OR name contains model)
// so we link to the exact Häfele product page when synced.
const row = await fetchOne(
`SELECT id, formex_code as code, name, price_chf, price_unit, image_url, formex_url
FROM formex_materials
WHERE active = 1
AND (supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele') OR collection = 'Hafele' OR formex_code LIKE 'HAF-%')
AND (formex_code LIKE ? OR name LIKE ?)
ORDER BY last_synced_at DESC, id DESC
LIMIT 1`,
[`%${m}%`, `%${m}%`]
);
if (!row) return null;
return {
id: row.id,
code: row.code,
name: row.name,
unitPriceChf: Number.isFinite(Number(row.price_chf)) ? toMoney(row.price_chf) : null,
unitLabel: row.price_unit || null,
source: 'haefele_materials',
url: row.formex_url || null,
imageUrl: row.image_url || null,
};
}
async function findHaefeleMaterialByUrl(url) {
const raw = String(url || '').trim();
if (!raw) return null;
// Normalize: strip query/hash (synced DB may store canonical URL).
const uNoHash = raw.split('#')[0];
const uBase = uNoHash.split('?')[0];
const u = uNoHash;
// Extract a stable numeric product id if present (e.g. ".../34242663/").
const m = uBase.match(/\/(\d{6,})\/?$/);
const productId = m?.[1] || null;
const candidates = [
u,
uBase,
].filter((v, idx, arr) => v && arr.indexOf(v) === idx);
const row = await fetchOne(
`SELECT id, formex_code as code, name, price_chf, price_unit, image_url, formex_url
FROM formex_materials
WHERE active = 1
AND (supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele') OR collection = 'Hafele' OR formex_code LIKE 'HAF-%')
AND (
formex_url = ?
OR formex_url = ?
OR formex_url LIKE ?
OR (? IS NOT NULL AND formex_url LIKE ?)
)
ORDER BY last_synced_at DESC, id DESC
LIMIT 1`,
[
candidates[0] || raw,
candidates[1] || raw,
`${uBase}%`,
productId,
productId ? `%/${productId}/%` : null,
]
);
if (!row) return null;
return {
id: row.id,
code: row.code,
name: row.name,
unitPriceChf: Number.isFinite(Number(row.price_chf)) ? toMoney(row.price_chf) : null,
unitLabel: row.price_unit || null,
source: 'haefele_materials',
url: row.formex_url || null,
imageUrl: row.image_url || null,
};
}
function computeCountsFromCuttingList(cuttingList) {
const items = new Map();
const pieces = Array.isArray(cuttingList?.pieces) ? cuttingList.pieces : [];
let doorCount = 0;
let drawersCount = 0;
for (const p of pieces) {
const qty = Math.max(0, Number(p?.quantity) || 0);
const m = p?.machining || {};
const wMm = Number(p?.widthMm) || 0;
if (p?.elementType === 'portes') {
doorCount += qty;
if (m.hingeCups?.count) {
addQty(items, 'hinge', {
key: 'hinge',
label: 'Charnière',
quantity: qty * (Number(m.hingeCups.count) || 0),
unit: 'pc',
});
}
// Assumption: 2 finish connectors per door (front/back).
if (m.finishConnectors?.model) {
addQty(items, `finish_connector:${m.finishConnectors.model}`, {
key: `finish_connector:${m.finishConnectors.model}`,
label: `Connecteur finition ${m.finishConnectors.model}`,
quantity: qty * 2,
unit: 'pc',
meta: { model: m.finishConnectors.model },
});
}
}
// Étagères amovibles — ferrures SP 15/8/5 (système ressort)
// User spec: 4 pieces per shelf/tablette.
if (p?.elementType === 'etageres' && m.mobileShelfSystem?.model === 'sp_15_8_5' && qty > 0) {
addQty(items, 'shelf_ferrure_sp_15_8_5', {
key: 'shelf_ferrure_sp_15_8_5',
label: 'Ferrure d’assemblage de tablettes SP 15/8/5 (ressort)',
quantity: qty * 4,
unit: 'pc',
meta: { model: 'SP 15/8/5' },
});
}
if (m.connectorHoles?.model && ['dessus', 'fond'].includes(p?.elementType)) {
const joints = Number(m.connectorHoles.jointsPerPiece) || (Array.isArray(m.connectorHoles.ixconnectJointsAlongDepth) ? m.connectorHoles.ixconnectJointsAlongDepth.length : 0);
const perJoint = Number(m.connectorHoles.connectorsPerJoint) || 1;
const tPer = Number(m.connectorHoles.tourillonsPerJoint) || 0;
addQty(items, `ixconnect:${m.connectorHoles.model}`, {
key: `ixconnect:${m.connectorHoles.model}`,
label: `Connecteur structure ${m.connectorHoles.model}`,
quantity: qty * joints * perJoint,
unit: 'pc',
meta: { model: m.connectorHoles.model },
});
if (tPer > 0) {
addQty(items, `tourillon:${m.connectorHoles.tourillonDiameter || 8}x30`, {
key: `tourillon:${m.connectorHoles.tourillonDiameter || 8}x30`,
label: `Tourillon Ø${m.connectorHoles.tourillonDiameter || 8}×30`,
quantity: qty * joints * tPer,
unit: 'pc',
});
}
}
if (m.carcassFasteners?.positionsAlongJointMm?.length && ['dessus', 'fond'].includes(p?.elementType)) {
const ferruresPerJoint = m.carcassFasteners.positionsAlongJointMm.length;
const jointCount = Number(m.carcassFasteners.jointCount) || (Array.isArray(m.carcassFasteners.junctionAxesAcrossWidthMm) ? m.carcassFasteners.junctionAxesAcrossWidthMm.length : 0);
const ferrures = qty * ferruresPerJoint * jointCount;
addQty(items, 'ferrure_carcasse', {
key: 'ferrure_carcasse',
label: `Ferrure corps (jonction) — ${CARCASS_FASTENERS.model}`,
quantity: ferrures,
unit: 'pc',
productUrl: CARCASS_FASTENERS.productUrl,
meta: { model: CARCASS_FASTENERS.model },
});
// 2 dowels per ferrure (per spec text)
addQty(items, `tourillon:${m.carcassFasteners.dowelDiameter || 8}x40`, {
key: `tourillon:${m.carcassFasteners.dowelDiameter || 8}x40`,
label: `Tourillon Ø${m.carcassFasteners.dowelDiameter || 8}×${m.carcassFasteners.dowelDepth || 40}`,
quantity: ferrures * 2,
unit: 'pc',
});
}
if (p?.elementType === 'renforts' && m.reinforcementFixing?.connectorModel) {
const sideCount = Number(m.reinforcementFixing.sideCount) || 2;
const ixPerSide = Number(m.reinforcementFixing.ixconnectPerSide) || 1;
const tPerSide = Number(m.reinforcementFixing.tourillonsPerSide) || 0;
addQty(items, `reinforcement_ix:${m.reinforcementFixing.connectorModel}`, {
key: `reinforcement_ix:${m.reinforcementFixing.connectorModel}`,
label: `Connecteur renfort ${m.reinforcementFixing.connectorModel}`,
quantity: qty * sideCount * ixPerSide,
unit: 'pc',
meta: { model: m.reinforcementFixing.connectorModel },
});
if (tPerSide > 0) {
addQty(items, `tourillon:${m.reinforcementFixing.tourillonDiameter || 8}x30`, {
key: `tourillon:${m.reinforcementFixing.tourillonDiameter || 8}x30`,
label: `Tourillon Ø${m.reinforcementFixing.tourillonDiameter || 8}×30`,
quantity: qty * sideCount * tPerSide,
unit: 'pc',
});
}
}
if (m.blockAssembly?.middleCount && ['panneauxInterieurs', 'cotes'].includes(p?.elementType)) {
// Note: block assembly “vis de jonction” doesn't provide exact count per piece,
// so we do not count it here to avoid misleading totals.
}
if (p?.elementType === 'tiroirs') {
drawersCount += qty;
}
// Fixation "dos" (paroi arrière) — 4 côtés (gauche/droite + haut/bas).
// Règle atelier (pour une longueur E = L ou H):
// - par côté: 1 pièce à 5cm de chaque extrémité (=> 2)
// - + 1 pièce tous les 30cm disponibles entre les 2 extrémités
// Donc: n(E) = 2 + floor(max(0, E-100) / 300) (E en mm)
if (p?.elementType === 'dos' && wMm > 0 && qty > 0) {
const hMm = Number(p?.heightMm) || 0;
const perEdgeW = 2 + Math.floor(Math.max(0, wMm - 100) / 300);
const perEdgeH = hMm > 0 ? 2 + Math.floor(Math.max(0, hMm - 100) / 300) : 0;
const totalPerPanel = perEdgeW * 2 + perEdgeH * 2;
const total = qty * totalPerPanel;
addQty(items, 'back_panel_fastener_ixconnect_rpc_g_13_20', {
key: 'back_panel_fastener_ixconnect_rpc_g_13_20',
label: 'Ferrure dos — Häfele Ixconnect RPC G 13/20 (rainure)',
quantity: total,
unit: 'pc',
productUrl: HAEFELE_PRODUCT_URLS.back_panel_fastener_ixconnect_rpc_g_13_20,
meta: { model: 'Ixconnect RPC G 13/20' },
});
}
}
return { items: Array.from(items.values()), doorCount, drawersCount };
}
export async function generateHardwareList({ config, modules = [], colors = {} }) {
const cuttingList = generateCuttingList(config, colors, modules);
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)
const handleVariantId = config?.hardware?.handleVariantId;
const hingeVariantId = config?.hardware?.hingeVariantId;
const drawerSystemId = config?.hardware?.drawerSystemId;
const [handleVariant, hingeVariant, drawerSystem] = await Promise.all([
priceById('handle_variants', handleVariantId),
priceById('hinge_variants', hingeVariantId),
priceById('blum_hardware', drawerSystemId),
]);
// Add handles as an item when selected
if (handleVariant && doorCount > 0) {
items.push({
key: `handle_variant:${handleVariant.id}`,
label: `Poignée — ${handleVariant.name}`,
quantity: doorCount,
unit: 'pc',
unitPriceChf: handleVariant.unitPriceChf,
source: handleVariant.source,
code: handleVariant.code,
});
}
// Attach hinge price to computed hinges (prefer synced Häfele, even without selected variant)
const hingeUrl = HAEFELE_PRODUCT_URLS.hinge_blum_clip_top_blumotion_110;
const haefeleHinge = await findHaefeleMaterialByUrl(hingeUrl);
for (const it of items) {
if (it.key !== 'hinge') continue;
if (hingeVariant) {
it.label = `Charnière — ${hingeVariant.name}`;
it.source = it.source ?? hingeVariant.source;
it.code = it.code ?? hingeVariant.code;
it.unitPriceChf = it.unitPriceChf ?? hingeVariant.unitPriceChf;
}
// Always attach product URL for the hinge line (useful for admins to sync/verify).
it.productUrl = it.productUrl ?? hingeUrl;
// Prefer synced Häfele data when available.
if (haefeleHinge) {
it.unitPriceChf = haefeleHinge.unitPriceChf ?? it.unitPriceChf;
it.productUrl = haefeleHinge.url || it.productUrl;
it.imageUrl = it.imageUrl ?? haefeleHinge.imageUrl;
it.unitLabel = it.unitLabel ?? haefeleHinge.unitLabel;
it.code = haefeleHinge.code || it.code;
it.source = it.source ?? haefeleHinge.source;
}
}
// Attach carcass fastener (Ferrure corps / jonction) reference + price
const carcassUrl = CARCASS_FASTENERS.productUrl;
const haefeleCarcass = await findHaefeleMaterialByUrl(carcassUrl);
for (const it of items) {
if (it.key !== 'ferrure_carcasse') continue;
it.productUrl = it.productUrl ?? carcassUrl;
if (haefeleCarcass) {
it.unitPriceChf = haefeleCarcass.unitPriceChf ?? it.unitPriceChf;
it.productUrl = haefeleCarcass.url || it.productUrl;
it.imageUrl = it.imageUrl ?? haefeleCarcass.imageUrl;
it.unitLabel = it.unitLabel ?? haefeleCarcass.unitLabel;
it.code = haefeleCarcass.code || it.code;
it.source = it.source ?? haefeleCarcass.source;
// Keep user-facing label stable but include model when present.
it.label = it.label || `Ferrure corps (jonction) — ${CARCASS_FASTENERS.model}`;
}
}
// Attach back panel fastener (dos) reference + price
const backFastenerUrl = HAEFELE_PRODUCT_URLS.back_panel_fastener_ixconnect_rpc_g_13_20;
const haefeleBackFastener = await findHaefeleMaterialByUrl(backFastenerUrl);
for (const it of items) {
if (it.key !== 'back_panel_fastener_ixconnect_rpc_g_13_20') continue;
it.productUrl = it.productUrl ?? backFastenerUrl;
if (haefeleBackFastener) {
it.unitPriceChf = haefeleBackFastener.unitPriceChf ?? it.unitPriceChf;
it.productUrl = haefeleBackFastener.url || it.productUrl;
it.imageUrl = it.imageUrl ?? haefeleBackFastener.imageUrl;
it.unitLabel = it.unitLabel ?? haefeleBackFastener.unitLabel;
it.code = haefeleBackFastener.code || it.code;
it.source = it.source ?? haefeleBackFastener.source;
}
}
// Drawer system: assume priced “per drawer”
if (drawerSystem) {
const totalDrawers = (Array.isArray(config?.modules) ? config.modules : []).reduce((s, m) => s + (Number(m?.drawers) || 0), 0);
if (totalDrawers > 0) {
items.push({
key: `drawer_system:${drawerSystem.id}`,
label: `Système tiroirs — ${drawerSystem.name}`,
quantity: totalDrawers,
unit: 'pc',
unitPriceChf: drawerSystem.unitPriceChf,
source: drawerSystem.source,
code: drawerSystem.code,
});
}
}
// Price Häfele items (best-effort by name match)
const haefeleLookups = new Map();
for (const it of items) {
const model = it?.meta?.model;
if (!model) continue;
if (!haefeleLookups.has(model)) {
haefeleLookups.set(model, findHaefeleMaterialForModel(model));
}
}
const haefeleResolved = new Map();
await Promise.all(
Array.from(haefeleLookups.entries()).map(async ([model, prom]) => {
const r = await prom;
haefeleResolved.set(model, r);
})
);
for (const it of items) {
const model = it?.meta?.model;
if (!model) continue;
const mat = haefeleResolved.get(model);
if (!mat) continue;
// For Häfele-connectors (SC 8/60, SC 8/25, SC 8/65...), prefer synced Häfele price when available.
it.unitPriceChf = mat.unitPriceChf ?? it.unitPriceChf;
it.source = it.source ?? mat.source;
it.code = it.code ?? mat.code;
it.productUrl = it.productUrl ?? mat.url;
it.imageUrl = it.imageUrl ?? mat.imageUrl;
it.unitLabel = it.unitLabel ?? mat.unitLabel;
}
// Compute totals
const normalized = items
.filter((it) => (Number(it.quantity) || 0) > 0)
.map((it) => {
let unitPriceChf = it.unitPriceChf == null ? null : toMoney(it.unitPriceChf);
const quantity = Math.max(0, Number(it.quantity) || 0);
let unitLabel = it.unitLabel || null;
// Häfele sometimes provides prices as "par 100 pièces" etc.
// Our UI + totals expect unitPriceChf to be per displayed `unit` (usually per pc).
if (unitPriceChf != null) {
const packSize = pricePackSizeFromUnitLabel(it.unitLabel, it.unit || 'pc');
if (packSize) {
unitPriceChf = toMoney(unitPriceChf / packSize);
unitLabel = 'par pièce (PC)';
}
}
const lineTotalChf = unitPriceChf == null ? null : toMoney(unitPriceChf * quantity);
return {
key: String(it.key),
label: String(it.label),
code: it.code || null,
quantity,
unit: it.unit || 'pc',
unitPriceChf,
lineTotalChf,
source: it.source || null,
productUrl: it.productUrl || null,
imageUrl: it.imageUrl || null,
unitLabel,
};
})
.sort((a, b) => a.label.localeCompare(b.label, 'fr'));
const totalChf = toMoney(
normalized.reduce((s, it) => s + (Number(it.lineTotalChf) || 0), 0)
);
return { items: normalized, totalChf };
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Perçages par pièce pour exports CNC (.nc) et MPR (HOMAC/Woodwop XML-like).
* Même jeu de coordonnées que la vue liste / G-code commentaires : origine bas-gauche, X/Y en mm.
*/
import {
buildTopBottomPlanHoleSpecsMm,
buildVerticalPanelIxconnectFaceHoleSpecsMm,
buildShelfMobileHoleSpecsMm,
buildFinishHabillageHoleSpecsMm,
buildDoorHingeCupHoleSpecsMm,
buildDrawerReinforcementHoleSpecsMm,
buildDrawerRenfortPanelIxconnectSpecsMm
} from '../../shared/cncPanelHoles.js';
export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0) {
const specs = [];
function pushHole(params) {
specs.push(params);
}
const m = piece?.machining || {};
const thicknessMm = Math.max(1, Number(piece?.thicknessMm) || 19);
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
if (m.system32?.positions?.length) {
const frontX = clampX(37);
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 || '')) {
for (const spec of buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
}
}
if (['cotes', 'panneauxInterieurs'].includes(piece.elementType || '')) {
for (const spec of buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
}
for (const spec of buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm, partIndex)) {
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
}
}
if (['cotes', 'habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus'].includes(piece.elementType || '')) {
for (const spec of buildFinishHabillageHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: 'blind' });
}
}
if (piece.elementType === 'etageres' || piece.elementType === 'rayonFixe') {
for (const spec of buildShelfMobileHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
}
}
if (piece.elementType === 'renfortsTiroir') {
for (const spec of buildDrawerReinforcementHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: 'blind' });
}
}
if (piece.elementType === 'portes') {
for (const spec of buildDoorHingeCupHoleSpecsMm(piece, widthMm, heightMm)) {
pushHole({ ...spec, toolMode: 'blind', face: 'face' });
}
}
if (m.reinforcementFixing) {
const rf = m.reinforcementFixing;
const dIx = Number(rf.edgeHole?.diameter || 8);
const dT = Number(rf.tourillonDiameter || 8);
const zIx = Number(rf.edgeHole?.depth || 45);
const zT = Number(piece.thicknessMm || 16);
const yFromTop = (mm) => clampY(heightMm - Number(mm));
for (const end of ['left', 'right']) {
const xEnd = end === 'left' ? 0 : widthMm;
if (Number.isFinite(Number(rf.ixconnectAxisFromTopMm))) {
pushHole({
xMm: clampX(xEnd),
yMm: yFromTop(rf.ixconnectAxisFromTopMm),
diameterMm: dIx,
depthMm: zIx,
type: 'ixconnect',
face: 'end',
end,
toolMode: 'edge'
});
}
for (const a of rf.tourillonAxesFromTopMm || []) {
pushHole({
xMm: clampX(xEnd),
yMm: yFromTop(a),
diameterMm: dT,
depthMm: zT,
type: 'tourillon',
face: 'end',
end,
toolMode: 'edge'
});
}
}
}
return { specs, thicknessMm };
}
+35
View File
@@ -0,0 +1,35 @@
import nodemailer from 'nodemailer';
let transporter = null;
function getTransporter() {
if (transporter) return transporter;
const host = process.env.SMTP_HOST;
const port = Number(process.env.SMTP_PORT) || 587;
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
if (!host || !user || !pass) {
throw new Error('SMTP configuration manquante (SMTP_HOST, SMTP_USER, SMTP_PASS)');
}
transporter = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass }
});
return transporter;
}
/**
* Send an email.
* @param {{ to: string, subject: string, html: string }} options
*/
export async function sendEmail({ to, subject, html }) {
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const t = getTransporter();
await t.sendMail({ from, to, subject, html });
}
+522
View File
@@ -0,0 +1,522 @@
/**
* Export MPR (WOODWOP / Homag-family .mpr) pour le débit APEX — un fichier par panneau, ZIP.
* Coordonnées XY = même repère que le G-code (.nc commentaires): origine bas-gauche, mm.
* Macros RSO/* : chemins exemple — à présence confirmée sur le poste/cellule.
*/
import JSZip from 'jszip';
import { generateCuttingList } from './cuttingEngine.js';
import { collectHoleSpecsForPiece } from './holeSpecifications.js';
import { GROOVE, getBackGroovePlanOrientation, BACK_GROOVE_PLAN_VERTICAL_TYPES } from '../../shared/manufacturing.js';
const MPR_ARTICLE_CODES = Object.freeze({
plintheBas: 'PL_BAS',
bandeauHaut: 'BH',
dessus: 'BO_U',
fond: 'BO_L',
cotes: 'SE',
cotesDoublage: 'CD',
fileur: 'FI',
habillageFinitionGauche: 'FR_L_G',
habillageFinitionDroite: 'FR_L_D',
habillageFinitionDessus: 'FR_H',
dos: 'DO',
renforts: 'RE',
portes: 'TU',
panneauxInterieurs: 'ZA_O',
rayonFixe: 'RF_X',
etageres: 'EB_M',
renfortsTiroir: 'LABO'
});
function fmtMm(n) {
const v = Number(n);
if (!Number.isFinite(v)) return '0.0';
const rounded = Math.round(v * 10) / 10;
return Number.isInteger(rounded) ? `${rounded}.0` : `${rounded}`;
}
function toSafe(value, fb) {
const n = Number(value);
return Number.isFinite(n) ? n : fb;
}
function escQuoted(s) {
return String(s ?? '').replace(/"/g, "'");
}
/** Scinde ligne « gauche+droit » qty 2 en deux fichiers SE_L / SE_R. */
export function flattenPiecesForMprExport(pieces = []) {
const out = [];
for (const piece of pieces) {
const qty = Math.max(1, Math.round(toSafe(piece.quantity, 1)));
const splitSides =
piece.elementType === 'cotes' &&
qty === 2 &&
piece.id === 'piece-cotes' &&
String(piece.description || '').includes('gauche et droit');
if (splitSides) {
out.push({ piece: { ...piece, quantity: 1 }, slug: 'SE_L', qtyIndex: 0 });
out.push({ piece: { ...piece, quantity: 1 }, slug: 'SE_R', qtyIndex: 1 });
continue;
}
let baseSlug = MPR_ARTICLE_CODES[piece.elementType] || String(piece.elementType || '').toUpperCase() || 'PIECE';
for (let qi = 0; qi < qty; qi++) {
let slug = baseSlug;
if (
qty > 1 &&
['panneauxInterieurs', 'renforts', 'etageres', 'rayonFixe', 'bandeauHaut'].includes(piece.elementType || '')
) {
slug = `${baseSlug}_${qi + 1}`;
}
out.push({ piece, slug, qtyIndex: qi });
}
}
return out;
}
function mprAnglesForPiece(piece) {
const et = piece.elementType || '';
if (habillageEtPorte(et)) {
return {
DRWIN: '270.0',
beschGedreht: '0',
turnMsg: et === 'portes' ? 'DREHEN X-ACHSE' : 'DREHEN X-ACHSE'
};
}
if (['dessus', 'fond'].includes(et)) {
return {
DRWIN: '0.0',
beschGedreht: '1',
turnMsg: 'DREHEN Y-ACHSE'
};
}
return {
DRWIN: '0.0',
beschGedreht: '0',
turnMsg: 'DREHEN Y-ACHSE'
};
}
function habillageEtPorte(et) {
return (
['habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus', 'portes'].includes(
et
)
);
}
/** Kantendicken : habillages façon exemple FR_* (tous faces 1 mm). */
function kantenFlags(piece) {
const fin = habillageEtPorte(piece.elementType || '');
if (fin) {
return { kavo: '1.0', kali: '1.0', kahi: '1.0', kare: '1.0' };
}
return { kavo: '1.0', kali: '0.0', kahi: '0.0', kare: '0.0' };
}
function grooveSection(piece, _Lmm, _Bmm) {
const m = piece?.machining || {};
const orient = piece?.elementType ? getBackGroovePlanOrientation(piece.elementType) : null;
if (!m.groove || !orient) return [];
const gw = fmtMm(Number(m.groove.width ?? GROOVE.back.width));
const gd = fmtMm(Number(m.groove.depth ?? GROOVE.back.depth));
const abst = fmtMm(Number(m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge));
const vertical = BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType);
const win = vertical ? '180.0' : '270.0';
return [
'<121 \\Block\\',
'MNM="Kantenbearbeitungen von oben"',
'DP="1"',
'KO="04"',
'<139 \\Komponente\\',
'IN="RSO/Makro/RSO_Nut_Flaeche.mpr"',
'MNM="Nut Flaeche rueckseite"',
'XA="L"',
'YA="B"',
'VA="LA L"',
`VA="BR ${gw}"`,
`VA="TI ${gd}"`,
`VA="ABST ${abst}"`,
`VA="WIN ${win}"`,
'VA="ANF 0.0"',
'VA="END 0.0"',
'VA="MAT MAT"',
'KO="04"'
];
}
/** Un trou → lignes BohrVert (projection face Z+ ; perçages chant restent approximation LS/LSL). */
function bohrVertLines(spec, thicknessMm) {
const dRaw = Number(spec.diameterMm) || 5;
const depthRaw = Number(spec.depthMm) || 12;
const edgeHint = Boolean(spec.toolMode === 'edge' || spec.face === 'end');
const throughHint = Boolean(spec.toolMode === 'through');
const looksThrough = depthRaw >= thicknessMm - 0.5;
let bm = 'LS';
let ti;
if (throughHint || looksThrough) {
bm = 'LSL';
ti = `${fmtMm(thicknessMm)} + 1`;
} else if (edgeHint && depthRaw >= 38) {
bm = 'LSL';
ti = fmtMm(depthRaw);
} else {
ti = fmtMm(depthRaw);
}
const name = fmtMm(Math.round(dRaw * 10) / 10);
const du = fmtMm(Math.round(dRaw * 10) / 10);
const xa = fmtMm(spec.xMm);
const ya = fmtMm(spec.yMm);
return [
'<102 \\BohrVert\\',
`MNM="Bohrung ${name} mm"`,
`XA="${xa}"`,
`YA="${ya}"`,
`BM="${bm}"`,
`TI="${ti}"`,
`DU="${du}"`,
'AN="1"',
'MI="0"',
'S_="2"',
'ASG="2"',
'MLM="0"',
'KO="04"'
];
}
/** Supprime les doublons numériques (tolérance 0.02 mm — sans fusionner trous légitimes proches). */
function dedupeHoleSpecs(specs, eps = 0.02) {
const out = [];
for (const s of specs) {
const hit = out.find(
(o) =>
Math.abs(o.xMm - s.xMm) < eps &&
Math.abs(o.yMm - s.yMm) < eps &&
Math.abs((o.diameterMm || 0) - (s.diameterMm || 0)) < eps
);
if (!hit) out.push(s);
}
return out;
}
function buildSingleMprText({
piece,
slug,
qtyIndex,
seqGlobal,
groupIndex,
orderReference,
stackListKm,
defaultMaterialArticle
}) {
const angles = mprAnglesForPiece(piece);
const kant = kantenFlags(piece);
const L = toSafe(piece.rawCutWidthMm, piece.widthMm);
const B = toSafe(piece.rawCutHeightMm, piece.heightMm);
const D = toSafe(piece.thicknessMm, 19);
const posLabel = `${groupIndex}.${seqGlobal}`;
const { specs, thicknessMm } = collectHoleSpecsForPiece(piece, L, B, qtyIndex);
const unique = dedupeHoleSpecs(specs);
/* Bloc Bohrungen */
const holeLines = [];
for (const sp of unique) {
holeLines.push(...bohrVertLines(sp, thicknessMm));
}
const dp = Math.max(1, Math.min(98, holeLines.filter((ln) => ln.startsWith('<102')).length));
const matKm = escQuoted(
piece.finishId != null && piece.finishId !== '' ? piece.finishId : defaultMaterialArticle || 'APEX'
);
const lines = [];
lines.push('[H', 'VERSION="4.0 Alpha"', 'FW="1400"', 'FM="1"', 'ZS="40"', 'OP="1"');
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(`STKL="${escQuoted(stackListKm)}"`, `KM="STUECKLISTE: ${escQuoted(stackListKm)}"`);
lines.push(`POS="${escQuoted(posLabel)}"`, `KM="POSITION: ${escQuoted(posLabel)}"`);
lines.push('BEZ="0"', `KM="Bezeichnung: ${escQuoted(slug)}"`);
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('ZF="2.0"', 'KM="Zugabe Fraesung"', 'ZB="3.0"', 'KM="Zugabe Bohrung"');
lines.push('RITZEN="1.0"', 'KM="Vorritztiefe mm"');
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('FORMAT="0"', 'KM="Formatieren 0 oder 1"');
lines.push('ZUGLI="0.0"', 'ZUGRE="0.0"', 'ZUGVO="0.0"', 'ZUGHI="0.0"');
lines.push('DW="IF DRWIN=0 OR DRWIN=90 OR DRWIN=180 THEN DRWIN ELSE 270"');
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(
'offxL="IF DW=180 THEN ZUGRE ELSE ZUGLI"',
'offxB="IF DW=90 THEN ZUGHI ELSE ZUGVO"',
'offyL="IF DW=180 THEN ZUGHI ELSE ZUGVO"',
'offyB="IF DW=90 THEN ZUGLI ELSE ZUGRE"'
);
lines.push(
'[K',
'<00 \\Koordinatensystem\\',
'NR="04"',
'XP="IF DW=0 OR DW=270 THEN 0 ELSE ld"',
'YP="IF DW=0 OR DW=90 THEN 0 ELSE bd"',
'ZP="0"',
'D1="DW"',
'KI="0"',
'D2="0"',
'MI="0"',
'<00 \\Koordinatensystem\\',
'NR="F1"',
'XP="IF DW=0 OR DW=270 THEN 0 ELSE ld"',
'YP="IF DW=0 OR DW=90 THEN 0 ELSE bd"',
'ZP="0"',
'D1="DW"',
'KI="90"',
'D2="0"',
'MI="0"',
'<00 \\Koordinatensystem\\',
'NR="F2"',
'XP="IF DW=0 OR DW=270 THEN 0 ELSE ld"',
'YP="IF DW=90 OR DW=0 THEN 0 ELSE bd"',
'ZP="0"',
'D1="DW+270"',
'KI="90"',
'D2="0"',
'MI="1"',
'<00 \\Koordinatensystem\\',
'NR="F3"',
'XP="IF DW=0 OR DW=90 THEN 0 ELSE ld"',
'YP="IF DW=180 OR DW=90 THEN 0 ELSE bd"',
'ZP="0"',
'D1="DW+180"',
'KI="90"',
'D2="0"',
'MI="1"',
'<00 \\Koordinatensystem\\',
'NR="F4"',
'XP="IF DW=180 OR DW=270 THEN 0 ELSE ld"',
'YP="IF DW=0 OR DW=270 THEN 0 ELSE bd"',
'ZP="0"',
'D1="DW+90"',
'KI="90"',
'D2="0"',
'MI="0"',
'<100 \\WerkStck\\',
'LA="IF DW=90 OR DW=270 THEN B ELSE L"',
'BR="IF DW=90 OR DW=270 THEN L ELSE B"',
'DI="D"',
'FNX="IF DW=90 OR DW=270 THEN offxB ELSE offxL"',
'FNY="IF DW=90 OR DW=270 THEN offyB ELSE offyL"',
'AX="IF DW=90 OR DW=270 THEN ZUGVO+ZUGHI ELSE ZUGLI+ZUGRE"',
'AY="IF DW=90 OR DW=270 THEN ZUGLI+ZUGRE ELSE ZUGVO+ZUGHI"',
'<101 \\Kommentar\\',
`KM="AUFTRAG : ${escQuoted(orderReference)}"`,
`KM="STUECKLISTE : ${escQuoted(stackListKm)}"`,
`KM="POSITION : ${escQuoted(posLabel)}"`,
`KM="Bezeichnung: ${escQuoted(slug)}"`,
'KM=""',
`KM="${escQuoted(angles.turnMsg)}"`,
'KM=""',
'KM="Teileinfo"',
`KM="Groesse: ${fmtMm(L)} x ${fmtMm(B)}"`,
`KM="Staerke: ${fmtMm(D)}"`,
`KM=""`,
`KM="Material: ${matKm}"`,
'KM="Belag IN: "',
'KM="Belag AU: "',
'KM=""',
'<139 \\Komponente\\',
'MNM="Beschriftung"',
'IN="RSO/Makro/RSO_Beschriftung.mpr"',
'VA="L L"',
'VA="B B"',
'VA="KAVODI KAVODI"',
'VA="KALIDI KALIDI"',
'VA="KAHIDI KAHIDI"',
'VA="KAREDI KAREDI"',
`VA="GEDREHT ${angles.beschGedreht}"`,
'VA="NCSTOPP 0"',
'KO="04"',
'<139 \\Komponente\\',
'MNM="Formatierung"',
'IN="RSO/Makro/RSO_Format.mpr"',
'VA="L L"',
'VA="B B"',
'VA="D D"',
'VA="WKZ 316"',
'VA="KAVODI KAVODI"',
'VA="KALIDI KALIDI"',
'VA="KAHIDI KAHIDI"',
'VA="KAREDI KAREDI"',
'VA="DIST 0.0"',
'VA="ECKRUND 0.1"',
'VA="KAFR 0"',
'VA="KAFRMAX 2.0"',
'VA="MAT MAT"',
'??="FORMAT"',
'KO="04"',
'<121 \\Block\\',
'MNM="Bearbeitungen von oben "',
`DP="${dp}"`,
'KO="04"'
);
lines.push(...holeLines);
lines.push(...grooveSection(piece, L, B));
lines.push('!');
return lines.join('\r\n');
}
function attachGlobalSeq(pieceFlat, allFlat) {
return pieceFlat.map((entry) => {
const globalIdx = allFlat.findIndex(
(f) =>
f.piece.id === entry.piece.id &&
f.qtyIndex === entry.qtyIndex &&
f.slug === entry.slug
);
return { ...entry, seqGlobal: globalIdx >= 0 ? globalIdx + 1 : 1 };
});
}
function buildMprFileRecords(entries, mprOptions) {
const { groupIndex = 1, orderReference = '', stackListKm = '01', defaultMaterialArticle = '' } = mprOptions;
return entries.map((entry) => {
const fileName = `${groupIndex}_${entry.seqGlobal}_${safeZipFileName(entry.slug)}.mpr`;
const content = buildSingleMprText({
piece: entry.piece,
slug: entry.slug,
qtyIndex: entry.qtyIndex,
seqGlobal: entry.seqGlobal,
groupIndex,
orderReference,
stackListKm,
defaultMaterialArticle
});
return { fileName, content };
});
}
async function packMprRecords(records, zipBaseName) {
if (records.length === 1) {
const only = records[0];
return {
buffer: Buffer.from(only.content, 'utf8'),
fileName: only.fileName,
mimeType: 'application/octet-stream',
fileCount: 1
};
}
const zip = new JSZip();
for (const rec of records) {
zip.file(rec.fileName, rec.content);
}
const safeBase = safeZipFileName(zipBaseName || 'piece-mpr');
return {
buffer: await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }),
fileName: `${safeBase}.zip`,
mimeType: 'application/zip',
fileCount: records.length
};
}
/** Nombre de fichiers MPR générés pour une ligne de débitage (ex. côtés L+R → 2). */
export function countMprInstancesForPiece(piece) {
return flattenPiecesForMprExport([piece]).length;
}
export async function generateMprZipBuffer({
config,
modules = [],
colors = {},
orderReference = '',
stackListKm = '01',
defaultMaterialArticle = '',
/** Index groupe commande dans le nom fichier (premier nombre). */
groupIndex = 1
}) {
const cuttingList = generateCuttingList(config, colors, modules);
const flat = flattenPiecesForMprExport(cuttingList.pieces);
const withSeq = attachGlobalSeq(flat, flat);
const records = buildMprFileRecords(withSeq, {
groupIndex,
orderReference,
stackListKm,
defaultMaterialArticle
});
return {
...(await packMprRecords(records, `apex-mpr-g${groupIndex}`)),
fileCount: records.length
};
}
/**
* Export MPR pour une seule ligne de la liste de débitage (identifiée par pieceId).
* @param {number|null|undefined} instanceIndex — exemplaire 0-based ; omis = tous les exemplaires de la ligne.
*/
export async function generateMprPieceDownload({
config,
modules = [],
colors = {},
pieceId,
instanceIndex,
orderReference = '',
stackListKm = '01',
defaultMaterialArticle = '',
groupIndex = 1
}) {
if (!pieceId || typeof pieceId !== 'string') {
throw new Error('pieceId requis.');
}
const cuttingList = generateCuttingList(config, colors, modules);
const piece = (cuttingList.pieces || []).find((p) => p.id === pieceId);
if (!piece) {
throw new Error('Pièce introuvable dans la liste de débitage.');
}
const allFlat = flattenPiecesForMprExport(cuttingList.pieces);
let pieceFlat = attachGlobalSeq(flattenPiecesForMprExport([piece]), allFlat);
if (instanceIndex != null && instanceIndex !== '') {
const idx = Math.floor(Number(instanceIndex));
if (!Number.isFinite(idx) || idx < 0) {
throw new Error('instanceIndex invalide.');
}
pieceFlat = pieceFlat.filter((entry) => entry.qtyIndex === idx);
if (pieceFlat.length === 0) {
throw new Error('Exemplaire introuvable pour cette pièce.');
}
}
const records = buildMprFileRecords(pieceFlat, {
groupIndex,
orderReference,
stackListKm,
defaultMaterialArticle
});
return packMprRecords(records, pieceId);
}
function safeZipFileName(seg) {
return String(seg).replace(/[^\w\-]+/g, '_').replace(/_+/g, '_');
}
export default {
generateMprZipBuffer,
generateMprPieceDownload,
countMprInstancesForPiece,
flattenPiecesForMprExport
};
+219
View File
@@ -0,0 +1,219 @@
/**
* Packing Engine — APEX 4.0
* Layer-based bin packing algorithm (per Dossier Technique Maître §3.1).
*
* Strategy:
* Level 1 — Côtés empilés (side panels stacked flat)
* Level 2 — Dessus + Fond côte à côte (top & bottom side by side)
* Level 3 — Étagères + Renforts groupés par épaisseur (shelves & reinforcements)
*
* Within each level, pieces are packed using First-Fit-Decreasing by weight,
* respecting a 28 kg max per box.
*/
import { PACKING, PANEL } from '../../shared/manufacturing.js';
/**
* Estimate the weight of a single piece in kg.
* Uses approximate melamine panel density by thickness.
* @param {number} widthMm
* @param {number} heightMm
* @param {number} thicknessMm
* @returns {number} weight in kg for ONE piece
*/
function estimatePieceWeight(widthMm, heightMm, thicknessMm) {
const volumeM3 = (widthMm / 1000) * (heightMm / 1000) * (thicknessMm / 1000);
let density;
if (thicknessMm >= PANEL.structure) {
density = PACKING.densityKgM3.melamine19;
} else if (thicknessMm >= PANEL.reinforcement) {
density = PACKING.densityKgM3.melamine16;
} else {
density = PACKING.densityKgM3.melamine8;
}
return volumeM3 * density;
}
/**
* Expand cutting list pieces into individual packable items.
* Each piece with quantity > 1 becomes multiple single items.
* @param {Array} pieces - pieces from cutting list
* @returns {Array} flat array of single items with weight
*/
function expandPieces(pieces) {
const items = [];
for (const piece of pieces) {
// Skip non-physical items (edge banding, etc.)
if (piece.unit === 'ml' || piece.widthMm === 0 || piece.heightMm === 0) continue;
const weightPerPiece = estimatePieceWeight(piece.widthMm, piece.heightMm, piece.thicknessMm);
for (let i = 0; i < piece.quantity; i++) {
items.push({
pieceId: piece.id,
elementType: piece.elementType,
description: piece.description,
widthMm: piece.widthMm,
heightMm: piece.heightMm,
thicknessMm: piece.thicknessMm,
quantity: 1,
weightKg: Math.round(weightPerPiece * 100) / 100,
index: i,
});
}
}
return items;
}
/**
* Classify expanded items into packing layers per APEX 4.0 §3.1.
*
* Level 1: cotes (side panels) — stacked
* Level 2: dessus + fond (top & bottom) — side by side
* Level 3: everything else grouped by thickness (etageres, renforts, panneauxInterieurs, dos, etc.)
*
* @param {Array} items - expanded single-piece items
* @returns {Object} { level1, level2, level3 } arrays
*/
function classifyByLayer(items) {
const level1 = []; // Côtés
const level2 = []; // Dessus + Fond
const level3 = []; // Étagères, Renforts, Séparateurs, Dos, Portes, etc.
for (const item of items) {
switch (item.elementType) {
case 'cotes':
case 'cotesDoublage':
level1.push(item);
break;
case 'dessus':
case 'fond':
level2.push(item);
break;
default:
level3.push(item);
break;
}
}
// Sort level 3 by thickness (group same thickness together) then by weight desc
level3.sort((a, b) => {
if (a.thicknessMm !== b.thicknessMm) return b.thicknessMm - a.thicknessMm;
return b.weightKg - a.weightKg;
});
return { level1, level2, level3 };
}
/**
* First-Fit-Decreasing bin packing for a set of items.
* Sort items by weight (heaviest first), then assign each to the
* first box that still has capacity.
*
* @param {Array} items - items to pack
* @param {number} startBoxId - starting box ID
* @param {string} levelLabel - label for these boxes (e.g. "Niveau 1")
* @returns {{ boxes: Array, nextBoxId: number }}
*/
function firstFitDecreasing(items, startBoxId = 1, levelLabel = '') {
if (items.length === 0) return { boxes: [], nextBoxId: startBoxId };
const sorted = [...items].sort((a, b) => b.weightKg - a.weightKg);
const maxWeight = PACKING.maxWeightKg;
const boxes = [];
for (const item of sorted) {
// If the single item exceeds max weight, it gets its own box
if (item.weightKg > maxWeight) {
boxes.push({
id: startBoxId + boxes.length,
level: levelLabel,
pieces: [item],
totalWeightKg: item.weightKg,
});
continue;
}
// Try to fit in an existing box
let placed = false;
for (const box of boxes) {
if (box.totalWeightKg + item.weightKg <= maxWeight) {
box.pieces.push(item);
box.totalWeightKg = Math.round((box.totalWeightKg + item.weightKg) * 100) / 100;
placed = true;
break;
}
}
// Open a new box
if (!placed) {
boxes.push({
id: startBoxId + boxes.length,
level: levelLabel,
pieces: [item],
totalWeightKg: item.weightKg,
});
}
}
return { boxes, nextBoxId: startBoxId + boxes.length };
}
/**
* Generate packing list from a cutting list result.
* Uses the APEX 4.0 layer-based strategy:
* Level 1 → sides stacked
* Level 2 → top + bottom side by side
* Level 3 → shelves + reinforcements grouped by thickness
*
* Each level is independently bin-packed with FFD, then all boxes
* are merged into a single sequential list.
*
* @param {Object} cuttingList - output of generateCuttingList()
* @returns {Object} packing summary
*/
export function generatePackingList(cuttingList) {
if (!cuttingList || !cuttingList.pieces || cuttingList.pieces.length === 0) {
return {
boxes: [],
totalBoxes: 0,
maxWeightKg: PACKING.maxWeightKg,
heaviestBoxKg: 0,
totalWeightKg: 0,
strategy: 'layer-based (APEX 4.0)',
};
}
const items = expandPieces(cuttingList.pieces);
const { level1, level2, level3 } = classifyByLayer(items);
// Pack each level independently
const pack1 = firstFitDecreasing(level1, 1, 'Niveau 1 — Côtés');
const pack2 = firstFitDecreasing(level2, pack1.nextBoxId, 'Niveau 2 — Dessus/Fond');
const pack3 = firstFitDecreasing(level3, pack2.nextBoxId, 'Niveau 3 — Étagères/Renforts');
const allBoxes = [...pack1.boxes, ...pack2.boxes, ...pack3.boxes];
const heaviestBoxKg = allBoxes.reduce((max, b) => Math.max(max, b.totalWeightKg), 0);
const totalWeightKg = allBoxes.reduce((sum, b) => sum + b.totalWeightKg, 0);
return {
boxes: allBoxes,
totalBoxes: allBoxes.length,
maxWeightKg: PACKING.maxWeightKg,
heaviestBoxKg: Math.round(heaviestBoxKg * 10) / 10,
totalWeightKg: Math.round(totalWeightKg * 10) / 10,
strategy: 'layer-based (APEX 4.0)',
layerSummary: {
level1: { label: 'Côtés empilés', boxes: pack1.boxes.length, items: level1.length },
level2: { label: 'Dessus + Fond', boxes: pack2.boxes.length, items: level2.length },
level3: { label: 'Étagères + Renforts', boxes: pack3.boxes.length, items: level3.length },
},
};
}
export default { generatePackingList };
+551 -151
View File
@@ -1,26 +1,24 @@
/**
* Plan Generator - Generate 2D assembly and parts plans in SVG format
* Also handles PDF export
* Includes APEX 4.0 manufacturing details: System 32 drilling, hinge
* positions, Ixconnect connectors, back panel groove, and reinforcements.
* Also handles PDF export.
*/
import {
PANEL, GROOVE, SYSTEM32, SYSTEM32_SEPARATORS, HINGE, CONNECTORS,
getHingeCountForDoor, getHingeCupPositions, getDoorHingeSide, HANDLE,
EDGE_BANDING, REINFORCEMENT, REINFORCEMENT_MACHINING, PLINTH, SHELF_MACHINING
} from '../../shared/manufacturing.js';
// Constants for drawing
const DRAWING_SCALE = 0.1; // 1mm = 0.1px
const MARGIN = 50;
const LABEL_OFFSET = 15;
const ARROW_SIZE = 8;
/**
* Generate SVG for assembly view (front elevation)
*/
export function generateAssemblyPlan(config, modules = []) {
const { widthMm, heightMm, plinthHeightMm, doorType, installationType } = config;
const bodyHeight = heightMm - plinthHeightMm;
const svgWidth = widthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = heightMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
// ── Shared SVG styles ────────────────────────────────────────────────
const SHARED_STYLES = `
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.inner { fill: none; stroke: #666; stroke-width: 0.5; }
.dimension { fill: none; stroke: #0066cc; stroke-width: 0.5; }
@@ -31,140 +29,85 @@ export function generateAssemblyPlan(config, modules = []) {
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.door { fill: #d4c4a4; stroke: #333; stroke-width: 1; opacity: 0.8; }
.separator { stroke: #666; stroke-width: 0.5; stroke-dasharray: 4,2; }
</style>`;
.s32-hole { fill: none; stroke: #e63946; stroke-width: 0.4; }
.hinge-marker { fill: #ff6b35; stroke: #333; stroke-width: 0.3; }
.groove { fill: none; stroke: #2a9d8f; stroke-width: 1; stroke-dasharray: 2,2; }
.connector { fill: #6c5ce7; stroke: #333; stroke-width: 0.3; }
.renfort { fill: #c9a96e; stroke: #333; stroke-width: 0.5; opacity: 0.7; }
.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; }
`;
// Title
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de face - Élévation</text>`;
// ── Helpers ──────────────────────────────────────────────────────────
const offsetX = MARGIN;
const offsetY = MARGIN;
/**
* Draw System 32 hole column (vertical series of holes)
*/
function drawSystem32Holes(x, topY, panelHeightMm, scale) {
let svg = '';
const usable = panelHeightMm - SYSTEM32.firstHoleOffset;
const count = Math.max(0, Math.floor(usable / SYSTEM32.pitch) + 1);
const r = (SYSTEM32.holeDiameter / 2) * scale;
// Plinth
const plinthY = offsetY + bodyHeight * DRAWING_SCALE;
svg += `<rect x="${offsetX}" y="${plinthY}" width="${widthMm * DRAWING_SCALE}" height="${plinthHeightMm * DRAWING_SCALE}" class="plinth" />`;
// Body
svg += `<rect x="${offsetX}" y="${offsetY}" width="${widthMm * DRAWING_SCALE}" height="${bodyHeight * DRAWING_SCALE}" class="body" />`;
// Module separators
if (modules.length > 0) {
let currentX = offsetX;
for (let i = 0; i < modules.length - 1; i++) {
currentX += modules[i].widthMm * DRAWING_SCALE;
svg += `<line x1="${currentX}" y1="${offsetY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
}
for (let i = 0; i < count; i++) {
const yOffset = (SYSTEM32.firstHoleOffset + i * SYSTEM32.pitch) * scale;
svg += `<circle cx="${x}" cy="${topY + yOffset}" r="${r}" class="s32-hole" />`;
}
return svg;
}
// Doors
if (doorType === 'sliding') {
const panelCount = config.slidingPanelsCount || 2;
const panelWidth = (widthMm / panelCount) * DRAWING_SCALE;
for (let i = 0; i < panelCount; i++) {
svg += `<rect x="${offsetX + i * panelWidth * 0.95 + 5}" y="${offsetY + 5}" width="${panelWidth * 0.9}" height="${(bodyHeight - 10) * DRAWING_SCALE}" class="door" />`;
/**
* Draw hinge position markers on a door plan (côté charnières)
*/
function drawHingeMarkers(doorX, doorY, doorWidthMm, doorHeightMm, scale, hingeSide = 'left') {
let svg = '';
const markerH = 6;
const markerW = 3;
const hingeCount = getHingeCountForDoor({ heightMm: doorHeightMm });
const positions = getHingeCupPositions(doorHeightMm, hingeCount);
const doorW = doorWidthMm * scale;
const cupInset = Math.min(22 * scale, doorW * 0.13);
const hingeLineX = hingeSide === 'right' ? doorX + doorW - cupInset : doorX + cupInset;
for (const posMm of positions) {
const y = doorY + posMm * scale;
svg += `<rect x="${hingeLineX - markerW / 2}" y="${y - markerH / 2}" width="${markerW}" height="${markerH}" class="hinge-marker" />`;
}
return svg;
}
/**
* Rainure poignée toute hauteur — côté opposé aux charnières, retrait H/B (HANDLE.fullHeightTopBottomOffset)
*/
function drawFullHeightHandleGrooveElevation(doorX, doorY, doorWidthMm, doorHeightMm, scale, hingeSide) {
const off = HANDLE.fullHeightTopBottomOffset;
const gw = HANDLE.fullHeightGroove.width;
const doorW = doorWidthMm * scale;
const axisX = hingeSide === 'right'
? doorX + (gw / 2) * scale
: doorX + doorW - (gw / 2) * scale;
const y1 = doorY + off * scale;
const y2 = doorY + doorHeightMm * scale - off * scale;
let svg = `<line x1="${axisX}" y1="${y1}" x2="${axisX}" y2="${y2}" class="fh-groove" />`;
const tx = axisX + (hingeSide === 'right' ? 5 : -5);
const anchor = hingeSide === 'right' ? 'start' : 'end';
svg += `<text x="${tx}" y="${doorY + doorHeightMm * scale * 0.45}" class="detail-label" text-anchor="${anchor}" fill="#0d9488">Rainure poignée TH (${gw} mm) · ${off} mm H/B</text>`;
return svg;
}
/**
* Draw back panel groove indicator (dashed line near panel edge).
* Per updated spec: groove at 16mm from panel edge.
*/
function drawGrooveIndicator(x, y, length, direction, scale) {
let svg = '';
const grooveOffset = GROOVE.back.axisFromPanelEdge * scale;
if (direction === 'vertical') {
svg += `<line x1="${x + grooveOffset}" y1="${y}" x2="${x + grooveOffset}" y2="${y + length}" class="groove" />`;
} else {
const blockCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
let currentX = offsetX;
const items = modules.length > 0 ? modules : (config.blocks || []);
for (let i = 0; i < blockCount; i++) {
const blockWidth = items[i]?.widthMm || (widthMm / blockCount);
svg += `<rect x="${currentX + 3}" y="${offsetY + 3}" width="${blockWidth * DRAWING_SCALE - 6}" height="${(bodyHeight - 6) * DRAWING_SCALE}" class="door" />`;
currentX += blockWidth * DRAWING_SCALE;
svg += `<line x1="${x}" y1="${y + grooveOffset}" x2="${x + length}" y2="${y + grooveOffset}" class="groove" />`;
}
}
// Dimension lines
svg += generateDimensionLine(offsetX, plinthY + plinthHeightMm * DRAWING_SCALE + 20, widthMm * DRAWING_SCALE, `${widthMm} mm`, 'horizontal');
svg += generateDimensionLine(offsetX - 20, offsetY, bodyHeight * DRAWING_SCALE, `${bodyHeight} mm`, 'vertical');
svg += generateDimensionLine(offsetX - 40, offsetY, heightMm * DRAWING_SCALE, `${heightMm} mm (total)`, 'vertical');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for side view
*/
export function generateSidePlan(config) {
const { heightMm, depthMm, plinthHeightMm } = config;
const bodyHeight = heightMm - plinthHeightMm;
const svgWidth = depthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = heightMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.plinth { fill: #e0d0c0; stroke: #333; stroke-width: 1; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.dim-text { font-family: Arial, sans-serif; font-size: 10px; fill: #0066cc; }
</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de côté</text>`;
const offsetX = MARGIN;
const offsetY = MARGIN;
// Plinth
const plinthY = offsetY + bodyHeight * DRAWING_SCALE;
svg += `<rect x="${offsetX}" y="${plinthY}" width="${depthMm * DRAWING_SCALE}" height="${plinthHeightMm * DRAWING_SCALE}" class="plinth" />`;
// Body
svg += `<rect x="${offsetX}" y="${offsetY}" width="${depthMm * DRAWING_SCALE}" height="${bodyHeight * DRAWING_SCALE}" class="body" />`;
// Dimension
svg += generateDimensionLine(offsetX, plinthY + plinthHeightMm * DRAWING_SCALE + 20, depthMm * DRAWING_SCALE, `${depthMm} mm`, 'horizontal');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for top view
*/
export function generateTopPlan(config, modules = []) {
const { widthMm, depthMm } = config;
const svgWidth = widthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = depthMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.separator { stroke: #666; stroke-width: 0.5; stroke-dasharray: 4,2; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.label { font-family: Arial, sans-serif; font-size: 8px; fill: #666; }
</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de dessus</text>`;
const offsetX = MARGIN;
const offsetY = MARGIN;
// Body outline
svg += `<rect x="${offsetX}" y="${offsetY}" width="${widthMm * DRAWING_SCALE}" height="${depthMm * DRAWING_SCALE}" class="body" />`;
// Module separators and labels
if (modules.length > 0) {
let currentX = offsetX;
for (let i = 0; i < modules.length; i++) {
const module = modules[i];
const moduleWidth = module.widthMm * DRAWING_SCALE;
// Label
svg += `<text x="${currentX + moduleWidth / 2}" y="${offsetY + depthMm * DRAWING_SCALE / 2}" class="label" text-anchor="middle">${module.moduleCode || `M${i + 1}`}</text>`;
// Separator
if (i < modules.length - 1) {
currentX += moduleWidth;
svg += `<line x1="${currentX}" y1="${offsetY}" x2="${currentX}" y2="${offsetY + depthMm * DRAWING_SCALE}" class="separator" />`;
}
}
}
svg += '</svg>';
return svg;
}
@@ -175,26 +118,367 @@ function generateDimensionLine(x, y, length, label, direction = 'horizontal') {
let svg = '';
if (direction === 'horizontal') {
// Line
svg += `<line x1="${x}" y1="${y}" x2="${x + length}" y2="${y}" class="dimension" />`;
// Arrows
svg += `<polygon points="${x},${y} ${x + ARROW_SIZE},${y - 3} ${x + ARROW_SIZE},${y + 3}" fill="#0066cc" />`;
svg += `<polygon points="${x + length},${y} ${x + length - ARROW_SIZE},${y - 3} ${x + length - ARROW_SIZE},${y + 3}" fill="#0066cc" />`;
// Label
svg += `<text x="${x + length / 2}" y="${y + LABEL_OFFSET}" class="dim-text" text-anchor="middle">${label}</text>`;
} else {
// Line
svg += `<line x1="${x}" y1="${y}" x2="${x}" y2="${y + length}" class="dimension" />`;
// Arrows
svg += `<polygon points="${x},${y} ${x - 3},${y + ARROW_SIZE} ${x + 3},${y + ARROW_SIZE}" fill="#0066cc" />`;
svg += `<polygon points="${x},${y + length} ${x - 3},${y + length - ARROW_SIZE} ${x + 3},${y + length - ARROW_SIZE}" fill="#0066cc" />`;
// Label (rotated)
svg += `<text x="${x - LABEL_OFFSET}" y="${y + length / 2}" class="dim-text" text-anchor="middle" transform="rotate(-90 ${x - LABEL_OFFSET} ${y + length / 2})">${label}</text>`;
}
return svg;
}
// ── Plan generators ──────────────────────────────────────────────────
/**
* Generate SVG for assembly view (front elevation)
*/
export function generateAssemblyPlan(config, modules = []) {
const { widthMm, heightMm, plinthHeightMm, doorType } = config;
const bodyHeight = heightMm - plinthHeightMm;
const S = DRAWING_SCALE;
const svgWidth = widthMm * S + MARGIN * 2;
const svgHeight = heightMm * S + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>${SHARED_STYLES}</style>`;
// Title
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de face - Élévation</text>`;
const oX = MARGIN;
const oY = MARGIN;
// Plinth
const plinthY = oY + bodyHeight * S;
svg += `<rect x="${oX}" y="${plinthY}" width="${widthMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
// Body
svg += `<rect x="${oX}" y="${oY}" width="${widthMm * S}" height="${bodyHeight * S}" class="body" />`;
// Module separators
if (modules.length > 0) {
let currentX = oX;
for (let i = 0; i < modules.length - 1; i++) {
currentX += modules[i].widthMm * S;
svg += `<line x1="${currentX}" y1="${oY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
}
}
// System 32 holes on left and right side panels
svg += drawSystem32Holes(oX + PANEL.structure * S / 2, oY + PANEL.structure * S, bodyHeight - PANEL.structure * 2, S);
svg += drawSystem32Holes(oX + widthMm * S - PANEL.structure * S / 2, oY + PANEL.structure * S, bodyHeight - PANEL.structure * 2, S);
// Doors
if (doorType === 'sliding') {
const panelCount = config.slidingPanelsCount || 2;
const panelWidth = (widthMm / panelCount) * S;
for (let i = 0; i < panelCount; i++) {
svg += `<rect x="${oX + i * panelWidth * 0.95 + 5}" y="${oY + 5}" width="${panelWidth * 0.9}" height="${(bodyHeight - 10) * S}" class="door" />`;
}
} else {
const blockCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
let currentX = oX;
const items = modules.length > 0 ? modules : (config.blocks || []);
const hw = config.hardware || {};
const fullHeightHandle = hw.handleType === 'full_height';
const widthDeductionMm = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0;
const gapMm = 2;
const doorHmm = bodyHeight - 4;
for (let i = 0; i < blockCount; i++) {
const bw = items[i]?.widthMm || (widthMm / blockCount);
const doorWmm = Math.max(100, Math.floor(widthMm / blockCount) - 4 - widthDeductionMm);
const modulePx = bw * S;
const doorWPx = doorWmm * S;
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);
svg += drawHingeMarkers(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
if (fullHeightHandle) {
svg += drawFullHeightHandleGrooveElevation(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
}
currentX += modulePx;
}
}
// Reinforcements (Forteresse 16 mm):
// width = armoire width - 2×19 (entre côtés),
// one at top + one at bottom (offset 19 mm),
// third one centered if wardrobe height > 1500 mm.
const renfortX = oX + PANEL.structure * S;
const renfortW = Math.max(0, (widthMm - PANEL.structure * 2) * S);
const renfortH = PANEL.reinforcement * S;
const topRenfortY = oY + REINFORCEMENT.topBottomOffsetMm * S;
const bottomRenfortY = plinthY - REINFORCEMENT.topBottomOffsetMm * S - renfortH;
const reinforcementYs = [topRenfortY, bottomRenfortY];
if (heightMm > REINFORCEMENT.thirdRequiredAboveHeightMm) {
reinforcementYs.push(oY + (bodyHeight * S) / 2 - renfortH / 2);
}
for (const y of reinforcementYs) {
svg += `<rect x="${renfortX}" y="${y}" width="${renfortW}" height="${renfortH}" class="renfort" />`;
}
svg += `<text x="${oX + (widthMm * S) / 2}" y="${svgHeight - 10}" class="detail-label" text-anchor="middle" fill="#6b5344" font-size="7.5">Renforts Forteresse (16 mm) : fixation par montant — ${REINFORCEMENT_MACHINING.connectorModel} au centre + 2× tourillons Ø${REINFORCEMENT_MACHINING.tourillonDiameter} à ${REINFORCEMENT_MACHINING.tourillonEdgeOffset} mm des bords · axes sur plan pièce « Renforts »</text>`;
// Dimension lines
svg += generateDimensionLine(oX, plinthY + plinthHeightMm * S + 20, widthMm * S, `${widthMm} mm`, 'horizontal');
svg += generateDimensionLine(oX - 20, oY, bodyHeight * S, `${bodyHeight} mm`, 'vertical');
svg += generateDimensionLine(oX - 40, oY, heightMm * S, `${heightMm} mm (total)`, 'vertical');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for side view — includes groove indicator and System 32 holes
*/
export function generateSidePlan(config) {
const { heightMm, depthMm, plinthHeightMm } = config;
const bodyHeight = heightMm - plinthHeightMm;
const S = DRAWING_SCALE;
const svgWidth = depthMm * S + MARGIN * 2;
const svgHeight = heightMm * S + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>${SHARED_STYLES}</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de côté</text>`;
const oX = MARGIN;
const oY = MARGIN;
// Plinth
const plinthY = oY + bodyHeight * S;
svg += `<rect x="${oX}" y="${plinthY}" width="${depthMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
// 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
const backX = oX + (depthMm - PANEL.back) * S;
svg += `<rect x="${backX}" y="${oY + PANEL.structure * S}" width="${PANEL.back * S}" height="${(bodyHeight - PANEL.structure * 2) * S}" fill="#d4c4a4" stroke="#666" stroke-width="0.5" />`;
svg += `<text x="${backX - 2}" y="${oY + bodyHeight * S / 2}" class="detail-label" text-anchor="end">Dos ${PANEL.back}mm</text>`;
// Groove indicator (dashed green line at 16mm from panel edge)
const grooveAxisX = oX + (depthMm - GROOVE.back.axisFromPanelEdge) * S;
svg += `<line x1="${grooveAxisX}" y1="${oY + PANEL.structure * S}" x2="${grooveAxisX}" y2="${plinthY - PANEL.structure * S}" class="groove" />`;
svg += `<text x="${grooveAxisX - 3}" y="${oY + bodyHeight * S * 0.3}" class="detail-label" text-anchor="end" fill="#2a9d8f">Rainure ${GROOVE.back.width}×${GROOVE.back.depth} (16mm du bord)</text>`;
// System 32 holes along the side panel
svg += drawSystem32Holes(oX + 37 * S, oY + PANEL.structure * S, bodyHeight - PANEL.structure * 2, S);
svg += `<text x="${oX + 37 * S}" y="${oY - 5}" class="detail-label" text-anchor="middle" fill="#e63946">System 32</text>`;
// Dimension lines
svg += generateDimensionLine(oX, plinthY + plinthHeightMm * S + 20, depthMm * S, `${depthMm} mm`, 'horizontal');
svg += generateDimensionLine(oX - 20, oY, bodyHeight * S, `${bodyHeight} mm`, 'vertical');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for top view — includes groove line for back panel
*/
export function generateTopPlan(config, modules = []) {
const { widthMm, depthMm } = config;
const S = DRAWING_SCALE;
const svgWidth = widthMm * S + MARGIN * 2;
const svgHeight = depthMm * S + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>${SHARED_STYLES}</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de dessus</text>`;
const oX = MARGIN;
const oY = MARGIN;
// Body outline
svg += `<rect x="${oX}" y="${oY}" width="${widthMm * S}" height="${depthMm * S}" class="body" />`;
// Back panel groove line (horizontal dashed line near panel edge)
const grooveY = oY + (depthMm - PANEL.structure) * S;
svg += `<line x1="${oX + PANEL.structure * S}" y1="${grooveY}" x2="${oX + (widthMm - PANEL.structure) * S}" y2="${grooveY}" class="groove" />`;
svg += `<text x="${oX + widthMm * S + 5}" y="${grooveY + 3}" class="detail-label" fill="#2a9d8f">Rainure dos</text>`;
// Module separators and labels
if (modules.length > 0) {
let currentX = oX;
for (let i = 0; i < modules.length; i++) {
const module = modules[i];
const moduleWidth = module.widthMm * S;
// Label
svg += `<text x="${currentX + moduleWidth / 2}" y="${oY + depthMm * S / 2}" class="label" text-anchor="middle">${module.moduleCode || `M${i + 1}`}</text>`;
// Separator
if (i < modules.length - 1) {
currentX += moduleWidth;
svg += `<line x1="${currentX}" y1="${oY}" x2="${currentX}" y2="${oY + depthMm * S}" class="separator" />`;
}
}
}
svg += '</svg>';
return svg;
}
/**
* Generate SVG detail plan — zoomed view of a side panel showing
* System 32 drilling, connector holes, groove, and edge banding
*/
export function generateDetailPlan(config) {
const { depthMm, heightMm, plinthHeightMm } = config;
const bodyHeight = heightMm - plinthHeightMm;
const innerWidth = config.widthMm - (PANEL.structure * 2);
const innerDepth = depthMm - PANEL.structure;
// Zoomed scale for detail view (3× normal)
const DS = 0.3;
// Show top 600mm of a side panel (enough for connectors, first S32 holes, groove)
const detailHeightMm = Math.min(600, bodyHeight);
const detailWidthMm = depthMm;
// Extra space on the right for shelf drilling inset
const svgWidth = detailWidthMm * DS + MARGIN * 2 + 260;
const svgHeight = detailHeightMm * DS + MARGIN * 2 + 40;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>${SHARED_STYLES}
.edge-band { fill: #ff9f1c; stroke: none; opacity: 0.6; }
.callout { font-family: Arial, sans-serif; font-size: 8px; fill: #333; }
.callout-line { stroke: #999; stroke-width: 0.3; fill: none; }
.shelf-outline { fill: #f9fafb; stroke: #111827; stroke-width: 0.8; }
.shelf-face-hole { fill: none; stroke: #ea580c; stroke-width: 0.55; }
.shelf-edge-hole { fill: none; stroke: #7c3aed; stroke-width: 0.55; stroke-dasharray: 1.2 1.2; }
</style>`;
svg += `<text x="${svgWidth / 2}" y="20" class="title" text-anchor="middle">Détail — Côté (vue intérieure)</text>`;
const oX = MARGIN + 40;
const oY = MARGIN + 20;
// Panel outline
svg += `<rect x="${oX}" y="${oY}" width="${detailWidthMm * DS}" height="${detailHeightMm * DS}" class="body" stroke-width="2" />`;
// Edge banding (2mm ABS on all 4 edges)
const eb = EDGE_BANDING.abs * DS;
svg += `<rect x="${oX}" y="${oY}" width="${detailWidthMm * DS}" height="${eb}" class="edge-band" />`; // top
svg += `<rect x="${oX}" y="${oY + detailHeightMm * DS - eb}" width="${detailWidthMm * DS}" height="${eb}" class="edge-band" />`; // bottom
svg += `<rect x="${oX}" y="${oY}" width="${eb}" height="${detailHeightMm * DS}" class="edge-band" />`; // left
svg += `<rect x="${oX + detailWidthMm * DS - eb}" y="${oY}" width="${eb}" height="${detailHeightMm * DS}" class="edge-band" />`; // right
// Back panel groove — 16mm from panel edge
const grooveAxisOffset = (detailWidthMm - GROOVE.back.axisFromPanelEdge) * DS;
const grooveX = oX + grooveAxisOffset - (GROOVE.back.width / 2) * DS;
const grooveW = GROOVE.back.width * DS;
svg += `<rect x="${grooveX}" y="${oY + 5}" width="${grooveW}" height="${detailHeightMm * DS - 10}" fill="#2a9d8f" opacity="0.3" stroke="#2a9d8f" stroke-width="0.5" />`;
// Callout
svg += `<line x1="${grooveX + grooveW}" y1="${oY + 30}" x2="${grooveX + grooveW + 25}" y2="${oY + 20}" class="callout-line" />`;
svg += `<text x="${grooveX + grooveW + 27}" y="${oY + 22}" class="callout">Rainure ${GROOVE.back.width}×${GROOVE.back.depth} mm</text>`;
svg += `<text x="${grooveX + grooveW + 27}" y="${oY + 31}" class="callout">Position à ${GROOVE.back.axisFromPanelEdge} mm du bord</text>`;
// System 32 holes (front row) — blind holes, depth 12mm
const s32X = oX + 37 * DS; // 37mm from front edge
const usable = detailHeightMm - SYSTEM32.firstHoleOffset * 2; // first AND last at 51mm
const holeCount = Math.max(0, Math.floor(usable / SYSTEM32.pitch) + 1);
const r = (SYSTEM32.holeDiameter / 2) * DS;
for (let i = 0; i < Math.min(holeCount, 15); i++) {
const holeY = oY + (SYSTEM32.firstHoleOffset + i * SYSTEM32.pitch) * DS;
svg += `<circle cx="${s32X}" cy="${holeY}" r="${r}" class="s32-hole" />`;
}
// Callout for System 32
svg += `<line x1="${s32X}" y1="${oY + SYSTEM32.firstHoleOffset * DS}" x2="${oX - 15}" y2="${oY + SYSTEM32.firstHoleOffset * DS - 10}" class="callout-line" />`;
svg += `<text x="${oX - 17}" y="${oY + SYSTEM32.firstHoleOffset * DS - 12}" class="callout" text-anchor="end">System 32 (borgne)</text>`;
svg += `<text x="${oX - 17}" y="${oY + SYSTEM32.firstHoleOffset * DS - 3}" class="callout" text-anchor="end">Ø${SYSTEM32.holeDiameter} / prof. ${SYSTEM32.holeDepth} / pas ${SYSTEM32.pitch}</text>`;
// First hole offset dimension
svg += generateDimensionLine(oX + detailWidthMm * DS + 10, oY, SYSTEM32.firstHoleOffset * DS, `${SYSTEM32.firstHoleOffset}`, 'vertical');
// Ixconnect connector holes (top edge) — extrémité d’arête: T@20 → IX@50 → T@80 (vue détail côté)
const conn = CONNECTORS.ixconnect;
const pattern = CONNECTORS.jointPattern;
// Show 3 holes: tourillon@20, ixconnect@50, tourillon@80 from left edge (arête extrémité)
const jT1X = oX + pattern.tourillon1Axis * DS;
const jIXX = oX + pattern.ixconnectAxis * DS;
const jT2X = oX + pattern.tourillon2Axis * DS;
const tourR = (CONNECTORS.tourillonDiameter / 2) * DS;
const ixEdgeR = (conn.edgeHole.diameter / 2) * DS;
// Edge holes on top edge
svg += `<circle cx="${jT1X}" cy="${oY}" r="${tourR}" class="connector" opacity="0.5" />`; // tourillon 1
svg += `<circle cx="${jIXX}" cy="${oY}" r="${ixEdgeR}" class="connector" />`; // Ixconnect
svg += `<circle cx="${jT2X}" cy="${oY}" r="${tourR}" class="connector" opacity="0.5" />`; // tourillon 2
// Face holes (Ixconnect only — Ø8×12mm)
svg += `<circle cx="${jIXX}" cy="${oY + conn.faceHole.depth * DS}" r="${(conn.faceHole.diameter / 2) * DS}" class="connector" />`;
// Callout
svg += `<line x1="${jT2X + 5}" y1="${oY}" x2="${jT2X + 30}" y2="${oY - 15}" class="callout-line" />`;
svg += `<text x="${jT2X + 32}" y="${oY - 22}" class="callout">Ixconnect ${conn.model}</text>`;
svg += `<text x="${jT2X + 32}" y="${oY - 13}" class="callout">About: Ø${conn.edgeHole.diameter}×${conn.edgeHole.depth} / Face: Ø${conn.faceHole.diameter}×${conn.faceHole.depth}</text>`;
svg += `<text x="${jT2X + 32}" y="${oY - 4}" class="callout">Pattern: T@${pattern.tourillon1Axis} → IX@${pattern.ixconnectAxis} → T@${pattern.tourillon2Axis}</text>`;
// Panel thickness label
svg += `<text x="${oX + detailWidthMm * DS / 2}" y="${oY + detailHeightMm * DS + 15}" class="callout" text-anchor="middle">Épaisseur: ${PANEL.structure} mm — Chant ABS ${EDGE_BANDING.abs} mm</text>`;
// --- Inset: Rayons mobiles (Étagères amovibles) — SP 15/8/5 ---
// Dimensions de découpe : L.int - 2 mm / P.int - 5 mm
const shelfWmm = Math.max(80, innerWidth - 2);
const shelfDmm = Math.max(80, innerDepth - 5);
const insetMaxWpx = 170;
const insetMaxHpx = 190;
const insetScale = Math.min(insetMaxWpx / shelfWmm, insetMaxHpx / shelfDmm);
const insetX = oX + detailWidthMm * DS + 60;
const insetY = oY + 40;
const insetW = shelfWmm * insetScale;
const insetH = shelfDmm * insetScale;
svg += `<text x="${insetX + insetW / 2}" y="${insetY - 16}" class="callout" text-anchor="middle">Rayons mobiles — SP 15/8/5 (dessous)</text>`;
svg += `<rect x="${insetX}" y="${insetY}" width="${insetW}" height="${insetH}" class="shelf-outline" />`;
// Hole positions: from front 32mm; from back 37mm
const yFrontMm = 32;
const yBackMm = shelfDmm - 37;
const yPosMm = [yFrontMm, yBackMm].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
// Face holes: Ø20 axis 9.5mm from left/right
const xSideMm = 9.5;
const xPosMm = [xSideMm, shelfWmm - xSideMm].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
const rFace = (20 / 2) * insetScale;
const rEdge = (8 / 2) * insetScale;
for (const yMm of yPosMm) {
const cy = insetY + yMm * insetScale;
// Face holes (underside) — left/right
for (const xMm of xPosMm) {
const cx = insetX + xMm * insetScale;
svg += `<circle cx="${cx}" cy="${cy}" r="${rFace}" class="shelf-face-hole" />`;
}
// Edge holes (against side panels) — left/right edge
svg += `<circle cx="${insetX}" cy="${cy}" r="${rEdge}" class="shelf-edge-hole" />`;
svg += `<circle cx="${insetX + insetW}" cy="${cy}" r="${rEdge}" class="shelf-edge-hole" />`;
}
// Callouts
svg += `<line x1="${insetX + insetW}" y1="${insetY + 10}" x2="${insetX + insetW + 22}" y2="${insetY}" class="callout-line" />`;
svg += `<text x="${insetX + insetW + 24}" y="${insetY + 2}" class="callout">Face: Ø20×15 — axe 9.5 mm</text>`;
svg += `<text x="${insetX + insetW + 24}" y="${insetY + 12}" class="callout">Chant: Ø8×37 — à 6 mm du dessous</text>`;
svg += `<text x="${insetX + insetW + 24}" y="${insetY + 22}" class="callout">Avant: 32 mm · Arrière: 37 mm</text>`;
svg += '</svg>';
return svg;
}
/**
* Generate all plans for a configuration
*/
@@ -203,8 +487,10 @@ export function generateAllPlans(config, modules = [], colors = {}) {
assembly: generateAssemblyPlan(config, modules),
side: generateSidePlan(config),
top: generateTopPlan(config, modules),
detail: generateDetailPlan(config),
metadata: {
generatedAt: new Date().toISOString(),
specs: 'APEX 4.0',
config: {
widthMm: config.widthMm,
heightMm: config.heightMm,
@@ -220,14 +506,14 @@ export function generateAllPlans(config, modules = [], colors = {}) {
/**
* Generate HTML page with all plans for PDF export
*/
export function generatePDFHtml(config, modules = [], cuttingList = null) {
export function generatePDFHtml(config, modules = [], cuttingList = null, packingList = null) {
const plans = generateAllPlans(config, modules);
let html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Plans Armoire</title>
<title>Plans Armoire — APEX 4.0</title>
<style>
@page { size: A4; margin: 15mm; }
body { font-family: Arial, sans-serif; font-size: 11px; }
@@ -244,13 +530,17 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
.summary h3 { margin: 0 0 10px; }
.specs { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.spec-item { display: flex; justify-content: space-between; }
.mfg-specs { background: #fff8e7; padding: 12px; border-radius: 6px; margin: 15px 0; border-left: 4px solid #c9a96e; }
.mfg-specs h4 { margin: 0 0 8px; color: #8b7355; }
.mfg-specs ul { margin: 0; padding-left: 18px; }
.mfg-specs li { margin: 2px 0; }
</style>
</head>
<body>
<div class="page">
<div class="header">
<h1 class="title">Plans de fabrication - Armoire sur mesure</h1>
<p class="subtitle">Généré le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}</p>
<p class="subtitle">APEX 4.0 — Généré le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}</p>
</div>
<div class="summary">
@@ -265,6 +555,27 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
</div>
</div>
<div class="mfg-specs">
<h4>Normes de fabrication APEX 4.0</h4>
<ul>
<li>Structure ${PANEL.structure} mm / Renforts ${PANEL.reinforcement} mm / Dos ${PANEL.back} mm</li>
<li>Chants ABS ${EDGE_BANDING.abs} mm visible — Dos: ABS sur ${EDGE_BANDING.backPanelSides} chants</li>
<li>Rainure dos ${GROOVE.back.width}×${GROOVE.back.depth} mm — position à ${GROOVE.back.axisFromPanelEdge} mm du bord</li>
<li>System 32 côtés : Ø${SYSTEM32.holeDiameter} mm borgne, prof. ${SYSTEM32.holeDepth} mm, pas ${SYSTEM32.pitch} mm, offset ${SYSTEM32.firstHoleOffset} mm</li>
<li>System 32 séparateurs : Ø${SYSTEM32_SEPARATORS.holeDiameter} mm traversant, offset ${SYSTEM32_SEPARATORS.firstHoleOffset} mm</li>
<li>Connecteurs ${CONNECTORS.ixconnect.model} (structure) : about Ø${CONNECTORS.ixconnect.edgeHole.diameter}×${CONNECTORS.ixconnect.edgeHole.depth} / face Ø${CONNECTORS.ixconnect.faceHole.diameter}×${CONNECTORS.ixconnect.faceHole.depth}</li>
<li>Connecteurs ${CONNECTORS.ixconnectFinish.model} (finitions) : face Ø${CONNECTORS.ixconnectFinish.faceHole.diameter}×${CONNECTORS.ixconnectFinish.faceHole.depth}, axes ${CONNECTORS.ixconnectFinish.axes.back}/${CONNECTORS.ixconnectFinish.axes.front} mm</li>
<li>Ixconnect: extrémités d’arête T@${CONNECTORS.jointPattern.tourillon1Axis} → IX@${CONNECTORS.jointPattern.ixconnectAxis} → T@${CONNECTORS.jointPattern.tourillon2Axis} mm (depuis bord avant) · jonctions internes dessus/fond: IX au milieu profondeur + T ±${CONNECTORS.jointPatternMiddleTourillonOffsetFromIxMm} mm — faces séparateurs verticaux sans Ixconnect</li>
<li>Renforts : 1× ${REINFORCEMENT_MACHINING.connectorModel} au centre + 2× tourillons Ø${REINFORCEMENT_MACHINING.tourillonDiameter} à ${REINFORCEMENT_MACHINING.tourillonEdgeOffset} mm des bords (axes selon largeur renfort)</li>
<li>Charnières ${HINGE.model} : axe à ${HINGE.axisOffset} mm du haut et du bas (réversible)</li>
<li>Matrice charnières: ≤900mm=2, 901-1600=3, 1601-2000=4, 2001-2400=5, 2401-2600=6</li>
<li>Étagères : ${SHELF_MACHINING.tourillonCount}× tourillons Ø${SHELF_MACHINING.tourillonDiameter} — ${SHELF_MACHINING.shelvesPerStratum} rayons/strate · Rayons mobiles SP 15/8/5 : face Ø20×15 (axe 9.5) + chant Ø8×37 (à 6 du dessous) · avant 32 / arrière 37</li>
<li>Plinthe : ${PLINTH.model} (pieds ${PLINTH.footHeight} mm + clips)</li>
<li>Dessus/Fond : ABS ${EDGE_BANDING.abs} mm avant ET arrière</li>
${config.hardware?.handleType === 'full_height' ? `<li>Poignée toute hauteur : déduction ${HANDLE.fullHeightWidthDeductionMm} mm en largeur porte ; rainure ${HANDLE.fullHeightGroove.width}×${HANDLE.fullHeightGroove.depth} mm côté opposé aux charnières ; retrait mécanique ${HANDLE.fullHeightTopBottomOffset} mm haut et bas</li>` : ''}
</ul>
</div>
<div class="plan-container">
<div class="plan-title">Vue de face (Élévation)</div>
${plans.assembly}
@@ -281,6 +592,13 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
<div class="plan-title">Vue de dessus</div>
${plans.top}
</div>
</div>
<div class="page">
<div class="plan-container">
<div class="plan-title">Détail constructif — Côté</div>
${plans.detail}
</div>
</div>`;
// Cutting list page
@@ -293,7 +611,7 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
<div class="specs">
<div class="spec-item"><span>Nombre de pièces:</span><strong>${cuttingList.summary.totalPieces}</strong></div>
<div class="spec-item"><span>Surface totale:</span><strong>${cuttingList.summary.totalSurfaceM2.toFixed(2)} m²</strong></div>
<div class="spec-item"><span>Chant plaqué:</span><strong>${cuttingList.summary.edgeBandingMl} ml</strong></div>
<div class="spec-item"><span>Chant ABS 2mm:</span><strong>${cuttingList.summary.edgeBandingMl} ml</strong></div>
<div class="spec-item"><span>Finitions:</span><strong>${cuttingList.summary.finishCount}</strong></div>
</div>
</div>`;
@@ -310,11 +628,15 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
<th>Ép.</th>
<th>Qté</th>
<th>Surface</th>
<th>Usinage</th>
</tr>
</thead>
<tbody>`;
for (const piece of group.pieces) {
const machStr = piece.machining
? Object.keys(piece.machining).join(', ')
: '-';
html += `
<tr>
<td>${piece.description}</td>
@@ -323,6 +645,7 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
<td>${piece.thicknessMm} mm</td>
<td>${piece.quantity}</td>
<td>${piece.surfaceM2.toFixed(3)} m²</td>
<td>${machStr}</td>
</tr>`;
}
@@ -330,8 +653,8 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
</tbody>
<tfoot>
<tr>
<th colspan="4">Sous-total</th>
<th>${group.pieceCount}</th>
<th colspan="5">Sous-total</th>
<th>${group.pieceCount} pcs</th>
<th>${group.totalSurfaceM2.toFixed(2)} m²</th>
</tr>
</tfoot>
@@ -340,6 +663,82 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
html += `
</div>`;
// Piece-by-piece machining plan pages
for (const piece of (cuttingList.pieces || [])) {
if (!piece.piecePlanSvg) continue;
html += `
<div class="page">
<h2>Plan pièce - ${piece.description}</h2>
<p style="margin:0 0 8px;color:#555;">
Fini: ${piece.widthMm} x ${piece.heightMm} x ${piece.thicknessMm} mm
&nbsp;|&nbsp;
Brut CNC: ${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm
&nbsp;|&nbsp;
Qté: ${piece.quantity}
</p>
<div class="plan-container">
${piece.piecePlanSvg}
</div>
</div>`;
}
}
// Packing list page (layer-based per APEX 4.0)
if (packingList && packingList.boxes && packingList.boxes.length > 0) {
html += `
<div class="page">
<h2>Liste de colisage — Stratégie par niveaux (APEX 4.0)</h2>
<div class="summary">
<h3>Résumé</h3>
<div class="specs">
<div class="spec-item"><span>Nombre de colis:</span><strong>${packingList.totalBoxes}</strong></div>
<div class="spec-item"><span>Poids max/colis:</span><strong>${packingList.maxWeightKg} kg</strong></div>
<div class="spec-item"><span>Colis le plus lourd:</span><strong>${packingList.heaviestBoxKg.toFixed(1)} kg</strong></div>
<div class="spec-item"><span>Poids total:</span><strong>${packingList.totalWeightKg.toFixed(1)} kg</strong></div>
</div>
</div>`;
// Layer summary table
if (packingList.layerSummary) {
const ls = packingList.layerSummary;
html += `
<table>
<thead><tr><th>Niveau</th><th>Contenu</th><th>Colis</th><th>Pièces</th></tr></thead>
<tbody>
<tr><td>1</td><td>${ls.level1.label}</td><td>${ls.level1.boxes}</td><td>${ls.level1.items}</td></tr>
<tr><td>2</td><td>${ls.level2.label}</td><td>${ls.level2.boxes}</td><td>${ls.level2.items}</td></tr>
<tr><td>3</td><td>${ls.level3.label}</td><td>${ls.level3.boxes}</td><td>${ls.level3.items}</td></tr>
</tbody>
</table>`;
}
for (const box of packingList.boxes) {
html += `
<h3>Colis ${box.id}${box.level ? ` — ${box.level}` : ''} — ${box.totalWeightKg.toFixed(1)} kg</h3>
<table>
<thead>
<tr><th>Pièce</th><th>Dim. (L×H×Ép.)</th><th>Qté</th><th>Poids</th></tr>
</thead>
<tbody>`;
for (const item of box.pieces) {
html += `
<tr>
<td>${item.description}</td>
<td>${item.widthMm}×${item.heightMm}×${item.thicknessMm} mm</td>
<td>${item.quantity}</td>
<td>${item.weightKg.toFixed(1)} kg</td>
</tr>`;
}
html += `
</tbody>
</table>`;
}
html += `
</div>`;
}
html += `
@@ -353,6 +752,7 @@ export default {
generateAssemblyPlan,
generateSidePlan,
generateTopPlan,
generateDetailPlan,
generateAllPlans,
generatePDFHtml
};
+89 -10
View File
@@ -1,15 +1,23 @@
/**
* Moteur de calcul de prix — configurateur armoires sur mesure.
* Règles : panneaux entiers (chute intégrée), blocs, kit, options.
* Specs APEX 4.0 importées de shared/manufacturing.js
*/
import { PANEL, DRAWERS, NICHE, getHingeCountForDoor } from '../../shared/manufacturing.js';
function numberOr(value, fallback) {
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : fallback;
}
/**
* Calcule la surface utile des éléments visibles (portes, fileurs, bandeau, plinthe, côtés doublage).
* Épaisseur panneaux 19 mm (référence).
* Épaisseur panneaux 19 mm (référence APEX 4.0).
* @param {Object} config - configuration (installation, width, height, depth, plinthHeightMm, blocks, doorType, slidingPanels)
* @param {number} panelThicknessMm - épaisseur panneaux (19)
*/
function calculateVisibleSurface(config, panelThicknessMm = 19) {
function calculateVisibleSurface(config, panelThicknessMm = PANEL.structure) {
const { installationType, widthMm, heightMm, depthMm, plinthHeightMm, blocks = [], doorType, slidingPanelsCount } = config;
const isNiche = installationType === 'niche';
let surface = 0;
@@ -204,12 +212,13 @@ function calculateHardwareCosts(hardware, hardwareData, config) {
}
}
// Hinge costs - typically 3 per hinged door
// Hinge costs - dynamic quantity from APEX 4.0 matrix
if (hardware.hingeVariantId && config.doorType !== 'sliding') {
const hinge = hinges.find(h => h.id === hardware.hingeVariantId);
if (hinge) {
const doorCount = config.modules?.length || config.blocks?.length || 2;
const hingesPerDoor = 3;
const doorHeightMm = (config.heightMm || 2400) - (config.plinthHeightMm || 100) - 4;
const hingesPerDoor = getHingeCountForDoor({ heightMm: doorHeightMm });
const quantity = doorCount * hingesPerDoor;
const cost = hinge.price_chf * quantity;
totalChf += cost;
@@ -263,16 +272,22 @@ export function calculatePrice(config, data) {
if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v)) v = Number(v);
settings[r.key] = v;
}
const kitFeeChf = Number(settings.kit_preparation_fee_chf) ?? 350;
const plinthMin = Number(settings.plinth_height_min_mm) ?? 60;
const plinthMax = Number(settings.plinth_height_max_mm) ?? 160;
const slidingMinWidth = Number(settings.sliding_doors_min_width_mm) ?? 1200;
const kitFeeChf = numberOr(settings.kit_preparation_fee_chf, 350);
const plinthMin = numberOr(settings.plinth_height_min_mm, 60);
const plinthMax = numberOr(settings.plinth_height_max_mm, 160);
const slidingMinWidth = numberOr(settings.sliding_doors_min_width_mm, 1200);
const allowSlidingDoors = String(settings.allow_sliding_doors ?? '0') === '1';
const standardHandleAxisMm = numberOr(settings.standard_handle_axis_mm, 1000);
const standardHandleToleranceMm = numberOr(settings.standard_handle_tolerance_mm, 200);
const sanitaryGapMm = numberOr(settings.sanitary_gap_mm, NICHE.sanitaryGapAround);
const {
installationType, widthMm, heightMm, depthMm, plinthHeightMm,
blocks = [], modules = [], hardware = {},
doorType, slidingPanelsCount, finishId, serviceOptionIds = []
doorType, slidingPanelsCount, finishId, serviceOptionIds = [],
columnMode: columnModeRaw
} = config;
const columnMode = columnModeRaw === 'block' ? 'block' : 'separations';
// Determine if using modules (V2) or blocks (legacy)
const useModules = modules && modules.length > 0;
@@ -286,11 +301,32 @@ export function calculatePrice(config, data) {
if (doorType === 'sliding' && widthMm < slidingMinWidth) {
errors.push({ field: 'doorType', message: `Les portes coulissantes ne sont possibles qu'à partir de ${slidingMinWidth} mm de largeur.` });
}
if (doorType === 'sliding' && !allowSlidingDoors) {
errors.push({ field: 'doorType', message: 'Les portes coulissantes sont désactivées pour le moment.' });
}
if (doorType === 'sliding' && (slidingPanelsCount < 2 || slidingPanelsCount > 3)) {
errors.push({ field: 'slidingPanelsCount', message: 'Choisissez 2 ou 3 vantaux coulissants.' });
}
if (installationType === 'niche') {
const nm = config.nicheMeasurements || {};
const minW = Math.min(nm.widthTopMm || widthMm, nm.widthMidMm || widthMm, nm.widthBottomMm || widthMm);
const minD = Math.min(nm.depthTopMm || depthMm, nm.depthMidMm || depthMm, nm.depthBottomMm || depthMm);
const minH = Math.min(nm.heightLeftMm || heightMm, nm.heightRightMm || heightMm);
const expectedW = Math.max(400, minW - sanitaryGapMm * 2);
const expectedD = Math.max(400, minD - sanitaryGapMm * 2);
const expectedH = Math.max(1800, minH - sanitaryGapMm * 2);
if (Math.abs(widthMm - expectedW) > 2 || Math.abs(depthMm - expectedD) > 2 || Math.abs(heightMm - expectedH) > 2) {
errors.push({ field: 'nicheMeasurements', message: 'Les dimensions retenues ne correspondent pas aux plus petites mesures niche avec vide sanitaire de 3 cm.' });
}
}
const handleAxis = Number(config.hardware?.handleBoreHeightMm || standardHandleAxisMm);
const minStandard = standardHandleAxisMm - standardHandleToleranceMm;
const maxStandard = standardHandleAxisMm + standardHandleToleranceMm;
const isHandleAxisNonStandard = handleAxis < minStandard || handleAxis > maxStandard;
// Calculate block/module costs
let blocksChf = 0;
let modulesBreakdown = null;
@@ -307,6 +343,13 @@ export function calculatePrice(config, data) {
if (Math.abs(totalModuleWidth - widthMm) > 10) {
errors.push({ field: 'modules', message: `La somme des largeurs des modules (${totalModuleWidth}mm) doit correspondre à la largeur totale (${widthMm}mm).` });
}
const hasDrawerColumnTooWide = modules.some((m) => ((m.drawers || 0) > 0 || m.type === 'tiroirs') && (m.widthMm || 0) > DRAWERS.maxColumnWidth);
if (hasDrawerColumnTooWide) {
errors.push({ field: 'modules', message: `Une colonne tiroirs dépasse ${DRAWERS.maxColumnWidth} mm (non autorisé).` });
}
if (modules.some((m) => m.hasRod) && heightMm < 1200) {
errors.push({ field: 'modules', message: 'Rayon avec tringle: hauteur minimale 1200 mm.' });
}
} else {
// Legacy: Use block-based pricing
const totalWidth = blocks.reduce((sum, b) => sum + (b.widthMm || 0), 0);
@@ -386,6 +429,34 @@ export function calculatePrice(config, data) {
}
}
// 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
// Block (x caisses): chaque caisse a 2 montants → 2n panneaux verticaux (plus de bois qu’en séparations)
const columnCount = useModules
? Math.max(1, modules.length)
: Math.max(1, blocks.length || 1);
const verticalStructurePanelCount = columnMode === 'block'
? 2 * columnCount
: 2 + Math.max(0, columnCount - 1);
const reinforcementCount = verticalStructurePanelCount * 2;
// Surcoût panneaux 19 mm (structure): en mode block, (n−1) montants de plus qu’en séparations
const bodyHeightMm = (heightMm || 0) - (plinthHeightMm || 100);
const extraBlockVerticalPanels = columnMode === 'block' && columnCount > 1 ? columnCount - 1 : 0;
const extraBlockStructureSurfaceM2 = extraBlockVerticalPanels > 0
? (extraBlockVerticalPanels * depthMm * bodyHeightMm) / 1_000_000
: 0;
const structureUnitPriceM2 = finish
? (Number(finish.price_chf) || 0) / ((finish.width_mm || 2800) * (finish.height_mm || 2070) / 1_000_000)
: 0;
const blockExtraStructureChf = Math.round(extraBlockStructureSurfaceM2 * structureUnitPriceM2 * 100) / 100;
// Each reinforcement is 100mm × innerDepth × 16mm — price as fraction of a panel
const reinforcementSurfaceM2 = (PANEL.reinforcementWidth * (depthMm - PANEL.structure - 20)) / 1_000_000 * reinforcementCount;
// Use 50% of panel price per m² for 16mm material (thinner, cheaper)
const reinforcementUnitPriceM2 = finish ? (Number(finish.price_chf) || 0) * 0.5 / ((finish.width_mm || 2800) * (finish.height_mm || 2070) / 1_000_000) : 0;
const reinforcementChf = Math.round(reinforcementSurfaceM2 * reinforcementUnitPriceM2 * 100) / 100;
let optionsChf = 0;
const services = data.serviceOptions || [];
for (const optId of serviceOptionIds) {
@@ -393,7 +464,7 @@ export function calculatePrice(config, data) {
if (opt) optionsChf += Number(opt.price_chf) || 0;
}
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf;
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf + reinforcementChf + blockExtraStructureChf;
return {
totalChf: Math.round(totalChf * 100) / 100,
@@ -403,11 +474,19 @@ export function calculatePrice(config, data) {
modulesBreakdown,
kitChf: kitFeeChf,
panelsChf,
reinforcementChf,
reinforcementCount,
columnMode,
verticalStructurePanelCount,
blockExtraStructureChf,
optionsChf,
hardwareChf,
hardwareBreakdown,
panelOptimization
},
warnings: isHandleAxisNonStandard
? [{ field: 'handleBoreHeightMm', message: `Hauteur perçage poignée hors plage standard (${minStandard}-${maxStandard} mm).` }]
: undefined,
errors: errors.length ? errors : undefined
};
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Supplier voucher service.
* After an order is placed, splits order items by supplier and sends each
* supplier an HTML email voucher listing the items they need to provide.
*/
import pool from '../db/index.js';
import { sendEmail } from './mailer.js';
import { getHingeCountForDoor } from '../../shared/manufacturing.js';
/**
* Resolve which supplier (if any) provides the finish/panel material.
*/
async function resolveFinishSupplier(finishId) {
if (!finishId) return null;
// Try formex_materials first (linked via finishes → formex_materials)
const { rows } = await pool.query(
`SELECT fm.id, fm.formex_code AS code, fm.name, fm.price_chf,
s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code
FROM formex_materials fm
JOIN suppliers s ON s.id = fm.supplier_id
WHERE fm.id = $1 AND fm.active = 1 AND s.active = 1`,
[finishId]
);
if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: 'panel' };
return null;
}
/**
* Resolve drawer system supplier.
*/
async function resolveBlumHardware(hardwareId, type) {
if (!hardwareId) return null;
const { rows } = await pool.query(
`SELECT bh.id, bh.code, bh.name, bh.price_chf, bh.type,
s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code
FROM blum_hardware bh
JOIN suppliers s ON s.id = bh.supplier_id
WHERE bh.id = $1 AND bh.active = 1 AND s.active = 1`,
[hardwareId]
);
if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: type };
return null;
}
/**
* Resolve handle supplier.
*/
async function resolveHandle(handleId) {
if (!handleId) return null;
const { rows } = await pool.query(
`SELECT hv.id, hv.code, hv.name, hv.price_chf,
s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code
FROM handle_variants hv
JOIN suppliers s ON s.id = hv.supplier_id
WHERE hv.id = $1 AND hv.active = 1 AND s.active = 1`,
[handleId]
);
if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: 'handle' };
return null;
}
/**
* Resolve hinge supplier.
*/
async function resolveHinge(hingeId) {
if (!hingeId) return null;
const { rows } = await pool.query(
`SELECT hv.id, hv.code, hv.name, hv.price_chf,
s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code
FROM hinge_variants hv
JOIN suppliers s ON s.id = hv.supplier_id
WHERE hv.id = $1 AND hv.active = 1 AND s.active = 1`,
[hingeId]
);
if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: 'hinge' };
return null;
}
/**
* Estimate quantity for each item based on the order config.
*/
function estimateQuantity(category, config) {
const { modules = [], blocks = [], doorType, slidingPanelsCount } = config;
const itemCount = modules.length || blocks.length || 1;
switch (category) {
case 'panel':
return 1; // quantity expressed in panels by the price engine; we report 1 "lot"
case 'drawer_system':
return modules.reduce((sum, m) => sum + (m.drawers || 0), 0) || 1;
case 'hinge':
if (doorType === 'sliding') return 0;
return itemCount * getHingeCountForDoor({
heightMm: (config.heightMm || 2400) - (config.plinthHeightMm || 100) - 4
});
case 'handle':
return doorType === 'sliding'
? (slidingPanelsCount || 2)
: itemCount;
case 'runner':
case 'lift_system':
return 1;
default:
return 1;
}
}
/**
* Build an HTML voucher email body for a single supplier.
*/
function buildVoucherHtml({ orderId, orderDate, customerName, supplierName, items }) {
const formattedDate = new Date(orderDate).toLocaleDateString('fr-CH', {
day: '2-digit', month: '2-digit', year: 'numeric'
});
const subtotal = items.reduce((sum, it) => sum + (it.unitPrice * it.qty), 0);
const rows = items.map(it => `
<tr>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;">${it.code || '—'}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;">${it.name}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:center;">${it.qty}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:right;">${it.unitPrice != null ? it.unitPrice.toFixed(2) + ' CHF' : '—'}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:right;">${it.unitPrice != null ? (it.unitPrice * it.qty).toFixed(2) + ' CHF' : '—'}</td>
</tr>`).join('');
return `<!DOCTYPE html>
<html lang="fr">
<head><meta charset="utf-8"><title>Bon de commande #${orderId}</title></head>
<body style="margin:0;padding:0;background:#f9fafb;font-family:Arial,sans-serif;color:#111827;">
<div style="max-width:680px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.1);">
<div style="background:#1e293b;color:#fff;padding:28px 32px;">
<h1 style="margin:0;font-size:20px;font-weight:700;">Bon de commande — APEX Configurateur</h1>
<p style="margin:6px 0 0;opacity:.75;font-size:14px;">Référence commande&nbsp;: <strong>#${orderId}</strong> &nbsp;|&nbsp; ${formattedDate}</p>
</div>
<div style="padding:24px 32px;">
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
<tr>
<td style="padding:4px 0;color:#6b7280;font-size:13px;width:140px;">Fournisseur</td>
<td style="padding:4px 0;font-weight:600;">${supplierName}</td>
</tr>
<tr>
<td style="padding:4px 0;color:#6b7280;font-size:13px;">Client</td>
<td style="padding:4px 0;">${customerName || '—'}</td>
</tr>
</table>
<h2 style="font-size:15px;font-weight:600;margin:0 0 12px;color:#1e293b;">Articles à fournir</h2>
<table style="width:100%;border-collapse:collapse;font-size:14px;">
<thead>
<tr style="background:#f1f5f9;">
<th style="padding:8px 12px;text-align:left;font-weight:600;color:#374151;">Code</th>
<th style="padding:8px 12px;text-align:left;font-weight:600;color:#374151;">Désignation</th>
<th style="padding:8px 12px;text-align:center;font-weight:600;color:#374151;">Qté</th>
<th style="padding:8px 12px;text-align:right;font-weight:600;color:#374151;">PU (CHF)</th>
<th style="padding:8px 12px;text-align:right;font-weight:600;color:#374151;">Total</th>
</tr>
</thead>
<tbody>${rows}</tbody>
<tfoot>
<tr>
<td colspan="4" style="padding:10px 12px;text-align:right;font-weight:700;border-top:2px solid #e5e7eb;">Sous-total</td>
<td style="padding:10px 12px;text-align:right;font-weight:700;border-top:2px solid #e5e7eb;">${subtotal.toFixed(2)} CHF</td>
</tr>
</tfoot>
</table>
</div>
<div style="padding:16px 32px 24px;font-size:12px;color:#9ca3af;border-top:1px solid #e5e7eb;">
Ce document est généré automatiquement par le configurateur APEX. Pour toute question : configurateur@apex.ch
</div>
</div>
</body>
</html>`;
}
/**
* Main entry point — called from orders route after insert.
* Splits the order by supplier and sends each supplier their voucher.
*
* @param {number} orderId
* @param {Object} config - the order config JSON (finishId, hardware, modules, blocks, doorType, …)
* @param {string} customerName
* @param {string} orderDate - ISO date string (defaults to now)
*/
export async function sendSupplierVouchers(orderId, config, customerName, orderDate = new Date().toISOString()) {
const suppliers = await buildSupplierVoucherPayload(config);
if (suppliers.length === 0) {
console.log(`[supplier-voucher] Commande #${orderId} : aucun fournisseur résolu, aucun email envoyé.`);
return;
}
// Send one email per supplier
const sends = [];
for (const supplier of suppliers) {
const html = buildVoucherHtml({
orderId,
orderDate,
customerName,
supplierName: supplier.supplierName,
items: supplier.items
});
sends.push(
sendEmail({
to: supplier.supplierEmail,
subject: `Bon de commande #${orderId} — APEX Configurateur`,
html
}).then(() => {
console.log(`[supplier-voucher] Email envoyé à ${supplier.supplierName} <${supplier.supplierEmail}> pour commande #${orderId}`);
})
);
}
await Promise.all(sends);
}
/**
* Build supplier payload from order config.
* Used by email sender and by admin API preview.
*
* @param {Object} config
* @returns {Promise<Array<{supplierId:number, supplierCode:string|null, supplierName:string, supplierEmail:string, items:Array, subtotal:number}>>}
*/
export async function buildSupplierVoucherPayload(config) {
const { hardware = {} } = config;
const { handleVariantId, hingeVariantId, drawerSystemId } = hardware;
// Resolve all items in parallel
const [finishResult, drawerResult, handleResult, hingeResult] = await Promise.all([
resolveFinishSupplier(config.finishId),
resolveBlumHardware(drawerSystemId, 'drawer_system'),
resolveHandle(handleVariantId),
resolveHinge(hingeVariantId)
]);
// Group items by supplier_id
const supplierMap = new Map(); // supplier_id → { supplier info, items[] }
for (const result of [finishResult, drawerResult, handleResult, hingeResult]) {
if (!result) continue;
const { supplier, item, category } = result;
const sid = supplier.supplier_id;
if (!supplierMap.has(sid)) {
supplierMap.set(sid, {
code: supplier.supplier_code || null,
name: supplier.supplier_name,
email: supplier.supplier_email,
items: []
});
}
const qty = estimateQuantity(category, config);
if (qty > 0) {
supplierMap.get(sid).items.push({
code: item.code,
name: item.name,
unitPrice: item.price_chf,
qty,
category
});
}
}
return Array.from(supplierMap, ([supplierId, supplierData]) => {
const subtotal = supplierData.items.reduce((sum, item) => sum + ((item.unitPrice || 0) * item.qty), 0);
return {
supplierId,
supplierCode: supplierData.code || null,
supplierName: supplierData.name,
supplierEmail: supplierData.email,
items: supplierData.items,
subtotal
};
});
}
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { validateConfig } from '../services/configValidation.js';
import { generateCuttingList } from '../services/cuttingEngine.js';
function run() {
const baseConfig = {
installationType: 'free',
widthMm: 2400,
heightMm: 2400,
depthMm: 610,
plinthHeightMm: 100,
modules: [{ type: 'tiroirs', widthMm: 900, drawers: 3, hasRod: false }],
doorType: 'hinged',
hardware: {
handleBoreHeightMm: 1000,
drawerType: 'visible'
}
};
const okValidation = validateConfig(baseConfig, { allow_sliding_doors: '0' });
assert.equal(okValidation.errors.length, 0, 'Base config should be valid');
const invalidDrawerConfig = {
...baseConfig,
modules: [{ type: 'tiroirs', widthMm: 1201, drawers: 2, hasRod: false }]
};
const invalidDrawerValidation = validateConfig(invalidDrawerConfig, {});
assert.ok(
invalidDrawerValidation.errors.some((e) => String(e.message).includes('1000')),
'Drawer width limit should trigger validation error'
);
const nonStandardHandle = {
...baseConfig,
hardware: { ...baseConfig.hardware, handleBoreHeightMm: 1450 }
};
const nonStandardHandleValidation = validateConfig(nonStandardHandle, {});
assert.ok(nonStandardHandleValidation.warnings.length > 0, 'Handle warning should be emitted');
const cuttingList = generateCuttingList(baseConfig, {}, baseConfig.modules);
const sidePiece = cuttingList.pieces.find((p) => p.elementType === 'cotes');
assert.ok(sidePiece?.machining?.groove, 'Side piece should include groove machining');
assert.equal(sidePiece.machining.groove.width, 9, 'Groove width should be 9mm');
assert.equal(sidePiece.machining.groove.depth, 10.5, 'Groove depth should be 10.5mm');
assert.equal(sidePiece.machining.groove.axisFromPanelEdge, 16, 'Groove position should be 16mm from edge');
}
run();
console.log('rules.spec.mjs: all assertions passed');
+591
View File
@@ -0,0 +1,591 @@
/**
* Perçages face panneaux dessus/fond — alignés sur la vue A des plans (cuttingEngine)
* et sur les commentaires HOLE du G-code (cncGenerator).
*/
import {
CONNECTORS,
PANEL,
CARCASS_FASTENERS,
getCarcassFerrureDowelDrillings,
getHingeCountForDoor,
getHingeCupPositions,
HINGE,
tourillonFaceDrillDepthMm,
DRAWER_REINFORCEMENT,
DRAWER_RENFORT_PANEL_IXCONNECT
} from './manufacturing.js';
/** Perçage ferrure en face — Ø8 mm (même diamètre que tourillon / Ixconnect face) */
export const CARCASS_FERRURE_FACE_DIAMETER_MM = 8;
/** 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;
/** Séparateurs verticaux : second jeu T–IX–T symétrique depuis le bord arrière (profondeur), si largeur brut le permet. */
export const IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM = 200;
export function mapJunctionXAcrossWidthMm(widthMm, xmm) {
const edgeInset = PANEL.structure * 0.5;
const clamped = Math.max(0, Math.min(widthMm, Number(xmm) || 0));
if (clamped <= 0) return edgeInset;
if (clamped >= widthMm) return Math.max(0, widthMm - edgeInset);
return clamped;
}
/** Même logique que `getCarcassDowelStationsForPlan` dans cuttingEngine.js */
export function getCarcassDowelStationsForPlan(cf, edgeLengthMm) {
const L = Math.max(1, Number(edgeLengthMm) || 0);
const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset;
let positions = cf.positionsAlongJointMm;
if (!positions?.length && cf.dowelStations?.length) {
positions = cf.dowelStations.map((s) => s.centerMm).filter((v) => Number.isFinite(Number(v)));
}
return getCarcassFerrureDowelDrillings(positions || [], L, off);
}
/**
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string }>}
*/
export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
const out = [];
const m = piece?.machining || {};
const thicknessMm = Math.max(1, Number(piece?.thicknessMm) || 19);
if (!['dessus', 'fond'].includes(piece.elementType || '')) return out;
const clampX = (v) => Math.max(0, Math.min(widthMm, 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). */
const refW =
Number(piece?.widthMm) > 0 ? Math.min(widthMm, Number(piece.widthMm)) : widthMm;
const edgeTolMm = 0.5;
const cf = m.carcassFasteners;
if (cf && (cf.dowelStations?.length || cf.positionsAlongJointMm?.length)) {
const edgeL = Math.max(1, Number(cf.edgeLengthMm) || heightMm);
const stations = getCarcassDowelStationsForPlan(cf, edgeL);
const jax = cf.junctionAxesAcrossWidthMm?.length ? cf.junctionAxesAcrossWidthMm : [0, refW];
const dTour = Number(cf.dowelDiameter || CARCASS_FASTENERS.dowel.diameter);
const zTour = tourillonFaceDrillDepthMm(thicknessMm);
const zFerr = Number(cf.ferrureDepthInThicknessMm ?? CARCASS_FASTENERS.ferrureDepthInThicknessMm);
const zFerrEdge = Number(cf.ferrureDepthAlongLengthMm ?? 46);
for (const xmm of jax) {
const rawX = clampX(xmm);
const isEnd = rawX <= edgeTolMm || rawX >= refW - edgeTolMm;
const endSide = rawX <= refW / 2 ? 'left' : 'right';
const x = clampX(mapJunctionXAcrossWidthMm(widthMm, xmm));
for (const st of stations) {
const along = st.holesAlongMm || [];
for (let hi = 0; hi < along.length; hi++) {
const alongMm = along[hi];
const yMm = clampY(alongMm);
const isTourillon = hi !== 1;
if (isTourillon) {
out.push({
xMm: x,
yMm,
diameterMm: dTour,
depthMm: zTour,
type: 'tourillon',
face: isEnd ? 'end' : 'face',
...(isEnd ? { end: endSide } : {})
});
} else {
/* Même perçage ferrure (Ø/prof) ; type ixconnect pour G-code / 3D : violet + long sur chant montant, comme renfort. */
out.push({
xMm: x,
yMm,
diameterMm: CARCASS_FERRURE_FACE_DIAMETER_MM,
depthMm: isEnd ? zFerrEdge : zFerr,
type: 'ixconnect',
face: isEnd ? 'end' : 'face',
...(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
* (souvent issus de ferrures caisse seules) n’ont pas de trou violet Inbus en plan / G-code / 3D. */
{
const lock = CONNECTORS.ixconnect.lockScrewInbus;
const dLock = Number(lock?.diameter) || 6;
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({
xMm: xInbus,
yMm,
diameterMm: dLock,
depthMm: zLock,
type: 'ixconnect_inbus',
face: 'face'
});
}
}
}
}
}
}
const ch = m.connectorHoles;
const joints = ch?.ixconnectJointsAlongDepth;
if (joints?.length) {
const dTour = Number(ch.tourillonDiameter || CONNECTORS.tourillonDiameter || 8);
const zTour = tourillonFaceDrillDepthMm(thicknessMm);
const dFace = Number(ch.faceHole?.diameter || CONNECTORS.ixconnect.faceHole.diameter || 8);
const zFace = Number(ch.faceHole?.depth || CONNECTORS.ixconnect.faceHole.depth || 12);
const dEdge = Number(ch.edgeHole?.diameter || CONNECTORS.ixconnect.edgeHole.diameter || 8);
const zEdge = Number(ch.edgeHole?.depth || CONNECTORS.ixconnect.edgeHole.depth || 45);
const zTourEdge = Math.min(zTour, 16);
for (const j of joints) {
/* isEnd doit utiliser l’axe nominal (repère caisse), pas clampX avant : si brut CNC < largeur finie,
* 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 rawAcross = clampX(axisNominal);
const isEnd =
axisNominal <= edgeTolMm || axisNominal >= refW - edgeTolMm;
const endSide = rawAcross <= refW / 2 ? 'left' : 'right';
const x = isEnd
? rawAcross <= edgeTolMm
? 0
: refW
: clampX(mapJunctionXAcrossWidthMm(widthMm, j.axisAcrossWidthMm));
const seq = [
{ 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.tourillon2Axis, d: dTour, z: isEnd ? zTourEdge : zTour, type: 'tourillon' }
];
for (const h of seq) {
const pos = Number(h.axis);
const yMm = clampY(pos);
out.push({
xMm: x,
yMm,
diameterMm: h.d,
depthMm: h.z,
type: h.type,
face: isEnd ? 'end' : 'face',
...(isEnd ? { end: endSide } : {})
});
}
/* Trou de serrage Inbus : un par Ixconnect — même (Y) que l’axe Ixconnect ; X aligné sur le centre du
* perçage tel qu’affiché en 3D (même formule que Cnc3DViewer pour FACE=end : min(prof chant, 48 % largeur, 80 mm) / 2).
* 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 dLock = Number(lock?.diameter) || 6;
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({
xMm: xInbus,
yMm: yIx,
diameterMm: dLock,
depthMm: zLock,
type: 'ixconnect_inbus',
face: 'face'
});
}
}
}
return out;
}
/**
* Rayons mobiles (Étagères amovibles) — système SP 15/8/5
* - Perçage face (dessous non visible): Ø20 × 15, axe à 9.5 mm du bord (gauche/droite)
* - Perçage sur chant (épaisseur, contre côtés): Ø8 × 37, positionné à 6 mm de la face inférieure
* - Positionnement en profondeur (Système 32):
* - Depuis bord avant: 32 mm (37 axe caisse - 5 mm retrait étagère)
* - Depuis bord arrière: 37 mm
*
* Note: le plan 2D (X,Y) ne porte pas l'offset en épaisseur (6 mm) pour le perçage chant ; on l’encode
* comme perçage "end" pour la visualisation et l’annotation G-code.
*
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string, end?: string }>}
*/
export function buildShelfMobileHoleSpecsMm(piece, widthMm, heightMm) {
const out = [];
const et = (piece?.elementType || '');
if (!['etageres', 'rayonFixe'].includes(et)) return out;
const m = piece?.machining || {};
const sp = m?.mobileShelfSystem;
const isFixed = et === 'rayonFixe';
if (!sp || (sp.model !== 'sp_15_8_5' && sp.model !== 'sp_15_8_5_face_only')) return out;
const W = Math.max(1, Number(widthMm) || 1);
const D = Math.max(1, Number(heightMm) || 1);
const clampX = (v) => Math.max(0, Math.min(W, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(D, Number(v) || 0));
const axisFromSide = Number(sp.faceHole?.axisFromSideMm ?? 9.5);
const dFace = Number(sp.faceHole?.diameterMm ?? 20);
const zFace = Number(sp.faceHole?.depthMm ?? 15);
const dEdge = Number(sp.edgeHole?.diameterMm ?? 8);
const zEdge = Number(sp.edgeHole?.depthMm ?? 37);
const yFront = clampY(Number(sp.positions?.fromFrontMm ?? 32));
const yBack = clampY(D - Number(sp.positions?.fromBackMm ?? 37));
const yPos = [yFront, yBack].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
const xLeft = clampX(axisFromSide);
const xRight = clampX(W - axisFromSide);
const xSides = [xLeft, xRight].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
for (const yMm of yPos) {
// Perçage face dessous (non visible): 2 côtés (gauche/droite)
for (const xMm of xSides) {
out.push({
xMm,
yMm,
diameterMm: dFace,
depthMm: zFace,
type: 'shelf_mobile_face',
face: 'face'
});
}
// Perçage chant côté gauche/droit (rayons mobiles uniquement)
if (!isFixed) {
out.push({
xMm: 0,
yMm,
diameterMm: dEdge,
depthMm: zEdge,
type: 'shelf_mobile_edge',
face: 'end',
end: 'left'
});
out.push({
xMm: W,
yMm,
diameterMm: dEdge,
depthMm: zEdge,
type: 'shelf_mobile_edge',
face: 'end',
end: 'right'
});
}
}
return out;
}
/**
* Renforts tiroir 25 mm — 4× Ixconnect SC 8/25 aux coins (50 mm des bords), repère Y depuis bord haut.
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string }>}
*/
function coteInnerFaceHasRenfortHoles(faceBlock) {
return Array.isArray(faceBlock?.holes) && faceBlock.holes.length > 0;
}
/**
* Pièce « côté » seul (gauche ou droit) : une seule face intérieure est présente dans `machining`.
* Pièce qty 2 : gauche = partIndex 0, droit = 1 lorsque les deux faces existent.
*/
function resolveCoteDrawerRenfortInnerFace(cotes, partIndex) {
const L = cotes?.leftInnerFace;
const R = cotes?.rightInnerFace;
const hasL = coteInnerFaceHasRenfortHoles(L);
const hasR = coteInnerFaceHasRenfortHoles(R);
if (hasL && hasR) {
return partIndex === 0 ? L : R;
}
if (hasL) return L;
if (hasR) return R;
return null;
}
/**
* Montants / séparateurs : Ixconnect SC 8/25 zone renfort tiroirs cachés (repère X avant, Y bas).
* @param {number} [partIndex] — pour cotes: 0 = montant gauche, 1 = droit lorsque les deux sont sur la même ligne ; pour séparateurs: index du séparateur
*/
export function buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm, partIndex = 0) {
const et = piece?.elementType || '';
const m = piece?.machining?.drawerRenfortPanelIxconnect;
if (!m) return [];
const d = Number(m.faceHole?.diameter ?? DRAWER_RENFORT_PANEL_IXCONNECT.faceHoleDiameterMm) || 8;
const z = Number(m.faceHole?.depth ?? DRAWER_RENFORT_PANEL_IXCONNECT.faceHoleDepthMm) || 12;
const out = [];
if (et === 'cotes' && m.cotes) {
const face = resolveCoteDrawerRenfortInnerFace(m.cotes, partIndex);
const holes = face?.holes;
if (!holes?.length) return [];
for (const h of holes) {
out.push({
xMm: Number(h.xMm) || 0,
yMm: Number(h.yMm) || 0,
diameterMm: d,
depthMm: z,
type: 'ixconnect_drawer_renfort_panel',
face: 'face'
});
}
return out;
}
if (et === 'panneauxInterieurs' && Array.isArray(m.separators)) {
const sep = m.separators[partIndex];
if (!sep) return [];
/** Vers module d’indice bas : une grande face du panneau ; vers module haut : l’autre (épaisseur). */
const pushFace = (block, tag, faceKey) => {
if (!block?.holes?.length) return;
for (const h of block.holes) {
out.push({
xMm: Number(h.xMm) || 0,
yMm: Number(h.yMm) || 0,
diameterMm: d,
depthMm: z,
type: `ixconnect_drawer_renfort_${tag}`,
face: faceKey
});
}
};
pushFace(sep.faceTowardLowerModule, 'sep_lower', 'face');
pushFace(sep.faceTowardHigherModule, 'sep_higher', 'back');
return out;
}
return [];
}
export function buildDrawerReinforcementHoleSpecsMm(piece, widthMm, heightMm) {
if ((piece?.elementType || '') !== 'renfortsTiroir') return [];
const ix = piece?.machining?.drawerReinforcement?.ixconnectFixing;
if (!ix?.holes?.length) return [];
const d = Number(ix.holeDiameterMm ?? DRAWER_REINFORCEMENT.ixconnectHoleDiameterMm) || 8;
const z = Number(ix.depthInReinforcementMm ?? DRAWER_REINFORCEMENT.ixconnectDepthInReinforcementMm) || 13;
return ix.holes.map((h) => ({
xMm: Number(h.xMm) || 0,
yMm: Number(h.yMm) || 0,
diameterMm: d,
depthMm: z,
type: 'ixconnect_drawer_reinforcement',
face: 'face'
}));
}
/**
* Montants : perçages face (axe épaisseur) Ixconnect + tourillons, 2 rangées haut/bas.
* Séparateurs verticaux : perçages en about sur les petits côtés d’extrémité (faces w×ép. aux deux bouts de la longueur),
* axe le long de Z (hauteur brut), pas depuis les grands chants profondeur (X).
* X = centres depuis bord avant profondeur ; Y G-code = 0 (bord bas) ou H (bord haut) — repère contour brut dans le plan pièce.
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string, end?: string }>}
*/
export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heightMm) {
const out = [];
const et = piece?.elementType || '';
if (!['cotes', 'panneauxInterieurs'].includes(et)) return out;
const ch = piece?.machining?.connectorHoles;
const p = ch?.jointPattern;
if (!p) return out;
const H = Math.max(1, Number(heightMm) || 1);
const W = Math.max(1, Number(widthMm) || 1);
const inset = IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM;
if (H < 2 * inset + 5) return out;
const thicknessMm = Math.max(1, Number(piece?.thicknessMm) || 19);
const clampY = (v) => Math.max(0, Math.min(H, Number(v) || 0));
const dTour = Number(ch.tourillonDiameter || CONNECTORS.tourillonDiameter || 8);
const zTour = tourillonFaceDrillDepthMm(thicknessMm);
const dFace = Number(ch.faceHole?.diameter || CONNECTORS.ixconnect.faceHole.diameter || 8);
const zFace = Number(ch.faceHole?.depth || CONNECTORS.ixconnect.faceHole.depth || 12);
const dEdge = Number(ch.edgeHole?.diameter || CONNECTORS.ixconnect.edgeHole.diameter || 8);
const zIxEdge = Number(ch.edgeHole?.depth || CONNECTORS.ixconnect.edgeHole.depth || 45);
const zTourEdge = Math.min(tourillonFaceDrillDepthMm(thicknessMm), 15);
const yRows = [inset, H - inset];
const t1 = Number(p.tourillon1Axis);
const t2 = Number(p.tourillon2Axis);
const ix = Number(p.ixconnectAxis);
const mirrorBack =
W >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM && t2 > t1;
if (et === 'panneauxInterieurs') {
const seqFront = [
{ axis: t1, d: dTour, z: zTourEdge, type: 'tourillon' },
{ axis: ix, d: dEdge, z: zIxEdge, type: 'ixconnect' },
{ axis: t2, d: dTour, z: zTourEdge, type: 'tourillon' }
];
const seqBack = mirrorBack
? [
{ axis: W - t2, d: dTour, z: zTourEdge, type: 'tourillon' },
{ axis: W - ix, d: dEdge, z: zIxEdge, type: 'ixconnect' },
{ axis: W - t1, d: dTour, z: zTourEdge, type: 'tourillon' }
]
: null;
const pushEndRow = (yMm, seq, endSide) => {
const cy = clampY(yMm);
for (const h of seq) {
const xMm = mapJunctionXAcrossWidthMm(W, Number(h.axis));
out.push({
xMm,
yMm: cy,
diameterMm: h.d,
depthMm: h.z,
type: h.type,
face: 'end',
end: endSide
});
}
};
/* Y = 0 / H : extrémités longueur du brut (petits côtés w×ép.), pas un inset sur la grande face. */
pushEndRow(0, seqFront, 'bottom');
if (seqBack) pushEndRow(0, seqBack, 'bottom');
pushEndRow(H, seqFront, 'top');
if (seqBack) pushEndRow(H, seqBack, 'top');
return out;
}
/* cotes : face */
const seqFront = [
{ axis: t1, d: dTour, z: zTour, type: 'tourillon' },
{ axis: ix, d: dFace, z: zFace, type: 'ixconnect' },
{ axis: t2, d: dTour, z: zTour, type: 'tourillon' }
];
const seqBack = mirrorBack
? [
{ axis: W - t2, d: dTour, z: zTour, type: 'tourillon' },
{ axis: W - ix, d: dFace, z: zFace, type: 'ixconnect' },
{ axis: W - t1, d: dTour, z: zTour, type: 'tourillon' }
]
: null;
const pushFaceRow = (yMm, seq) => {
const cy = clampY(yMm);
for (const h of seq) {
const xMm = mapJunctionXAcrossWidthMm(W, Number(h.axis));
out.push({
xMm,
yMm: cy,
diameterMm: h.d,
depthMm: h.z,
type: h.type,
face: 'face'
});
}
};
for (const yMm of yRows) {
pushFaceRow(yMm, seqFront);
if (seqBack) pushFaceRow(yMm, seqBack);
}
return out;
}
/**
* Habillages de finition Premium — perçages SC 8/25 (Ixconnect) stockés dans `piece.machining.finishHabillage.holes`.
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string, end?: string }>}
*/
export function buildFinishHabillageHoleSpecsMm(piece, widthMm, heightMm) {
const out = [];
const fh = piece?.machining?.finishHabillage;
const holes = fh?.holes;
if (!Array.isArray(holes) || holes.length === 0) return out;
const W = Math.max(1, Number(widthMm) || 1);
const H = Math.max(1, Number(heightMm) || 1);
const clampX = (v) => Math.max(0, Math.min(W, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(H, Number(v) || 0));
for (const h of holes) {
if (!h) continue;
out.push({
xMm: clampX(h.xMm),
yMm: clampY(h.yMm),
diameterMm: Number(h.diameterMm) || Number(fh?.faceHole?.diameter) || 8,
depthMm: Number(h.depthMm) || Number(fh?.faceHole?.depth) || 12.5,
type: h.type || 'ixconnect',
face: h.face || 'face',
...(h.end ? { end: h.end } : {})
});
}
return out;
}
/**
* Portes : cuvettes charnières (Blum) + 2 perçages Ø8 par cuvette (45 mm c/c, centres à 9,5 mm du centre cuvette vers l’intérieur).
* Positions (depuis haut) : axe 103 mm et grille System 32 (voir getHingeCupPositions).
* Repère plan: x = largeur gauche→droite ; y = hauteur depuis bas.
*
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string }>}
*/
export function buildDoorHingeCupHoleSpecsMm(piece, widthMm, heightMm) {
const out = [];
if ((piece?.elementType || '') !== 'portes') return out;
const W = Math.max(1, Number(widthMm) || 1);
const H = Math.max(1, Number(heightMm) || 1);
const clampX = (v) => Math.max(0, Math.min(W, Number(v) || 0));
const clampY = (v) => Math.max(0, Math.min(H, Number(v) || 0));
const hc = piece?.machining?.hingeCups;
if (!hc) return out;
const diameterMm = Number(hc.diameter) || 35;
const depthMm = Number(hc.cupDepth) || 12;
const screwDiameterMm = Number(hc.screwDiameterMm ?? HINGE.cupScrewDiameter) || 8;
const screwDepthMm = Number(hc.screwDepthMm ?? HINGE.cupScrewDepth) || 13;
const screwSpacingMm = Number(hc.screwSpacingMm ?? HINGE.cupScrewSpacing) || 45;
const screwOffsetMm = Number(hc.screwOffsetFromCupCenterMm ?? HINGE.cupScrewOffsetFromCupCenter) || 9.5;
const halfScrewSpacing = screwSpacingMm / 2;
// Default Blum inset for cup center (~22mm from edge) unless explicitly set upstream.
const axisFromEdgeMm = Number(hc.axisFromEdgeMm ?? hc.axisFromSideMm ?? hc.axisFromEdge ?? 22);
// Hinge side hint (viewer-only). If absent, keep a consistent default.
const hingeSide = piece?.doorPlanHints?.hingeSide === 'right' ? 'right' : 'left';
const xMm = hingeSide === 'right' ? clampX(W - axisFromEdgeMm) : clampX(axisFromEdgeMm);
const dxInterior = hingeSide === 'right' ? -screwOffsetMm : screwOffsetMm;
const count = Math.max(2, Number(hc.count) || getHingeCountForDoor({ heightMm: H }));
const positionsFromTop =
Array.isArray(hc.positionsFromTop) && hc.positionsFromTop.length
? hc.positionsFromTop.map((v) => Number(v)).filter((v) => Number.isFinite(v))
: getHingeCupPositions(H, count);
for (const fromTop of positionsFromTop) {
const yMm = clampY(H - fromTop); // convert "from top" to plan Y from bottom
out.push({
xMm,
yMm,
diameterMm,
depthMm,
type: 'hinge_cup',
face: 'face'
});
const xScrew = clampX(xMm + dxInterior);
for (const dy of [-halfScrewSpacing, halfScrewSpacing]) {
out.push({
xMm: xScrew,
yMm: clampY(yMm + dy),
diameterMm: screwDiameterMm,
depthMm: screwDepthMm,
type: 'hinge_screw',
face: 'face'
});
}
}
return out;
}
+601
View File
@@ -0,0 +1,601 @@
/**
* APEX 4.0 — Dossier Technique Maître
* Single source of truth for all manufacturing specifications.
* Every dimension in millimetres unless noted otherwise.
*/
// ── Panel thicknesses ────────────────────────────────────────────────
export const PANEL = {
structure: 19, // Côtés, dessus, fond, séparateurs, étagères
reinforcement: 16, // Renforts Forteresse
back: 8, // Dos (panneau arrière)
reinforcementWidth: 100, // Largeur standard d'un renfort
};
// ── Reinforcement layout rules (Renforts Forteresse) ────────────────
export const REINFORCEMENT = {
topBottomOffsetMm: 19, // Renfort haut/bas à 19 mm
thirdRequiredAboveHeightMm: 1500, // 3e renfort si armoire > 1.5 m
};
// ── Edge banding (Chants ABS) ────────────────────────────────────────
export const EDGE_BANDING = {
abs: 2, // Chant ABS visible (2 mm)
hidden: 0.5, // Chant fin caché (0.5 mm)
backPanelSides: 2, // Dos: ABS sur 2 chants longs (haut + bas)
topBottomSides: 2, // Dessus/Fond: ABS faces avant ET arrière
shelfSides: 1, // Étagères: ABS face avant uniquement
};
// ── Groove for back panel (Rainure dos) ──────────────────────────────
export const GROOVE = {
back: {
width: 9, // mm — largeur rainure
depth: 10.5, // mm — profondeur rainure
axisFromPanelEdge: 16, // mm — rainure à 16 mm du bord des côtés/fond/dessus
},
};
/** Rainure dos — plans / CNC : même coupe que montant (vertical) vs dessus/fond/bandeau (horizontal). */
export const BACK_GROOVE_PLAN_VERTICAL_TYPES = ['cotes', 'fileur', 'cotesDoublage'];
export const BACK_GROOVE_PLAN_HORIZONTAL_TYPES = ['dessus', 'fond', 'bandeauHaut'];
/** @returns {'vertical' | 'horizontal' | null} */
export function getBackGroovePlanOrientation(elementType) {
if (BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(elementType)) return 'vertical';
if (BACK_GROOVE_PLAN_HORIZONTAL_TYPES.includes(elementType)) return 'horizontal';
return null;
}
/**
* Séparateurs : plaqués contre le dos (sans rainure). Largeur = même entraxe profondeur que le montant,
* diminuée du décalage d’axe et de la demi-largeur de rainure (côtés).
* @param {number} carcassDepthMm — profondeur caisse / largeur en plan du panneau côté (mm)
*/
export function getSeparatorDepthMm(carcassDepthMm) {
const d = Number(carcassDepthMm) || 0;
return Math.round(d - GROOVE.back.axisFromPanelEdge - GROOVE.back.width / 2);
}
// ── Connectors (Häfele Ixconnect) ────────────────────────────────────
export const CONNECTORS = {
// SC 8/60 — structural assembly (caisse: côtés ↔ dessus/fond)
ixconnect: {
model: 'SC 8/60',
edgeHole: { diameter: 8, depth: 45 }, // mm — perçage en about
faceHole: { diameter: 8, depth: 12 }, // mm — perçage en face
/** Trou de serrage Inbus — face intérieure panneaux horizontaux (dessus/fond) uniquement */
lockScrewInbus: {
diameter: 6,
depth: 9,
/** mm — depuis le chant d’assemblage (axe du perçage Ø45), vers l’intérieur du panneau */
offsetFromAssemblyEdgeMm: 25
}
},
// SC 8/25 — finish panels (portes, finitions)
ixconnectFinish: {
model: 'SC 8/25',
faceHole: { diameter: 8, depth: 12.5 }, // mm — face interne Ø8 × 12.5mm
axes: { back: 30, front: 43 }, // mm — axes: 30mm (arrière) / 43mm (avant)
},
// Positioning per structural joint (axes mm depuis le bord avant de l’arête, le long de la profondeur)
// — Arêtes d’extrémité (montants extérieurs): T → IX → T
// — Jonctions internes (séparateurs): Ixconnect à l’axe médian profondeur, tourillons ±30 mm de cet axe
jointPatternEdge: {
tourillon1Axis: 20,
ixconnectAxis: 50,
tourillon2Axis: 80,
},
/** @deprecated use jointPatternEdge — alias pour compat. */
jointPattern: {
tourillon1Axis: 20,
ixconnectAxis: 50,
tourillon2Axis: 80,
},
/** Tourillons par rapport à l’axe Ixconnect sur jonction milieu (mm). */
jointPatternMiddleTourillonOffsetFromIxMm: 30,
tourillon: {
diameter: 8, // mm — Ø tourillon
length: 30, // mm — tourillon Ø8×30
},
tourillonDiameter: 8, // mm — backward compat
};
/**
* mm — depuis le bord physique panneau côté dos : axe rainure + largeur rainure (= fin utile côté dos, repère Ixconnect).
* Évite chevauchement perçages / rainure dos sur dessus·fond.
*/
export function getBackGrooveInnerEndInsetFromBackEdgeMm() {
return GROOVE.back.axisFromPanelEdge + GROOVE.back.width;
}
/**
* Axes Ixconnect / tourillons le long de l’arête (profondeur caisse pour dessus·fond, largeur = profondeur pour côtés),
* mesurés depuis le bord avant (face avant).
* @param {'edge'|'middle'} variant — extrémité de caisse vs jonction entre modules (séparateur)
* @param {{ horizontalPanel?: boolean }} [options] — si `horizontalPanel`, la profondeur utile exclut la zone rainure dos (16+9 mm depuis le bord arrière).
*/
export function getIxconnectJointPatternAlongDepthMm(depthMm, variant, options = {}) {
const horizontalPanel = Boolean(options.horizontalPanel);
const inset = horizontalPanel ? getBackGrooveInnerEndInsetFromBackEdgeMm() : 0;
const d = Math.max(0, Number(depthMm) || 0);
const dEff = horizontalPanel ? Math.max(0, d - inset) : d;
const round1 = (v) => Math.round(v * 10) / 10;
if (variant === 'edge') {
const t1 = CONNECTORS.jointPatternEdge.tourillon1Axis;
const ix = CONNECTORS.jointPatternEdge.ixconnectAxis;
const t2 = CONNECTORS.jointPatternEdge.tourillon2Axis;
if (!horizontalPanel || dEff <= 0) {
return {
role: 'edge',
tourillon1Axis: t1,
ixconnectAxis: ix,
tourillon2Axis: t2
};
}
if (dEff >= t2) {
return {
role: 'edge',
tourillon1Axis: t1,
ixconnectAxis: ix,
tourillon2Axis: t2
};
}
const s = dEff / t2;
return {
role: 'edge',
tourillon1Axis: round1(t1 * s),
ixconnectAxis: round1(ix * s),
tourillon2Axis: round1(t2 * s)
};
}
if (dEff <= 0) {
return {
role: 'middle',
tourillon1Axis: 0,
ixconnectAxis: 0,
tourillon2Axis: 0
};
}
const ixMid = round1(dEff / 2);
const off = CONNECTORS.jointPatternMiddleTourillonOffsetFromIxMm;
return {
role: 'middle',
tourillon1Axis: round1(Math.max(0, ixMid - off)),
ixconnectAxis: ixMid,
tourillon2Axis: round1(Math.min(dEff, ixMid + off))
};
}
/**
* Largeurs de colonnes (mm) pour placer les jonctions — ordre: modules API, puis blocks, puis config.modules.
* Évite le cas « modules = [] » alors que `config.modules` contient les largeurs (une seule jonction / mauvais comptage).
*/
function getModuleWidthsForJunctions(modules = [], config = {}) {
const fromArr = (arr) =>
(Array.isArray(arr) ? arr : [])
.map((m) => Number(m?.widthMm) || 0)
.filter((v) => v > 0);
let w = fromArr(modules);
if (w.length === 0) w = fromArr(config.blocks);
if (w.length === 0) w = fromArr(config.modules);
return w;
}
/**
* Positions X des jonctions verticales (côtés + séparateurs) sur un panneau horizontal — mm depuis le bord gauche.
*/
export function getJunctionAxesAcrossWidthMm(dimensionsWidthMm, modules = [], config = {}) {
const maxW = Math.max(0, Number(dimensionsWidthMm) || 0);
const mWidths = getModuleWidthsForJunctions(modules, config);
if (mWidths.length > 0) {
let acc = 0;
const axes = [0];
for (let i = 0; i < mWidths.length - 1; i++) {
acc += mWidths[i];
axes.push(Math.max(0, Math.min(maxW, acc)));
}
axes.push(maxW);
const sorted = [...axes].sort((a, b) => a - b);
const dedup = [];
const eps = 0.5;
for (const v of sorted) {
if (dedup.length === 0 || Math.abs(v - dedup[dedup.length - 1]) > eps) {
dedup.push(Math.round(v * 100) / 100);
}
}
return dedup;
}
return [0, maxW];
}
// ── System 32 drilling ───────────────────────────────────────────────
export const SYSTEM32 = {
firstHoleOffset: 51, // mm — distance from panel top/bottom edge to first/last hole
pitch: 32, // mm — distance between consecutive holes
holeDiameter: 5, // mm — Ø 5 mm
holeDepth: 12, // mm — profondeur de perçage (borgne, côtés de caisse)
throughHole: false, // Côtés de caisse: perçages borgnes (non traversants)
};
// ── System 32 for vertical separators ────────────────────────────────
export const SYSTEM32_SEPARATORS = {
firstHoleOffset: 32, // mm — 51mm - 19mm (fond) = 32mm from bottom edge
pitch: 32, // mm — same pitch
holeDiameter: 5, // mm — Ø 5 mm
throughHole: true, // Séparations verticales: Ø5mm TRAVERSANTS
};
// ── Hinge positioning (Blum CLIP top) ────────────────────────────────
export const HINGE = {
axisOffset: 103, // mm — per APEX 4.0 reversibility rule
axisFrom: 'top_and_bottom',
perDoor: 3, // default, real count uses matrix below
reversible: true, // Panels are reversible (symmetric S32 drilling)
model: 'Blum CLIP top',
/** Per hinge cup: two Ø8 bores (Blum drilling pattern), depth mm, spacing mm (center–center), offset mm from cup center toward door interior (⟂ hinge edge). */
cupScrewDiameter: 8,
cupScrewDepth: 13,
cupScrewSpacing: 45,
cupScrewOffsetFromCupCenter: 9.5,
};
// Height/weight matrix from APEX 4.0 section 6.B
export const HINGE_MATRIX = [
{ maxHeightMm: 900, maxWeightKg: 6, hinges: 2 },
{ maxHeightMm: 1600, maxWeightKg: 17, hinges: 3 },
{ maxHeightMm: 2000, maxWeightKg: 20, hinges: 4 },
{ maxHeightMm: 2400, maxWeightKg: 22, hinges: 5 },
{ maxHeightMm: 2600, maxWeightKg: Infinity, hinges: 6 }
];
export function getHingeCountForDoor({ heightMm, weightKg = null }) {
const h = Number(heightMm) || 0;
const hasWeight = weightKg !== null && weightKg !== undefined && weightKg !== '';
const w = hasWeight && Number.isFinite(Number(weightKg)) ? Number(weightKg) : null;
for (const row of HINGE_MATRIX) {
const byHeight = h <= row.maxHeightMm;
const byWeight = w == null ? false : w <= row.maxWeightKg;
if (byHeight || byWeight) return row.hinges;
}
return HINGE_MATRIX[HINGE_MATRIX.length - 1].hinges;
}
export function getHingeCupPositions(doorHeightMm, hingeCount = null) {
const height = Number(doorHeightMm) || 0;
const count = Math.max(2, Number(hingeCount) || getHingeCountForDoor({ heightMm: height }));
const first = HINGE.axisOffset;
const last = Math.max(first, height - HINGE.axisOffset);
if (count <= 2) return [first, last];
const spaces = count - 1;
const rawStep = (last - first) / spaces;
const positions = [first];
for (let i = 1; i < spaces; i++) {
const rawPos = first + rawStep * i;
// Rule: align intermediary hinges on System 32 grid.
const snapped = Math.round(rawPos / 32) * 32;
const minPos = positions[positions.length - 1] + 32;
const maxPos = last - (spaces - i) * 32;
positions.push(Math.min(Math.max(snapped, minPos), maxPos));
}
positions.push(last);
return positions;
}
// ── Plinth system (AXILO™ 78) ───────────────────────────────────────
export const PLINTH = {
model: 'AXILO 78',
footHeight: 78, // mm — hauteur pied réglable
clipsPerMeter: 3, // clips de plinthe par mètre linéaire
// Reserved packing space for hardware kit
packingReserved: true,
};
// ── Reinforcement machining (Renforts Forteresse) ────────────────────
export const REINFORCEMENT_MACHINING = {
// Sur chaque petit chant d'extrémité: 1× Ixconnect SC 8/60 au centre + 2× tourillons à 20 mm des bords
connectorModel: 'SC 8/60',
connector: {
model: 'SC 8/60',
edgeHole: { diameter: 8, depth: 45 },
faceHole: { diameter: 8, depth: 12 },
},
ixconnectCenter: true,
tourillonCount: 2,
tourillonEdgeOffset: 20, // mm — tourillons à 20 mm des bords (axe mesuré depuis le même repère que l’Ixconnect)
tourillonDiameter: 8,
};
/**
* Axes de perçage sur le petit chant d'extrémité (mm depuis le bord haut du renfort).
* @param {number} reinforcementFaceHeightMm — hauteur de la face de renfort (généralement 100 mm)
*/
export function getReinforcementEndFixingAxesFromTopMm(reinforcementFaceHeightMm) {
const h = Math.max(0, Number(reinforcementFaceHeightMm) || 0);
const off = REINFORCEMENT_MACHINING.tourillonEdgeOffset;
const round1 = (v) => Math.round(v * 10) / 10;
return {
// Deux tourillons proches des bords haut/bas de la face 100mm.
tourillonAxesFromTopMm: [round1(off), round1(Math.max(off, h - off))],
ixconnectAxisFromTopMm: round1(h / 2),
};
}
// ── Niche and sanitary gaps ─────────────────────────────────────────────
export const NICHE = {
sanitaryGapAround: 30, // mm — vide sanitaire de 3 cm tout autour
drawerBackGap: 10, // mm — vide sanitaire derrière tiroir
};
// ── Drawers (manufactured) ──────────────────────────────────────────────
export const DRAWERS = {
maxColumnWidth: 1000, // mm
minDepth: 400, // mm
maxDepth: 650, // mm
depthStep: 50, // mm
hiddenHandleDepthReduction: 40, // mm (assumption until final handle specs)
standardHeights: [60, 90, 128, 177, 240],
};
/**
* Renforts latéraux caisse (25 mm) — uniquement si tiroirs cachés derrière porte ;
* un montant gauche + un droit par colonne à tiroirs, même profondeur (min. des deux parois).
* Fixation sur la face intérieure des montants / séparateurs.
*/
export const DRAWER_REINFORCEMENT = {
thicknessMm: 25,
/** mm depuis le fond caisse (repère rainure côté ou fond séparateur) — début utile du renfort */
backInsetFromRearMm: 25,
/** mm réservés vers l’avant quand les tiroirs sont derrière une porte à charnières */
hingeFrontClearanceMm: 77,
/** mm — déduit de la hauteur pile : partie droite au-dessus du rayon façade tiroir du haut */
topFrontRadiusMm: 3,
/** Ixconnect « petit » — même famille que finitions SC 8/25 */
ixconnectModel: 'SC 8/25',
/** mm — retrait des axes depuis chaque bord (chaque coin : 50 mm des deux côtés) */
ixconnectCornerInsetMm: 50,
ixconnectHoleDiameterMm: 8,
/** Perçage face côté renfort (vers montant / séparateur) */
ixconnectDepthInReinforcementMm: 13,
/** Perçage face intérieure montant ou séparateur (vis-à-vis) */
ixconnectDepthInVerticalPanelMm: 12,
};
/**
* Ixconnect SC 8/25 sur montants / séparateurs — zone tiroirs cachés + renfort.
* Repère face intérieure : X depuis bord avant (profondeur), Y depuis bas du panneau (corps).
*/
export const DRAWER_RENFORT_PANEL_IXCONNECT = {
model: 'SC 8/25',
/** 77 + 50 mm depuis le devant (axe avant) */
frontAxesFromFrontMm: 77 + 50,
/** Côté caisse : 19 + 9 + 50 mm depuis le fond */
backAxesFromRearCoteMm: 19 + 9 + 50,
/** Séparateur : 50 mm depuis le fond */
backAxesFromRearSeparatorMm: 50,
/** 50 mm du haut / bas de la zone renfort */
verticalInsetMm: 50,
faceHoleDiameterMm: 8,
faceHoleDepthMm: 12
};
/**
* 4 trous (rectangle) sur une face intérieure montant/séparateur pour renfort tiroir caché.
* @param {number} panelDepthMm — largeur du panneau en plan (= profondeur caisse ou prof. séparateur)
* @param {boolean} isCote — true = montant extérieur, false = séparateur
* @param {number} zoneBottomFromPanelBottomMm — bas de la zone tiroirs depuis bas du panneau
* @param {number} renfortHeightMm — hauteur utile renfort (= pile tiroirs − R)
*/
export function buildDrawerRenfortPanelIxconnectCornersMm(
panelDepthMm,
isCote,
zoneBottomFromPanelBottomMm,
renfortHeightMm
) {
const P = DRAWER_RENFORT_PANEL_IXCONNECT;
const D = Math.max(0, Number(panelDepthMm) || 0);
const z0 = Math.max(0, Number(zoneBottomFromPanelBottomMm) || 0);
const h = Math.max(0, Number(renfortHeightMm) || 0);
const backInset = isCote ? P.backAxesFromRearCoteMm : P.backAxesFromRearSeparatorMm;
const xf = P.frontAxesFromFrontMm;
const xb = Math.max(xf + 1, D - backInset);
const y1 = z0 + P.verticalInsetMm;
const y2 = z0 + h - P.verticalInsetMm;
if (h < 2 * P.verticalInsetMm + 5 || D < xf + backInset + 5 || y2 <= y1) return [];
return [
{ xMm: xf, yMm: y1, role: 'avant_bas' },
{ xMm: xf, yMm: y2, role: 'avant_haut' },
{ xMm: xb, yMm: y1, role: 'fond_bas' },
{ xMm: xb, yMm: y2, role: 'fond_haut' }
];
}
/**
* 4 trous d’angle pour Ixconnect SC 8/25 — repère plan face : X depuis bord gauche, Y depuis bord haut (mm).
* @returns {{ xMm: number, yMm: number, role: string }[]}
*/
export function getDrawerReinforcementIxconnectHolePositionsMm(widthMm, heightMm, insetMm = DRAWER_REINFORCEMENT.ixconnectCornerInsetMm) {
const w = Math.max(0, Number(widthMm) || 0);
const h = Math.max(0, Number(heightMm) || 0);
const ins = Math.max(8, Math.min(Number(insetMm) || 50, Math.min(w, h) / 2 - 0.5));
if (w < 2 * ins || h < 2 * ins) return [];
return [
{ xMm: ins, yMm: ins, role: 'nw' },
{ xMm: w - ins, yMm: ins, role: 'ne' },
{ xMm: ins, yMm: h - ins, role: 'sw' },
{ xMm: w - ins, yMm: h - ins, role: 'se' }
];
}
/**
* Hauteur totale du bloc tiroirs (façades + jeux), mm.
* @param {number} drawerCount
* @param {number[]} drawerHeightsMm
* @param {number} [drawerGapMm]
*/
export function drawerStackHeightMm(drawerCount, drawerHeightsMm, drawerGapMm = 8) {
const n = Math.max(0, Number(drawerCount) || 0);
if (n <= 0) return 0;
const heights = Array.isArray(drawerHeightsMm) && drawerHeightsMm.length ? drawerHeightsMm : DRAWERS.standardHeights;
const gap = Number.isFinite(Number(drawerGapMm)) ? Number(drawerGapMm) : 8;
const sum = heights
.slice(0, Math.min(n, heights.length))
.reduce((s, v) => s + (Number(v) || 0), 0);
return Math.max(0, Math.round(sum + Math.max(0, n - 1) * gap));
}
// ── Full-height handle assumptions ──────────────────────────────────────
export const HANDLE = {
/** Retrait mécanique rainure poignée (haut + bas) */
fullHeightTopBottomOffset: 2, // mm from top and bottom
/** Déduction largeur panneau porte (vs poignée standard) — même valeur liste de débitage / plans */
fullHeightWidthDeductionMm: 18,
/** Rainure usinée sur le montant côté poignée (profil type dos, à confirmer au profilé) */
fullHeightGroove: {
width: GROOVE.back.width,
depth: GROOVE.back.depth,
},
};
/**
* Côté charnières en élévation pour la porte d’index `doorIndex` :
* vantail extérieur gauche → charnières gauche ; droit → droite ; portes centrales → réglage utilisateur.
*/
export function getDoorHingeSide(doorIndex, doorCount, hardware = {}) {
const hw = hardware || {};
const n = Math.max(1, Number(doorCount) || 1);
const i = Math.max(0, Number(doorIndex) || 0);
if (n <= 1) return hw.doorHingeSide === 'right' ? 'right' : 'left';
if (i === 0) return 'left';
if (i === n - 1) return 'right';
return hw.centerDoorHingeSide === 'right' ? 'right' : 'left';
}
// ── Block assembly drilling ─────────────────────────────────────────────
export const BLOCK_ASSEMBLY = {
sideOffsetFromFace: 30, // mm
sideOffsetFromBack: 30, // mm
topBottomOffset: 100, // mm
middleCount: 3, // per joint
};
// ── Carcass assembly ferrures ───────────────────────────────────────────
export const CARCASS_FASTENERS = {
/** Ferrure d'assemblage utilisée pour les jonctions de caisse (côtés/séparateurs ↔ dessus/fond). */
model: 'Häfele Rasant-Tab',
/** Référence produit (Ferrures d’assemblage Rasant-Tab, Ø trou 3 ou 5 mm). */
productUrl: 'https://www.haefele.ch/fr/produkt/ferrures-d-assemblage-rasant-tab-visser-dans-trou-de-per-age-3-ou-5-mm/26236520/?MasterSKU=P-00861456',
piecesPerJoint: 3,
/** Retrait des ferrures d’extrémité (mm) */
endInsetMm: 50,
edgeOffsets: [50, 'middle', 50],
/** Perçage ferrure: profondeur sur longueur de la pièce (mm) */
ferrureDepthAlongLengthMm: 46,
/** Perçage ferrure: profondeur sur l’épaisseur de la pièce (mm) */
ferrureDepthInThicknessMm: 12,
dowel: {
diameter: 8,
/** Longueur nominale tourillon (réf. atelier) */
depth: 40,
/** Perçage borgne en face (dans l’épaisseur du panneau), mm — toujours 12 pour dessus/fond */
faceDrillDepthMm: 12,
/** 2× tourillons par ferrure : axes à ±sideOffset du centre de chaque ferrure (mm) */
sideOffset: 30,
},
};
/** Profondeur effective perçage tourillon en face (ne dépasse pas l’épaisseur). */
export function tourillonFaceDrillDepthMm(thicknessMm) {
const cap = CARCASS_FASTENERS.dowel.faceDrillDepthMm;
const t = Math.max(1, Number(thicknessMm) || PANEL.structure);
return Math.min(cap, Math.max(6, t - 2));
}
/** Longueur mini. d’arête (mm) pour placer une ferrure au milieu (en plus des 50 mm d’extrémité). */
export const CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM = 500;
/**
* Centres des ferrures le long d’une arête de jonction.
* Règle: 50 mm des extrémités ; ferrure centrale seulement si l’arête dépasse {@link CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM}.
* @param {number} edgeLengthMm — longueur de l’arête (mm), ici hauteur utile de la jonction
* @returns {number[]} distances depuis le début de l’arête (mm)
*/
export function getCarcassJointPositions(edgeLengthMm) {
const L = Math.max(0, Number(edgeLengthMm) || 0);
if (L <= 0) return [];
const inset = Math.min(CARCASS_FASTENERS.endInsetMm, Math.max(20, L * 0.1));
const tail = L - inset;
const minMid = CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM;
if (tail <= inset + 20) {
if (L > minMid) {
const u = L / 4;
return [u, L / 2, L - u].map((v) => Math.round(Math.min(L, Math.max(0, v)) * 10) / 10);
}
const u = L / 4;
return [u, L - u].map((v) => Math.round(Math.min(L, Math.max(0, v)) * 10) / 10);
}
if (L > minMid) {
return [inset, L / 2, tail].map((v) => Math.round(v * 10) / 10);
}
return [inset, tail].map((v) => Math.round(v * 10) / 10);
}
/**
* Chaque ferrure: 3 perçages alignés sur la longueur:
* - 1 perçage ferrure au centre
* - 2 perçages tourillons à ±sideOffset du centre
* @returns {{ centerMm: number, holesAlongMm: [number, number, number] }[]}
*/
export function getCarcassFerrureDowelDrillings(centerPositionsMm, edgeLengthMm, sideOffsetMm = CARCASS_FASTENERS.dowel.sideOffset) {
const L = Math.max(0, Number(edgeLengthMm) || 0);
const off = Math.max(0, Number(sideOffsetMm) || 0);
const centers = centerPositionsMm || [];
const clamp = (v) => Math.round(Math.min(L, Math.max(0, v)) * 10) / 10;
return centers.map((c0) => {
const c = Number(c0);
return { centerMm: c, holesAlongMm: [clamp(c - off), clamp(c), clamp(c + off)] };
});
}
// ── Shelf machining ─────────────────────────────────────────────────
export const SHELF_MACHINING = {
// Tourillons Ø8 front + back for shelf pin alignment
tourillonDiameter: 8, // mm
tourillonCount: 4, // 2 front + 2 back per shelf
// "2 rayons sur une même strate de 19mm" — 2 shelves can share same S32 row
shelvesPerStratum: 2,
};
// ── Packing ──────────────────────────────────────────────────────────
export const PACKING = {
maxWeightKg: 28,
// Approximate density for weight estimation (kg per m³)
densityKgM3: {
melamine19: 680, // Panneau mélaminé 19 mm
melamine16: 660, // Panneau mélaminé 16 mm (renforts)
melamine25: 670, // Panneau mélaminé 25 mm (renforts tiroir)
melamine8: 640, // Panneau mélaminé 8 mm (dos)
},
// Layer-based packing strategy (per APEX 4.0 spec):
layers: {
level1: 'sides', // Niveau 1: Côtés empilés
level2: 'topAndBottom', // Niveau 2: Dessus + fond côte à côte
level3: 'shelvesAndRenforts', // Niveau 3: Étagères + renforts groupés par épaisseur
},
// Hardware kit reservation (AXILO™ pieds + clips de plinthe)
hardwareKitReserved: true, // Espace réservé dans le colisage
};
// ── Convenience: convert mm constants to metres (for Three.js) ──────
export const PANEL_M = {
structure: PANEL.structure / 1000,
reinforcement: PANEL.reinforcement / 1000,
back: PANEL.back / 1000,
reinforcementWidth: PANEL.reinforcementWidth / 1000,
};