v1.0
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
||||
node_modules/
|
||||
server/data/
|
||||
.env
|
||||
*.db
|
||||
*.sqlite
|
||||
.DS_Store
|
||||
|
||||
# Build output
|
||||
client/dist/
|
||||
|
||||
# Downloaded material images (generated by Formex crawler)
|
||||
client/public/materials/*.jpg
|
||||
client/public/materials/*.png
|
||||
client/public/materials/*.webp
|
||||
!client/public/materials/.gitkeep
|
||||
|
||||
# Uploaded room photos
|
||||
client/public/rooms/*.jpg
|
||||
client/public/rooms/*.jpeg
|
||||
client/public/rooms/*.png
|
||||
client/public/rooms/*.webp
|
||||
!client/public/rooms/.gitkeep
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr-CH">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Configurateur armoires sur mesure — SIGNATURE</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2541
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "configurateur-armoires-client",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@react-three/drei": "^9.117.0",
|
||||
"@react-three/fiber": "^8.17.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"three": "^0.169.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# This directory stores downloaded Formex material images
|
||||
# The actual images are not tracked by git
|
||||
@@ -0,0 +1,2 @@
|
||||
# This directory stores uploaded room photos
|
||||
# The actual images are not tracked by git
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import Layout from './components/Layout';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import Home from './pages/Home';
|
||||
import Configurator from './pages/Configurator';
|
||||
import Login from './pages/Login';
|
||||
import Admin from './pages/Admin';
|
||||
import Materials from './pages/Materials';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
|
||||
<Route path="/configurateur" element={<ProtectedRoute><Configurator /></ProtectedRoute>} />
|
||||
<Route path="/materiaux" element={<ProtectedRoute><Materials /></ProtectedRoute>} />
|
||||
<Route path="/admin" element={<ProtectedRoute><Admin /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
export async function fetchProducts() {
|
||||
const res = await fetch(`${API_BASE}/products`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchBlockTypes() {
|
||||
const res = await fetch(`${API_BASE}/block-types`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchFinishes() {
|
||||
const res = await fetch(`${API_BASE}/finishes`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchServiceOptions() {
|
||||
const res = await fetch(`${API_BASE}/service-options`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchSettings() {
|
||||
const res = await fetch(`${API_BASE}/settings`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function calculatePrice(config) {
|
||||
const res = await fetch(`${API_BASE}/price`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchOrders() {
|
||||
const res = await fetch(`${API_BASE}/orders`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function submitOrder(order) {
|
||||
const res = await fetch(`${API_BASE}/orders`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(order)
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Formex sync API
|
||||
|
||||
export async function triggerFormexSync() {
|
||||
const res = await fetch(`${API_BASE}/sync/formex`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SSE stream URL for real-time sync progress
|
||||
*/
|
||||
export function getFormexSyncStreamUrl() {
|
||||
return `${API_BASE}/sync/formex/stream`;
|
||||
}
|
||||
|
||||
export async function fetchSyncLogs(limit = 20) {
|
||||
const res = await fetch(`${API_BASE}/sync/logs?limit=${limit}`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchSyncStatus() {
|
||||
const res = await fetch(`${API_BASE}/sync/status`);
|
||||
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 } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('search', search);
|
||||
if (collection) params.set('collection', collection);
|
||||
if (category) params.set('category', category);
|
||||
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}/formex/materials?${params}`);
|
||||
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());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createFormexMaterial(data) {
|
||||
const res = await fetch(`${API_BASE}/formex/materials`, {
|
||||
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 updateFormexMaterial(id, data) {
|
||||
const res = await fetch(`${API_BASE}/formex/materials/${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 deleteFormexMaterial(id) {
|
||||
const res = await fetch(`${API_BASE}/formex/materials/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchFormexCollections() {
|
||||
const res = await fetch(`${API_BASE}/formex/collections`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchFormexCategories() {
|
||||
const res = await fetch(`${API_BASE}/formex/categories`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch material details including dimensions and color variants
|
||||
* Alias for fetchFormexMaterial for use in the configurator
|
||||
*/
|
||||
export async function fetchMaterialDetails(id) {
|
||||
return fetchFormexMaterial(id);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* AIRenderModal - Generates and displays a DALL-E 3 photorealistic render
|
||||
* of the configured wardrobe, giving the customer a realistic preview.
|
||||
*/
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import styles from './AIRenderModal.module.css';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function AIRenderModal({ isOpen, config, roomConfig, onClose }) {
|
||||
const [imageUrl, setImageUrl] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const abortRef = useRef(null);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
setImageUrl(null);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
setProgress(0);
|
||||
|
||||
// Simulate progress (DALL-E takes ~15-25s)
|
||||
timerRef.current = setInterval(() => {
|
||||
setProgress(p => Math.min(p + Math.random() * 8, 92));
|
||||
}, 1000);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const body = {
|
||||
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,
|
||||
shelves: m.shelves,
|
||||
drawers: m.drawers,
|
||||
hasRod: m.hasRod
|
||||
})),
|
||||
materialName: config.finishLabel || null,
|
||||
doorType: config.doorType
|
||||
},
|
||||
roomContext: roomConfig || null
|
||||
};
|
||||
|
||||
fetch(`${API_BASE}/api/ai/render`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(r.status === 402 ? 'quota' : 'api');
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.imageUrl) {
|
||||
setImageUrl(data.imageUrl);
|
||||
setProgress(100);
|
||||
} else {
|
||||
throw new Error('no_image');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (err.name === 'AbortError') return;
|
||||
if (err.message === 'quota') {
|
||||
setError('Quota IA dépassé. Réessayez plus tard.');
|
||||
} else {
|
||||
setError('Impossible de générer le rendu. Vérifiez la configuration IA.');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
});
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!imageUrl) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = imageUrl;
|
||||
a.download = `armoire-rendu-${Date.now()}.png`;
|
||||
a.target = '_blank';
|
||||
a.click();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onClick={onClose}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>
|
||||
<span className={styles.aiBadge}>IA</span>
|
||||
Rendu photoréaliste
|
||||
</h2>
|
||||
<button className={styles.closeBtn} onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
{loading && !imageUrl && (
|
||||
<div className={styles.loadingArea}>
|
||||
<div className={styles.spinner} />
|
||||
<p className={styles.loadingText}>Génération en cours...</p>
|
||||
<div className={styles.progressBar}>
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className={styles.loadingHint}>
|
||||
L'IA crée un rendu réaliste de votre armoire. Cela peut prendre 15-25 secondes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className={styles.errorArea}>
|
||||
<p className={styles.errorText}>{error}</p>
|
||||
<button className={styles.retryBtn} onClick={() => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
setProgress(0);
|
||||
}}>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imageUrl && (
|
||||
<div className={styles.imageArea}>
|
||||
<img src={imageUrl} alt="Rendu photoréaliste de l'armoire" className={styles.renderImage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
{imageUrl && (
|
||||
<button className={styles.downloadBtn} onClick={handleDownload}>
|
||||
Télécharger l'image
|
||||
</button>
|
||||
)}
|
||||
<button className={styles.closeFooterBtn} onClick={onClose}>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
width: min(95vw, 900px);
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-light, #e5e7eb);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
color: var(--text, #1f2937);
|
||||
}
|
||||
|
||||
.aiBadge {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--action, #4f46e5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted, #6b7280);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
background: var(--bg-subtle, #f3f4f6);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loadingArea {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid var(--border-light, #e5e7eb);
|
||||
border-top-color: var(--action, #4f46e5);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loadingText {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text, #1f2937);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
width: 260px;
|
||||
height: 6px;
|
||||
background: var(--border-light, #e5e7eb);
|
||||
border-radius: 3px;
|
||||
margin: 0 auto 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
background: var(--action, #4f46e5);
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.loadingHint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #6b7280);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.errorArea {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
font-size: 0.9rem;
|
||||
color: var(--danger, #dc2626);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.retryBtn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--action, #4f46e5);
|
||||
background: transparent;
|
||||
color: var(--action, #4f46e5);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.retryBtn:hover {
|
||||
background: var(--bg-subtle, #f9fafb);
|
||||
}
|
||||
|
||||
/* Image */
|
||||
.imageArea {
|
||||
padding: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.renderImage {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border-light, #e5e7eb);
|
||||
}
|
||||
|
||||
.downloadBtn {
|
||||
padding: 8px 16px;
|
||||
background: var(--action, #4f46e5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.downloadBtn:hover {
|
||||
background: var(--action-hover, #4338ca);
|
||||
}
|
||||
|
||||
.closeFooterBtn {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
color: var(--text-muted, #6b7280);
|
||||
border: 1px solid var(--border-light, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.closeFooterBtn:hover {
|
||||
background: var(--bg-subtle, #f9fafb);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* ColorPickerModal - Compact swatch-grid color picker for wardrobe elements.
|
||||
* Sidebar collections + dense swatch grid = everything visible at once.
|
||||
*/
|
||||
import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||||
import styles from './ColorPickerModal.module.css';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
const ELEMENT_LABELS = {
|
||||
plintheBas: 'Plinthe bas',
|
||||
fileur: 'Fileur',
|
||||
bandeauHaut: 'Bandeau haut',
|
||||
champs: 'Champs (bords)',
|
||||
caisse: 'Caisse',
|
||||
cotesDoublage: 'Doublage',
|
||||
cotes: 'Cotes',
|
||||
dessus: 'Dessus',
|
||||
fond: 'Fond',
|
||||
dos: 'Dos',
|
||||
portes: 'Portes',
|
||||
panneauxInterieurs: 'Panneaux int.',
|
||||
separateurs: 'Separateurs',
|
||||
etageres: 'Etageres',
|
||||
tiroirs: 'Tiroirs',
|
||||
penderie: 'Tringle'
|
||||
};
|
||||
|
||||
export default function ColorPickerModal({
|
||||
isOpen,
|
||||
elementType,
|
||||
currentFinish,
|
||||
finishes = [],
|
||||
onSelect,
|
||||
onClose,
|
||||
onApplyToAll,
|
||||
config
|
||||
}) {
|
||||
const [selectedFinish, setSelectedFinish] = useState(currentFinish);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCollection, setSelectedCollection] = useState('all');
|
||||
const [hoveredFinish, setHoveredFinish] = useState(null);
|
||||
const [aiRecs, setAiRecs] = useState([]);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const tooltipRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedFinish(currentFinish);
|
||||
setSearchTerm('');
|
||||
setSelectedCollection('all');
|
||||
setHoveredFinish(null);
|
||||
setAiRecs([]);
|
||||
// Fetch AI recommendations
|
||||
if (config && finishes.length > 0) {
|
||||
setAiLoading(true);
|
||||
const controller = new AbortController();
|
||||
fetch(`${API_BASE}/api/ai/recommend-materials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
config: {
|
||||
width: config.totalWidth,
|
||||
height: config.height,
|
||||
depth: config.depth,
|
||||
columns: config.modules?.map(m => ({
|
||||
type: m.type || m.moduleCode,
|
||||
width: m.width
|
||||
})),
|
||||
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 }))
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data?.recommendations) {
|
||||
setAiRecs(data.recommendations);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setAiLoading(false));
|
||||
return () => controller.abort();
|
||||
}
|
||||
}
|
||||
}, [isOpen, currentFinish]);
|
||||
|
||||
const collections = useMemo(() => {
|
||||
const cols = [...new Set(finishes.map(f => f.collection).filter(Boolean))];
|
||||
return cols.sort();
|
||||
}, [finishes]);
|
||||
|
||||
const filteredFinishes = useMemo(() => {
|
||||
return finishes.filter(finish => {
|
||||
const matchesSearch = !searchTerm ||
|
||||
finish.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
finish.label?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
finish.code?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesCollection = selectedCollection === 'all' ||
|
||||
finish.collection === selectedCollection;
|
||||
return matchesSearch && matchesCollection;
|
||||
});
|
||||
}, [finishes, searchTerm, selectedCollection]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedFinish && onSelect) {
|
||||
onSelect(elementType, selectedFinish);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleApplyToAll = () => {
|
||||
if (selectedFinish && onApplyToAll) {
|
||||
onApplyToAll(selectedFinish);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onClick={onClose}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
{/* Header: title + search */}
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>
|
||||
{ELEMENT_LABELS[elementType] || elementType}
|
||||
</h2>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.searchInput}
|
||||
placeholder="Rechercher..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button className={styles.closeBtn} onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
{/* Body: sidebar + swatch grid */}
|
||||
<div className={styles.body}>
|
||||
{/* Collection sidebar */}
|
||||
<div className={styles.sidebar}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.sidebarTab} ${selectedCollection === 'all' ? styles.sidebarTabActive : ''}`}
|
||||
onClick={() => setSelectedCollection('all')}
|
||||
>
|
||||
Tous
|
||||
<span className={styles.tabCount}>{finishes.length}</span>
|
||||
</button>
|
||||
{collections.map(col => {
|
||||
const count = finishes.filter(f => f.collection === col).length;
|
||||
return (
|
||||
<button
|
||||
key={col}
|
||||
type="button"
|
||||
className={`${styles.sidebarTab} ${selectedCollection === col ? styles.sidebarTabActive : ''}`}
|
||||
onClick={() => setSelectedCollection(col)}
|
||||
>
|
||||
{col}
|
||||
<span className={styles.tabCount}>{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Swatch grid */}
|
||||
<div className={styles.swatchArea}>
|
||||
{/* AI Recommendations */}
|
||||
{(aiRecs.length > 0 || aiLoading) && (
|
||||
<div className={styles.aiSection}>
|
||||
<div className={styles.aiHeader}>
|
||||
<span className={styles.aiBadge}>IA</span>
|
||||
<span className={styles.aiLabel}>Recommandations</span>
|
||||
</div>
|
||||
{aiLoading ? (
|
||||
<div className={styles.aiShimmer}>
|
||||
<div className={styles.aiShimmerDot} />
|
||||
<div className={styles.aiShimmerDot} />
|
||||
<div className={styles.aiShimmerDot} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.aiSwatches}>
|
||||
{aiRecs.map(rec => {
|
||||
const finish = finishes.find(f => f.id === rec.finishId);
|
||||
if (!finish) return null;
|
||||
const isSelected = selectedFinish?.id === finish.id;
|
||||
return (
|
||||
<button
|
||||
key={`ai-${finish.id}`}
|
||||
type="button"
|
||||
className={`${styles.swatch} ${isSelected ? styles.swatchSelected : ''} ${styles.aiSwatch}`}
|
||||
onClick={() => setSelectedFinish(finish)}
|
||||
onMouseEnter={() => setHoveredFinish({ ...finish, aiReason: rec.reason })}
|
||||
onMouseLeave={() => setHoveredFinish(null)}
|
||||
title={rec.reason}
|
||||
>
|
||||
{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>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredFinishes.length > 0 ? (
|
||||
<div className={styles.swatchGrid}>
|
||||
{filteredFinishes.map(finish => {
|
||||
const isSelected = selectedFinish?.id === finish.id;
|
||||
const isHovered = hoveredFinish?.id === finish.id;
|
||||
return (
|
||||
<button
|
||||
key={finish.id}
|
||||
type="button"
|
||||
className={`${styles.swatch} ${isSelected ? styles.swatchSelected : ''}`}
|
||||
onClick={() => setSelectedFinish(finish)}
|
||||
onMouseEnter={() => setHoveredFinish(finish)}
|
||||
onMouseLeave={() => setHoveredFinish(null)}
|
||||
title={`${finish.name || finish.label || ''} ${finish.code ? `(${finish.code})` : ''}`}
|
||||
>
|
||||
{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>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.empty}>Aucune finition trouvee</div>
|
||||
)}
|
||||
|
||||
{/* Hover detail strip -- shows info of hovered swatch */}
|
||||
{hoveredFinish && (
|
||||
<div className={styles.hoverDetail} ref={tooltipRef}>
|
||||
{hoveredFinish.image_url && (
|
||||
<img src={hoveredFinish.image_url} alt="" className={styles.hoverImg} />
|
||||
)}
|
||||
<div className={styles.hoverInfo}>
|
||||
<strong>{hoveredFinish.name || hoveredFinish.label}</strong>
|
||||
{hoveredFinish.code && <span className={styles.hoverCode}>{hoveredFinish.code}</span>}
|
||||
{hoveredFinish.collection && <span className={styles.hoverColl}>{hoveredFinish.collection}</span>}
|
||||
{hoveredFinish.price_chf != null && (
|
||||
<span className={styles.hoverPrice}>{hoveredFinish.price_chf.toFixed(2)} CHF</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer: selection preview + actions */}
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.preview}>
|
||||
{selectedFinish ? (
|
||||
<>
|
||||
{selectedFinish.image_url ? (
|
||||
<img src={selectedFinish.image_url} alt="" className={styles.previewImg} />
|
||||
) : selectedFinish.hexColor ? (
|
||||
<div className={styles.previewDot} style={{ backgroundColor: selectedFinish.hexColor }} />
|
||||
) : null}
|
||||
<strong>{selectedFinish.name || selectedFinish.label}</strong>
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.previewLabel}>Aucune selection</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<button className={styles.secondaryBtn} onClick={handleApplyToAll} disabled={!selectedFinish}>
|
||||
Appliquer a tous
|
||||
</button>
|
||||
<button className={styles.cancelBtn} onClick={onClose}>Annuler</button>
|
||||
<button className={styles.confirmBtn} onClick={handleConfirm} disabled={!selectedFinish}>
|
||||
Appliquer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
/* ===================================================================
|
||||
ColorPickerModal -- compact swatch-grid layout
|
||||
Sidebar collections + dense swatch grid = no scroll needed
|
||||
=================================================================== */
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
width: 95vw;
|
||||
max-width: 1100px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Header: title + search + close --- */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
background: var(--bg-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--action);
|
||||
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
line-height: 1;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* --- Body: sidebar + swatch area --- */
|
||||
.body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0; /* allow flex shrink */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Sidebar: vertical collection tabs --- */
|
||||
.sidebar {
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--border-light);
|
||||
background: var(--bg-subtle);
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.sidebarTab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.sidebarTab:hover {
|
||||
color: var(--text);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.sidebarTabActive {
|
||||
color: var(--action);
|
||||
background: #fff;
|
||||
border-left-color: var(--action);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabCount {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--border-light);
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
min-width: 1.4rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebarTabActive .tabCount {
|
||||
background: rgba(79, 70, 229, 0.12);
|
||||
color: var(--action);
|
||||
}
|
||||
|
||||
/* --- Swatch area: grid + hover detail --- */
|
||||
.swatchArea {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Dense swatch grid -- small squares, many per row */
|
||||
.swatchGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(52px, 1fr));
|
||||
gap: 6px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border: 2px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.swatch:hover {
|
||||
border-color: var(--action);
|
||||
transform: scale(1.08);
|
||||
z-index: 2;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.swatchSelected {
|
||||
border-color: var(--action);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.25);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.swatchImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.swatchColor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.swatchCheck {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--action);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* --- Hover detail strip (bottom of swatch area) --- */
|
||||
.hoverDetail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
margin-top: 8px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hoverImg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hoverInfo {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 10px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.hoverInfo strong {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.hoverCode {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.hoverColl {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.hoverPrice {
|
||||
font-size: 0.75rem;
|
||||
color: var(--success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- Footer: selection preview + action buttons --- */
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
background: var(--bg-subtle);
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview strong {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.previewImg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.previewDot {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.previewLabel {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cancelBtn,
|
||||
.confirmBtn,
|
||||
.secondaryBtn {
|
||||
padding: 7px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-light);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cancelBtn:hover {
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
background: #fff;
|
||||
border: 1px solid var(--action);
|
||||
color: var(--action);
|
||||
}
|
||||
|
||||
.secondaryBtn:hover:not(:disabled) {
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.secondaryBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.confirmBtn {
|
||||
background: var(--action);
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.confirmBtn:hover:not(:disabled) {
|
||||
background: var(--action-hover);
|
||||
}
|
||||
|
||||
.confirmBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* --- Mobile --- */
|
||||
@media (max-width: 640px) {
|
||||
.modal {
|
||||
max-height: 95vh;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
max-width: none;
|
||||
order: 3;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.swatchGrid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* AI Recommendations section */
|
||||
.aiSection {
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
background: linear-gradient(135deg, #f8f7ff 0%, #eef2ff 100%);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(79, 70, 229, 0.15);
|
||||
}
|
||||
|
||||
.aiHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.aiBadge {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--action, #4f46e5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.aiLabel {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #6b7280);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.aiSwatches {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.aiSwatch {
|
||||
position: relative;
|
||||
border-color: rgba(79, 70, 229, 0.3) !important;
|
||||
}
|
||||
|
||||
.aiSwatchBadge {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
font-size: 0.5rem;
|
||||
background: var(--action, #4f46e5);
|
||||
color: #fff;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.aiShimmer {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.aiShimmerDot {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: aiShimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes aiShimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* CuttingListTable - Display cutting list in a table format
|
||||
* Shows pieces grouped by finish with dimensions and quantities
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import styles from './CuttingListTable.module.css';
|
||||
|
||||
export default function CuttingListTable({ cuttingList, onExport }) {
|
||||
if (!cuttingList || !cuttingList.pieces) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
Aucune donnée de débitage disponible
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { pieces, byFinish, summary } = cuttingList;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Summary */}
|
||||
<div className={styles.summary}>
|
||||
<h3 className={styles.summaryTitle}>Résumé</h3>
|
||||
<div className={styles.summaryGrid}>
|
||||
<div className={styles.summaryItem}>
|
||||
<span className={styles.summaryLabel}>Pièces totales</span>
|
||||
<span className={styles.summaryValue}>{summary.totalPieces}</span>
|
||||
</div>
|
||||
<div className={styles.summaryItem}>
|
||||
<span className={styles.summaryLabel}>Surface totale</span>
|
||||
<span className={styles.summaryValue}>{summary.totalSurfaceM2.toFixed(2)} m²</span>
|
||||
</div>
|
||||
<div className={styles.summaryItem}>
|
||||
<span className={styles.summaryLabel}>Chant plaqué</span>
|
||||
<span className={styles.summaryValue}>{summary.edgeBandingMl} ml</span>
|
||||
</div>
|
||||
<div className={styles.summaryItem}>
|
||||
<span className={styles.summaryLabel}>Finitions</span>
|
||||
<span className={styles.summaryValue}>{summary.finishCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tables by finish */}
|
||||
{byFinish.map((group, groupIndex) => (
|
||||
<div key={groupIndex} className={styles.finishGroup}>
|
||||
<h4 className={styles.finishTitle}>
|
||||
<span className={styles.finishBadge}>{group.finishLabel}</span>
|
||||
<span className={styles.finishSummary}>
|
||||
{group.pieceCount} pièces • {group.totalSurfaceM2.toFixed(2)} m²
|
||||
</span>
|
||||
</h4>
|
||||
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Largeur</th>
|
||||
<th>Hauteur</th>
|
||||
<th>Ép.</th>
|
||||
<th>Qté</th>
|
||||
<th>Surface</th>
|
||||
<th>Chant</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.pieces.map((piece, pieceIndex) => (
|
||||
<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}>{piece.thicknessMm}</td>
|
||||
<td className={styles.qtyCell}>{piece.quantity}</td>
|
||||
<td className={styles.surfaceCell}>{piece.surfaceM2.toFixed(3)}</td>
|
||||
<td className={styles.edgeCell}>
|
||||
{piece.edgeBanding.type !== 'none' && (
|
||||
<span className={`${styles.edgeBadge} ${styles[piece.edgeBanding.type]}`}>
|
||||
{piece.edgeBanding.sides} côtés
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
.container {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.summary {
|
||||
background: #f0f7ff;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #cce0ff;
|
||||
}
|
||||
|
||||
.summaryTitle {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.summaryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summaryItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.summaryLabel {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.summaryValue {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.finishGroup {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.finishGroup:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.finishTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.finishBadge {
|
||||
padding: 4px 10px;
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.finishSummary {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
padding: 8px 10px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.descCell {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.dimCell {
|
||||
text-align: right;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.qtyCell {
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.surfaceCell {
|
||||
text-align: right;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.edgeCell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.edgeBadge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.edgeBadge.visible {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.edgeBadge.hidden {
|
||||
background: #fef3c7;
|
||||
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;
|
||||
}
|
||||
|
||||
.descCell {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.summaryGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* DesignTip - AI-powered design advice card that fades in below the 3D preview.
|
||||
* Shows contextual tips, compliments, and trends based on current configuration.
|
||||
*/
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import styles from './DesignTip.module.css';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
const CATEGORY_ICONS = {
|
||||
compliment: '✨',
|
||||
conseil: '💡',
|
||||
tendance: '🎯'
|
||||
};
|
||||
|
||||
export default function DesignTip({ config }) {
|
||||
const [tip, setTip] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const debounceRef = useRef(null);
|
||||
const abortRef = useRef(null);
|
||||
|
||||
const fetchTip = useCallback(async (cfg) => {
|
||||
// Cancel previous request
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const body = {
|
||||
config: {
|
||||
width: cfg.totalWidth,
|
||||
height: cfg.height,
|
||||
depth: cfg.depth,
|
||||
installationType: cfg.installationType,
|
||||
columns: cfg.modules?.map(m => ({
|
||||
type: m.type || m.moduleCode,
|
||||
width: m.width,
|
||||
shelves: m.shelves,
|
||||
drawers: m.drawers,
|
||||
hasRod: m.hasRod
|
||||
})),
|
||||
materialName: cfg.finishLabel || null,
|
||||
doorType: cfg.doorType
|
||||
}
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/ai/design-tips`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('API error');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.tip) {
|
||||
setTip(data);
|
||||
setVisible(true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
// Silently fail — tip is optional
|
||||
setVisible(false);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Debounced fetch on config change
|
||||
useEffect(() => {
|
||||
if (!config || !config.modules?.length) return;
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
fetchTip(config);
|
||||
}, 2000); // 2s debounce to avoid spamming on rapid changes
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [
|
||||
config?.totalWidth,
|
||||
config?.height,
|
||||
config?.modules?.length,
|
||||
config?.modules?.map(m => m.type).join(','),
|
||||
config?.finishId,
|
||||
config?.doorType,
|
||||
fetchTip
|
||||
]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!visible && !loading) return null;
|
||||
|
||||
return (
|
||||
<div className={`${styles.container} ${visible && tip ? styles.visible : ''}`}>
|
||||
{loading && !tip && (
|
||||
<div className={styles.shimmer}>
|
||||
<div className={styles.shimmerLine} />
|
||||
<div className={styles.shimmerLineShort} />
|
||||
</div>
|
||||
)}
|
||||
{tip && (
|
||||
<div className={styles.tipContent}>
|
||||
<span className={styles.icon}>{CATEGORY_ICONS[tip.category] || '✨'}</span>
|
||||
<p className={styles.text}>{tip.tip}</p>
|
||||
<span className={styles.badge}>IA</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
.container {
|
||||
margin-top: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #f8f7ff 0%, #f0f4ff 100%);
|
||||
border: 1px solid var(--border-light, #e5e7eb);
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tipContent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text, #374151);
|
||||
line-height: 1.45;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--action, #4f46e5);
|
||||
color: #fff;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Loading shimmer */
|
||||
.shimmer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.shimmerLine,
|
||||
.shimmerLineShort {
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.shimmerLine {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.shimmerLineShort {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import styles from './Layout.module.css';
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const { isAdmin, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.app}>
|
||||
<header className={styles.header}>
|
||||
<Link to="/" className={styles.logo}>SIGNATURE</Link>
|
||||
<nav>
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<Link to="/">Accueil</Link>
|
||||
<Link to="/configurateur">Configurateur</Link>
|
||||
<Link to="/materiaux">Matériaux</Link>
|
||||
<Link to="/admin">Admin</Link>
|
||||
<button type="button" className={styles.logoutBtn} onClick={handleLogout}>Déconnexion</button>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login">Connexion</Link>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
<main className={styles.main}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: 0.02em;
|
||||
text-decoration: none;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
text-decoration: none;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.header nav {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.header nav a {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header nav a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.header nav .logoutBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header nav .logoutBtn:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fetchMaterialDetails } from '../api/client';
|
||||
import styles from './MaterialDetailModal.module.css';
|
||||
|
||||
/**
|
||||
* Modal for displaying detailed material information in the configurator.
|
||||
* Shows product image, specs, dimension variants, and color variants.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} props.material - The material object (basic info)
|
||||
* @param {number|null} props.selectedDimensionId - Currently selected dimension variant ID
|
||||
* @param {Function} props.onSelectDimension - Called when a dimension is selected (dimensionId)
|
||||
* @param {Function} props.onSelectVariant - Called when a color variant is selected (material)
|
||||
* @param {Function} props.onConfirm - Called when user confirms selection
|
||||
* @param {Function} props.onClose - Called to close the modal
|
||||
*/
|
||||
export default function MaterialDetailModal({
|
||||
material,
|
||||
selectedDimensionId,
|
||||
onSelectDimension,
|
||||
onSelectVariant,
|
||||
onConfirm,
|
||||
onClose
|
||||
}) {
|
||||
const [details, setDetails] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [localDimensionId, setLocalDimensionId] = useState(selectedDimensionId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!material?.id) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchMaterialDetails(material.id)
|
||||
.then(data => {
|
||||
setDetails(data);
|
||||
// Reset dimension to default when material changes
|
||||
if (data.dimensions?.length > 0) {
|
||||
const defaultDim = data.dimensions.find(d => d.is_default) || data.dimensions[0];
|
||||
setLocalDimensionId(defaultDim.id);
|
||||
if (onSelectDimension) onSelectDimension(defaultDim.id);
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [material?.id]);
|
||||
|
||||
const handleDimensionClick = (dimId) => {
|
||||
setLocalDimensionId(dimId);
|
||||
if (onSelectDimension) {
|
||||
onSelectDimension(dimId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVariantClick = (variant) => {
|
||||
if (onSelectVariant) {
|
||||
onSelectVariant(variant);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm(material.id, localDimensionId);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const selectedDim = details?.dimensions?.find(d => d.id === localDimensionId);
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onClick={onClose}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<button className={styles.closeBtn} onClick={onClose} type="button" aria-label="Fermer">
|
||||
×
|
||||
</button>
|
||||
|
||||
{loading && <div className={styles.loading}>Chargement...</div>}
|
||||
|
||||
{error && <div className={styles.error}>Erreur: {error}</div>}
|
||||
|
||||
{!loading && !error && details && (
|
||||
<>
|
||||
<div className={styles.content}>
|
||||
{/* Product Image */}
|
||||
<div className={styles.imageSection}>
|
||||
{details.image_url ? (
|
||||
<img
|
||||
src={details.image_url}
|
||||
alt={details.name}
|
||||
className={styles.productImage}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.noImage}>Pas d'image</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<div className={styles.infoSection}>
|
||||
<h2 className={styles.productName}>{details.name}</h2>
|
||||
|
||||
<div className={styles.specs}>
|
||||
<div className={styles.specRow}>
|
||||
<span className={styles.specLabel}>Code:</span>
|
||||
<span className={styles.specValue}>{details.formex_code}</span>
|
||||
</div>
|
||||
<div className={styles.specRow}>
|
||||
<span className={styles.specLabel}>Collection:</span>
|
||||
<span className={styles.specValue}>{details.collection}</span>
|
||||
</div>
|
||||
{details.category && (
|
||||
<div className={styles.specRow}>
|
||||
<span className={styles.specLabel}>Catégorie:</span>
|
||||
<span className={styles.specValue}>{details.category}</span>
|
||||
</div>
|
||||
)}
|
||||
{details.price_chf && (
|
||||
<div className={styles.specRow}>
|
||||
<span className={styles.specLabel}>Prix:</span>
|
||||
<span className={styles.specValue}>{details.price_chf.toFixed(2)} CHF</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dimension Variants */}
|
||||
{details.dimensions && details.dimensions.length > 0 && (
|
||||
<div className={styles.dimensionsSection}>
|
||||
<h3 className={styles.sectionTitle}>Dimensions disponibles</h3>
|
||||
<div className={styles.dimensionButtons}>
|
||||
{details.dimensions.map(dim => (
|
||||
<button
|
||||
key={dim.id}
|
||||
type="button"
|
||||
className={`${styles.dimensionBtn} ${dim.id === localDimensionId ? styles.selected : ''}`}
|
||||
onClick={() => handleDimensionClick(dim.id)}
|
||||
>
|
||||
{dim.width_mm} x {dim.height_mm} x {dim.thickness_mm} mm
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedDim && (
|
||||
<p className={styles.selectedDimInfo}>
|
||||
Format sélectionné: <strong>{selectedDim.width_mm} x {selectedDim.height_mm} x {selectedDim.thickness_mm} mm</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Color Variants */}
|
||||
{details.variants && details.variants.length > 0 && (
|
||||
<div className={styles.variantsSection}>
|
||||
<h3 className={styles.sectionTitle}>Autres coloris</h3>
|
||||
<div className={styles.variantThumbs}>
|
||||
{details.variants.map(variant => (
|
||||
<button
|
||||
key={variant.id}
|
||||
type="button"
|
||||
className={styles.variantThumb}
|
||||
onClick={() => handleVariantClick(variant)}
|
||||
title={variant.variant_name}
|
||||
>
|
||||
{variant.image_url ? (
|
||||
<img src={variant.image_url} alt={variant.variant_name} />
|
||||
) : (
|
||||
<span className={styles.variantCode}>{variant.variant_code}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Formex Link */}
|
||||
{details.formex_url && (
|
||||
<a
|
||||
href={details.formex_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.formexLink}
|
||||
>
|
||||
Voir sur Formex.ch →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.cancelBtn} onClick={onClose}>
|
||||
Annuler
|
||||
</button>
|
||||
<button type="button" className={styles.confirmBtn} onClick={handleConfirm}>
|
||||
Sélectionner ce matériau
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Image Section */
|
||||
.imageSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.productImage {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.noImage {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Info Section */
|
||||
.infoSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.productName {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Specs */
|
||||
.specs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.specRow {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.specLabel {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.specValue {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* Dimensions */
|
||||
.dimensionsSection,
|
||||
.variantsSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dimensionButtons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dimensionBtn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.dimensionBtn:hover {
|
||||
border-color: var(--action);
|
||||
}
|
||||
|
||||
.dimensionBtn.selected {
|
||||
border-color: var(--action);
|
||||
background: #eef2ff;
|
||||
color: var(--action);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.selectedDimInfo {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Color Variants */
|
||||
.variantThumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.variantThumb {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.variantThumb:hover {
|
||||
border-color: var(--action);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.variantThumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.variantCode {
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Formex Link */
|
||||
.formexLink {
|
||||
display: inline-block;
|
||||
color: var(--action);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.formexLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Actions -- sticky at bottom */
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
background: var(--bg-subtle);
|
||||
border-radius: 0 0 12px 12px;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.cancelBtn,
|
||||
.confirmBtn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.cancelBtn:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.confirmBtn {
|
||||
background: var(--action);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.confirmBtn:hover {
|
||||
background: var(--action-hover);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createFormexMaterial, updateFormexMaterial } from '../api/client';
|
||||
import styles from './MaterialModal.module.css';
|
||||
|
||||
export default function MaterialModal({ material, collections, onClose, onSave }) {
|
||||
const isEditing = !!material;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
formex_code: '',
|
||||
name: '',
|
||||
collection: '',
|
||||
width_mm: '',
|
||||
height_mm: '',
|
||||
thickness_mm: '',
|
||||
formex_url: ''
|
||||
});
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (material) {
|
||||
setFormData({
|
||||
formex_code: material.formex_code || '',
|
||||
name: material.name || '',
|
||||
collection: material.collection || '',
|
||||
width_mm: material.width_mm?.toString() || '',
|
||||
height_mm: material.height_mm?.toString() || '',
|
||||
thickness_mm: material.thickness_mm?.toString() || '',
|
||||
formex_url: material.formex_url || ''
|
||||
});
|
||||
}
|
||||
}, [material]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validation
|
||||
if (!formData.formex_code.trim() || !formData.name.trim() || !formData.collection.trim()) {
|
||||
setError('Le code, le nom et la collection sont requis.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
formex_code: formData.formex_code.trim(),
|
||||
name: formData.name.trim(),
|
||||
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,
|
||||
formex_url: formData.formex_url.trim() || null
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
await updateFormexMaterial(material.id, payload);
|
||||
} else {
|
||||
await createFormexMaterial(payload);
|
||||
}
|
||||
|
||||
onSave();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOverlayClick = (e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onClick={handleOverlayClick}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.header}>
|
||||
<h2>{isEditing ? 'Modifier le matériau' : 'Nouveau matériau'}</h2>
|
||||
<button type="button" onClick={onClose} className={styles.closeBtn}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="formex_code">Code Formex *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="formex_code"
|
||||
name="formex_code"
|
||||
value={formData.formex_code}
|
||||
onChange={handleChange}
|
||||
placeholder="ex: 10000"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="name">Nom *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="ex: Panneaux mélaminés Duplex Plus 10000 Tilia"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="collection">Collection *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="collection"
|
||||
name="collection"
|
||||
value={formData.collection}
|
||||
onChange={handleChange}
|
||||
list="collections-list"
|
||||
placeholder="ex: Duplex Plus"
|
||||
required
|
||||
/>
|
||||
<datalist id="collections-list">
|
||||
{collections.map((c) => (
|
||||
<option key={c} value={c} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className={styles.row}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="width_mm">Largeur (mm)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="width_mm"
|
||||
name="width_mm"
|
||||
value={formData.width_mm}
|
||||
onChange={handleChange}
|
||||
placeholder="2800"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="height_mm">Hauteur (mm)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="height_mm"
|
||||
name="height_mm"
|
||||
value={formData.height_mm}
|
||||
onChange={handleChange}
|
||||
placeholder="2070"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="thickness_mm">Épaisseur (mm)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="thickness_mm"
|
||||
name="thickness_mm"
|
||||
value={formData.thickness_mm}
|
||||
onChange={handleChange}
|
||||
placeholder="19"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="formex_url">URL Formex</label>
|
||||
<input
|
||||
type="url"
|
||||
id="formex_url"
|
||||
name="formex_url"
|
||||
value={formData.formex_url}
|
||||
onChange={handleChange}
|
||||
placeholder="https://www.formex.ch/fr/shop/..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button type="button" onClick={onClose} className={styles.cancelBtn}>
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className={styles.saveBtn}>
|
||||
{saving ? 'Enregistrement…' : (isEditing ? 'Enregistrer' : 'Créer')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #fef2f2;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.375rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cancelBtn:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.saveBtn:hover:not(:disabled) {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.saveBtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Plans2DView - Display 2D plans (assembly, side, top views)
|
||||
* With tabs for different views and export options
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import CuttingListTable from './CuttingListTable';
|
||||
import styles from './Plans2DView.module.css';
|
||||
|
||||
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, setPlans] = useState(null);
|
||||
const [cuttingList, setCuttingList] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Fetch plans and cutting list
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Fetch plans
|
||||
const plansRes = await fetch(`${API_BASE}/api/plans/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config, modules, colors })
|
||||
});
|
||||
const plansData = await plansRes.json();
|
||||
|
||||
if (plansData.success) {
|
||||
setPlans(plansData.plans);
|
||||
}
|
||||
|
||||
// Fetch cutting list
|
||||
const cutRes = await fetch(`${API_BASE}/api/plans/cutting-list`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config, modules, colors })
|
||||
});
|
||||
const cutData = await cutRes.json();
|
||||
|
||||
if (cutData.success) {
|
||||
setCuttingList(cutData.cuttingList);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error fetching plans:', err);
|
||||
setError('Erreur lors de la génération des plans');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [config, modules, colors]);
|
||||
|
||||
// Export handlers
|
||||
const handleExport = useCallback(async (format) => {
|
||||
try {
|
||||
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 })
|
||||
});
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'liste-debitage.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
} 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 })
|
||||
});
|
||||
const html = await res.text();
|
||||
|
||||
// Open in new window for printing
|
||||
const printWindow = window.open('', '_blank');
|
||||
printWindow.document.write(html);
|
||||
printWindow.document.close();
|
||||
printWindow.onload = () => {
|
||||
printWindow.print();
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Export error:', err);
|
||||
alert('Erreur lors de l\'export');
|
||||
}
|
||||
}, [config, modules, colors]);
|
||||
|
||||
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' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>Plans de fabrication</h2>
|
||||
<button className={styles.closeBtn} onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.tabs}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`${styles.tab} ${activeTab === tab.id ? styles.active : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{loading ? (
|
||||
<div className={styles.loading}>Génération des plans...</div>
|
||||
) : error ? (
|
||||
<div className={styles.error}>{error}</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'assembly' && plans?.assembly && (
|
||||
<div
|
||||
className={styles.planContainer}
|
||||
dangerouslySetInnerHTML={{ __html: plans.assembly }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'side' && plans?.side && (
|
||||
<div
|
||||
className={styles.planContainer}
|
||||
dangerouslySetInnerHTML={{ __html: plans.side }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'top' && plans?.top && (
|
||||
<div
|
||||
className={styles.planContainer}
|
||||
dangerouslySetInnerHTML={{ __html: plans.top }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'cutting' && (
|
||||
<CuttingListTable
|
||||
cuttingList={cuttingList}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.specs}>
|
||||
<span>{config.widthMm} × {config.heightMm} × {config.depthMm} mm</span>
|
||||
<span>•</span>
|
||||
<span>{config.installationType === 'niche' ? 'Encastrée' : 'Libre'}</span>
|
||||
<span>•</span>
|
||||
<span>{config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</span>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.exportBtn}
|
||||
onClick={() => handleExport('pdf')}
|
||||
>
|
||||
Imprimer / PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
padding: 0 20px;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--action);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--action);
|
||||
}
|
||||
|
||||
.tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--action);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.planContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: var(--bg-subtle);
|
||||
border-radius: 8px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.planContainer svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.specs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.exportBtn {
|
||||
padding: 10px 20px;
|
||||
background: var(--action);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.exportBtn:hover {
|
||||
background: var(--action-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.modal {
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
overflow-x: auto;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 14px;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.specs {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function ProtectedRoute({ children }) {
|
||||
const { isAdmin } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to={`/login?next=${encodeURIComponent(location.pathname)}`} replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* Room3D component - Renders a 3D room based on AI analysis
|
||||
* Used to display the wardrobe in context of the user's actual room
|
||||
*/
|
||||
|
||||
// Floor material based on type
|
||||
function FloorMaterial({ material, color }) {
|
||||
const texture = useMemo(() => {
|
||||
// In a real implementation, you'd load actual textures
|
||||
// For now, we use colors with some roughness variation
|
||||
return null;
|
||||
}, [material]);
|
||||
|
||||
return (
|
||||
<meshStandardMaterial
|
||||
color={color || '#c4a574'}
|
||||
roughness={material === 'wood' ? 0.7 : material === 'tile' ? 0.3 : 0.8}
|
||||
metalness={0.05}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Get material properties from wall material type
|
||||
function getWallMaterialProps(material, texture) {
|
||||
const props = { roughness: 0.8, metalness: 0.02 };
|
||||
switch (material) {
|
||||
case 'paint':
|
||||
props.roughness = texture === 'glossy' ? 0.3 : texture === 'smooth' ? 0.7 : 0.8;
|
||||
break;
|
||||
case 'wallpaper':
|
||||
props.roughness = 0.85;
|
||||
break;
|
||||
case 'brick':
|
||||
props.roughness = 0.95;
|
||||
props.metalness = 0.0;
|
||||
break;
|
||||
case 'wood_panel':
|
||||
props.roughness = 0.65;
|
||||
props.metalness = 0.05;
|
||||
break;
|
||||
case 'concrete':
|
||||
props.roughness = 0.9;
|
||||
props.metalness = 0.01;
|
||||
break;
|
||||
case 'plaster':
|
||||
props.roughness = 0.85;
|
||||
break;
|
||||
case 'stone':
|
||||
props.roughness = 0.92;
|
||||
props.metalness = 0.0;
|
||||
break;
|
||||
case 'tile':
|
||||
props.roughness = 0.3;
|
||||
props.metalness = 0.05;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
// Wall with optional window/door cutouts
|
||||
function Wall({ position, rotation, size, color, material, texture, windows = [], doors = [] }) {
|
||||
const [width, height] = size;
|
||||
const matProps = getWallMaterialProps(material, texture);
|
||||
|
||||
// Create wall geometry with cutouts (simplified - just overlays for now)
|
||||
return (
|
||||
<group position={position} rotation={rotation}>
|
||||
{/* Main wall */}
|
||||
<mesh receiveShadow>
|
||||
<planeGeometry args={[width, height]} />
|
||||
<meshStandardMaterial
|
||||
color={color || '#f5f5f0'}
|
||||
side={THREE.DoubleSide}
|
||||
roughness={matProps.roughness}
|
||||
metalness={matProps.metalness}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Windows */}
|
||||
{windows.map((win, i) => {
|
||||
const winWidth = width * win.width;
|
||||
const winHeight = height * win.height;
|
||||
const winX = (win.x - 0.5) * width;
|
||||
const winY = (win.y - 0.5) * height;
|
||||
|
||||
return (
|
||||
<group key={`window-${i}`} position={[winX, winY, 0.01]}>
|
||||
{/* Window glass */}
|
||||
<mesh>
|
||||
<planeGeometry args={[winWidth, winHeight]} />
|
||||
<meshStandardMaterial
|
||||
color="#87CEEB"
|
||||
transparent
|
||||
opacity={0.3}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Window frame */}
|
||||
<lineSegments>
|
||||
<edgesGeometry args={[new THREE.PlaneGeometry(winWidth, winHeight)]} />
|
||||
<lineBasicMaterial color="#555" linewidth={2} />
|
||||
</lineSegments>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Doors */}
|
||||
{doors.map((door, i) => {
|
||||
const doorWidth = width * door.width;
|
||||
const doorHeight = height * door.height;
|
||||
const doorX = (door.x - 0.5) * width;
|
||||
const doorY = -(height / 2) + (doorHeight / 2);
|
||||
|
||||
return (
|
||||
<group key={`door-${i}`} position={[doorX, doorY, 0.01]}>
|
||||
{/* Door opening (darker rectangle) */}
|
||||
<mesh>
|
||||
<planeGeometry args={[doorWidth, doorHeight]} />
|
||||
<meshStandardMaterial color="#2a2a2a" side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
{/* Door frame */}
|
||||
<lineSegments>
|
||||
<edgesGeometry args={[new THREE.PlaneGeometry(doorWidth, doorHeight)]} />
|
||||
<lineBasicMaterial color="#8b7355" linewidth={2} />
|
||||
</lineSegments>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Room3D({ roomConfig, wardrobeConfig }) {
|
||||
if (!roomConfig) return null;
|
||||
|
||||
const { width, length, height, walls, windows, doors, floor, ceiling, wardrobePlacement } = roomConfig;
|
||||
|
||||
// Calculate wall positions
|
||||
const halfWidth = width / 2;
|
||||
const halfLength = length / 2;
|
||||
const halfHeight = height / 2;
|
||||
|
||||
// Get windows/doors for each wall
|
||||
const backWindows = windows.filter(w => w.wall === 'back').map(w => ({
|
||||
x: w.x, y: w.y, width: w.width, height: w.height
|
||||
}));
|
||||
const leftWindows = windows.filter(w => w.wall === 'left').map(w => ({
|
||||
x: w.x, y: w.y, width: w.width, height: w.height
|
||||
}));
|
||||
const rightWindows = windows.filter(w => w.wall === 'right').map(w => ({
|
||||
x: w.x, y: w.y, width: w.width, height: w.height
|
||||
}));
|
||||
|
||||
const backDoors = doors.filter(d => d.wall === 'back').map(d => ({
|
||||
x: d.x, width: d.width, height: d.height
|
||||
}));
|
||||
const leftDoors = doors.filter(d => d.wall === 'left').map(d => ({
|
||||
x: d.x, width: d.width, height: d.height
|
||||
}));
|
||||
const rightDoors = doors.filter(d => d.wall === 'right').map(d => ({
|
||||
x: d.x, width: d.width, height: d.height
|
||||
}));
|
||||
|
||||
// Calculate wardrobe position based on placement
|
||||
const getWardrobePosition = () => {
|
||||
if (!wardrobeConfig || !wardrobePlacement) return [0, 0, 0];
|
||||
|
||||
const wardrobeWidth = (wardrobeConfig.widthMm || 2400) / 1000;
|
||||
const wardrobeDepth = (wardrobeConfig.depthMm || 600) / 1000;
|
||||
|
||||
switch (wardrobePlacement.wall) {
|
||||
case 'back':
|
||||
return [
|
||||
((wardrobePlacement.xPercent / 100) - 0.5) * width + wardrobeWidth / 2,
|
||||
0,
|
||||
-halfLength + wardrobeDepth / 2 + 0.05
|
||||
];
|
||||
case 'left':
|
||||
return [
|
||||
-halfWidth + wardrobeDepth / 2 + 0.05,
|
||||
0,
|
||||
((wardrobePlacement.xPercent / 100) - 0.5) * length + wardrobeWidth / 2
|
||||
];
|
||||
case 'right':
|
||||
return [
|
||||
halfWidth - wardrobeDepth / 2 - 0.05,
|
||||
0,
|
||||
((wardrobePlacement.xPercent / 100) - 0.5) * length + wardrobeWidth / 2
|
||||
];
|
||||
default:
|
||||
return [0, 0, -halfLength + wardrobeDepth / 2 + 0.05];
|
||||
}
|
||||
};
|
||||
|
||||
const wardrobePosition = getWardrobePosition();
|
||||
const wardrobeRotation = wardrobePlacement?.wall === 'left'
|
||||
? [0, Math.PI / 2, 0]
|
||||
: wardrobePlacement?.wall === 'right'
|
||||
? [0, -Math.PI / 2, 0]
|
||||
: [0, 0, 0];
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Floor */}
|
||||
<mesh
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
position={[0, 0, 0]}
|
||||
receiveShadow
|
||||
>
|
||||
<planeGeometry args={[width, length]} />
|
||||
<FloorMaterial material={floor?.material} color={floor?.color} />
|
||||
</mesh>
|
||||
|
||||
{/* Back wall */}
|
||||
{walls?.back?.visible !== false && (
|
||||
<Wall
|
||||
position={[0, halfHeight, -halfLength]}
|
||||
rotation={[0, 0, 0]}
|
||||
size={[width, height]}
|
||||
color={walls?.back?.color}
|
||||
material={walls?.back?.material}
|
||||
texture={walls?.back?.texture}
|
||||
windows={backWindows}
|
||||
doors={backDoors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Left wall */}
|
||||
{walls?.left?.visible !== false && (
|
||||
<Wall
|
||||
position={[-halfWidth, halfHeight, 0]}
|
||||
rotation={[0, Math.PI / 2, 0]}
|
||||
size={[length, height]}
|
||||
color={walls?.left?.color}
|
||||
material={walls?.left?.material}
|
||||
texture={walls?.left?.texture}
|
||||
windows={leftWindows}
|
||||
doors={leftDoors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Right wall */}
|
||||
{walls?.right?.visible !== false && (
|
||||
<Wall
|
||||
position={[halfWidth, halfHeight, 0]}
|
||||
rotation={[0, -Math.PI / 2, 0]}
|
||||
size={[length, height]}
|
||||
color={walls?.right?.color}
|
||||
material={walls?.right?.material}
|
||||
texture={walls?.right?.texture}
|
||||
windows={rightWindows}
|
||||
doors={rightDoors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Ceiling (plafond) */}
|
||||
{ceiling?.visible !== false && (() => {
|
||||
const ceilingColor = ceiling?.color || '#ffffff';
|
||||
const ceilingType = ceiling?.type || 'flat';
|
||||
const slopeAngle = ceiling?.slopeAngleDeg || 0;
|
||||
const ceilingMatProps = getWallMaterialProps(ceiling?.material || 'paint', 'smooth');
|
||||
|
||||
if (ceilingType === 'sloped' && slopeAngle > 0) {
|
||||
// Sloped ceiling - render as a tilted plane
|
||||
const slopeRad = (slopeAngle * Math.PI) / 180;
|
||||
const slopeDir = ceiling?.slopeDirection || 'front_to_back';
|
||||
const slopeRotX = slopeDir === 'front_to_back' ? Math.PI / 2 + slopeRad * 0.5 : Math.PI / 2;
|
||||
const slopeRotZ = slopeDir === 'left_to_right' ? slopeRad * 0.5 : 0;
|
||||
const slopeW = slopeDir === 'left_to_right' ? width / Math.cos(slopeRad * 0.5) : width;
|
||||
const slopeL = slopeDir === 'front_to_back' ? length / Math.cos(slopeRad * 0.5) : length;
|
||||
return (
|
||||
<mesh
|
||||
rotation={[slopeRotX, 0, slopeRotZ]}
|
||||
position={[0, height, 0]}
|
||||
receiveShadow
|
||||
>
|
||||
<planeGeometry args={[slopeW, slopeL]} />
|
||||
<meshStandardMaterial
|
||||
color={ceilingColor}
|
||||
side={THREE.DoubleSide}
|
||||
roughness={ceilingMatProps.roughness}
|
||||
metalness={ceilingMatProps.metalness}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
// Flat ceiling
|
||||
return (
|
||||
<mesh
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
position={[0, height, 0]}
|
||||
receiveShadow
|
||||
>
|
||||
<planeGeometry args={[width, length]} />
|
||||
<meshStandardMaterial
|
||||
color={ceilingColor}
|
||||
side={THREE.DoubleSide}
|
||||
roughness={ceilingMatProps.roughness}
|
||||
metalness={ceilingMatProps.metalness}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Wardrobe position indicator (for debugging/visualization) */}
|
||||
{wardrobePlacement && (
|
||||
<mesh position={[wardrobePosition[0], 0.01, wardrobePosition[2]]}>
|
||||
<planeGeometry args={[
|
||||
(wardrobeConfig?.widthMm || 2400) / 1000,
|
||||
(wardrobeConfig?.depthMm || 600) / 1000
|
||||
]} />
|
||||
<meshBasicMaterial color="#0066cc" transparent opacity={0.3} />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// Export position calculator for use by parent
|
||||
export function calculateWardrobeTransform(roomConfig, wardrobeConfig) {
|
||||
if (!roomConfig || !roomConfig.wardrobePlacement) {
|
||||
return { position: [0, 0, 0], rotation: [0, 0, 0] };
|
||||
}
|
||||
|
||||
const { width, length, wardrobePlacement } = roomConfig;
|
||||
const halfWidth = width / 2;
|
||||
const halfLength = length / 2;
|
||||
const wardrobeWidth = (wardrobeConfig?.widthMm || 2400) / 1000;
|
||||
const wardrobeDepth = (wardrobeConfig?.depthMm || 600) / 1000;
|
||||
|
||||
let position, rotation;
|
||||
|
||||
switch (wardrobePlacement.wall) {
|
||||
case 'back':
|
||||
position = [
|
||||
((wardrobePlacement.xPercent / 100) - 0.5) * width + wardrobeWidth / 4,
|
||||
0,
|
||||
-halfLength + wardrobeDepth / 2 + 0.05
|
||||
];
|
||||
rotation = [0, 0, 0];
|
||||
break;
|
||||
case 'left':
|
||||
position = [
|
||||
-halfWidth + wardrobeDepth / 2 + 0.05,
|
||||
0,
|
||||
((wardrobePlacement.xPercent / 100) - 0.5) * length
|
||||
];
|
||||
rotation = [0, Math.PI / 2, 0];
|
||||
break;
|
||||
case 'right':
|
||||
position = [
|
||||
halfWidth - wardrobeDepth / 2 - 0.05,
|
||||
0,
|
||||
((wardrobePlacement.xPercent / 100) - 0.5) * length
|
||||
];
|
||||
rotation = [0, -Math.PI / 2, 0];
|
||||
break;
|
||||
default:
|
||||
position = [0, 0, -halfLength + wardrobeDepth / 2 + 0.05];
|
||||
rotation = [0, 0, 0];
|
||||
}
|
||||
|
||||
return { position, rotation };
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import styles from './RoomUpload.module.css';
|
||||
|
||||
/**
|
||||
* RoomUpload component - Drag & drop or click to upload room photos
|
||||
* Sends to AI for analysis and returns room configuration
|
||||
*/
|
||||
export default function RoomUpload({ onAnalysisComplete, onClose }) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [preview, setPreview] = useState(null);
|
||||
const [file, setFile] = useState(null);
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [analysis, setAnalysis] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const handleDragOver = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const droppedFile = e.dataTransfer.files[0];
|
||||
if (droppedFile && droppedFile.type.startsWith('image/')) {
|
||||
handleFile(droppedFile);
|
||||
} else {
|
||||
setError('Veuillez déposer une image (JPEG, PNG ou WebP)');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFile = (selectedFile) => {
|
||||
setFile(selectedFile);
|
||||
setError(null);
|
||||
setAnalysis(null);
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setPreview(e.target.result);
|
||||
};
|
||||
reader.readAsDataURL(selectedFile);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const selectedFile = e.target.files[0];
|
||||
if (selectedFile) {
|
||||
handleFile(selectedFile);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!file) return;
|
||||
|
||||
setAnalyzing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const response = await fetch('/api/room/analyze', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Erreur lors de l\'analyse');
|
||||
}
|
||||
|
||||
setAnalysis(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (analysis && onAnalysisComplete) {
|
||||
onAnalysisComplete(analysis);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setAnalysis(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.modal}>
|
||||
<button className={styles.closeBtn} onClick={onClose} type="button">
|
||||
×
|
||||
</button>
|
||||
|
||||
<h2 className={styles.title}>Visualiser dans ma pièce</h2>
|
||||
<p className={styles.subtitle}>
|
||||
Téléchargez une photo de votre pièce et notre IA analysera l'espace pour y placer votre armoire.
|
||||
</p>
|
||||
|
||||
{!preview ? (
|
||||
<div
|
||||
className={`${styles.dropzone} ${dragOver ? styles.dragOver : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<div className={styles.dropIcon}>📷</div>
|
||||
<p className={styles.dropText}>
|
||||
Glissez-déposez une photo ici<br />
|
||||
<span>ou cliquez pour sélectionner</span>
|
||||
</p>
|
||||
<p className={styles.dropHint}>JPEG, PNG ou WebP - Max 10 MB</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleFileSelect}
|
||||
className={styles.fileInput}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.previewContainer}>
|
||||
<img src={preview} alt="Aperçu de la pièce" className={styles.previewImage} />
|
||||
|
||||
{!analysis && !analyzing && (
|
||||
<button type="button" className={styles.changeBtn} onClick={handleReset}>
|
||||
Changer d'image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className={styles.error}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analyzing && (
|
||||
<div className={styles.analyzing}>
|
||||
<div className={styles.spinner}></div>
|
||||
<p>Analyse de votre pièce en cours...</p>
|
||||
<p className={styles.analyzingHint}>L'IA détecte les dimensions, fenêtres et portes</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<div className={styles.analysisResult}>
|
||||
<h3>Analyse terminée</h3>
|
||||
|
||||
<div className={styles.resultGrid}>
|
||||
<div className={styles.resultItem}>
|
||||
<span className={styles.resultLabel}>Dimensions estimées</span>
|
||||
<span className={styles.resultValue}>
|
||||
{analysis.analysis.roomDimensions.widthM}m × {analysis.analysis.roomDimensions.lengthM}m × {analysis.analysis.roomDimensions.heightM}m
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.resultItem}>
|
||||
<span className={styles.resultLabel}>Type de pièce</span>
|
||||
<span className={styles.resultValue}>
|
||||
{analysis.analysis.roomType === 'bedroom' ? 'Chambre' :
|
||||
analysis.analysis.roomType === 'living_room' ? 'Salon' :
|
||||
analysis.analysis.roomType === 'hallway' ? 'Couloir' :
|
||||
analysis.analysis.roomType === 'dressing' ? 'Dressing' : 'Autre'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.resultItem}>
|
||||
<span className={styles.resultLabel}>Fenêtres détectées</span>
|
||||
<span className={styles.resultValue}>{analysis.analysis.windows?.length || 0}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.resultItem}>
|
||||
<span className={styles.resultLabel}>Portes détectées</span>
|
||||
<span className={styles.resultValue}>{analysis.analysis.doors?.length || 0}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.resultItem}>
|
||||
<span className={styles.resultLabel}>Mur suggéré pour l'armoire</span>
|
||||
<span className={styles.resultValue}>
|
||||
{analysis.analysis.suggestedWardrobeWall === 'back' ? 'Fond' :
|
||||
analysis.analysis.suggestedWardrobeWall === 'left' ? 'Gauche' : 'Droite'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.resultItem}>
|
||||
<span className={styles.resultLabel}>Confiance</span>
|
||||
<span className={styles.resultValue}>{analysis.analysis.confidence}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysis.analysis.suggestedWardrobePosition?.reason && (
|
||||
<p className={styles.suggestion}>
|
||||
<strong>Suggestion :</strong> {analysis.analysis.suggestedWardrobePosition.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.cancelBtn} onClick={onClose}>
|
||||
Annuler
|
||||
</button>
|
||||
|
||||
{preview && !analysis && !analyzing && (
|
||||
<button type="button" className={styles.analyzeBtn} onClick={handleAnalyze}>
|
||||
Analyser la pièce
|
||||
</button>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<button type="button" className={styles.confirmBtn} onClick={handleConfirm}>
|
||||
Utiliser cette configuration
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0 0 1.5rem;
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Dropzone */
|
||||
.dropzone {
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 12px;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.dropzone:hover,
|
||||
.dropzone.dragOver {
|
||||
border-color: #0066cc;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.dropIcon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dropText {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dropText span {
|
||||
color: #0066cc;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dropHint {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.fileInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Preview */
|
||||
.previewContainer {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.previewImage {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
object-fit: contain;
|
||||
background: #f0f0f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.changeBtn {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.changeBtn:hover {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.error {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 8px;
|
||||
color: #b91c1c;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Analyzing */
|
||||
.analyzing {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #e0e0e0;
|
||||
border-top-color: #0066cc;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 1rem;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.analyzing p {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.analyzingHint {
|
||||
margin-top: 0.5rem !important;
|
||||
font-size: 0.85rem;
|
||||
color: #666 !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
/* Analysis Result */
|
||||
.analysisResult {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1.25rem;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.analysisResult h3 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.resultGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.resultItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.resultLabel {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.resultValue {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
margin: 1rem 0 0;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #86efac;
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.cancelBtn,
|
||||
.analyzeBtn,
|
||||
.confirmBtn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.cancelBtn:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.analyzeBtn {
|
||||
background: #0066cc;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.analyzeBtn:hover {
|
||||
background: #0052a3;
|
||||
}
|
||||
|
||||
.confirmBtn {
|
||||
background: #16a34a;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.confirmBtn:hover {
|
||||
background: #15803d;
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* WardrobeElement - Clickable 3D element for the wardrobe preview
|
||||
* Each element can have its own color/finish and responds to click events
|
||||
* Uses dominant color extracted from material swatch images for realistic preview
|
||||
*/
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import { useThree } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
|
||||
// Default colors for different element types
|
||||
const DEFAULT_COLORS = {
|
||||
plintheBas: '#8b7355',
|
||||
fileur: '#8b7355',
|
||||
bandeauHaut: '#8b7355',
|
||||
champs: '#6b5344',
|
||||
caisse: '#c4a574',
|
||||
cotesDoublage: '#c4a574',
|
||||
cotes: '#b09060',
|
||||
dessus: '#c4a574',
|
||||
fond: '#d4c4a4',
|
||||
dos: '#d4c4a4',
|
||||
portes: '#a08060',
|
||||
panneauxInterieurs: '#d4c4a4',
|
||||
separateurs: '#6b5344',
|
||||
etageres: '#d4c4a4',
|
||||
tiroirs: '#c4a574',
|
||||
penderie: '#8b7355'
|
||||
};
|
||||
|
||||
// Element labels for display
|
||||
const ELEMENT_LABELS = {
|
||||
plintheBas: 'Plinthe bas',
|
||||
fileur: 'Fileur',
|
||||
bandeauHaut: 'Bandeau haut',
|
||||
champs: 'Champs',
|
||||
caisse: 'Caisse',
|
||||
cotesDoublage: 'Côtés de doublage',
|
||||
cotes: 'Côtés',
|
||||
dessus: 'Dessus',
|
||||
fond: 'Fond',
|
||||
dos: 'Dos',
|
||||
portes: 'Portes',
|
||||
panneauxInterieurs: 'Panneaux intérieurs',
|
||||
separateurs: 'Séparateurs',
|
||||
etageres: 'Étagères',
|
||||
tiroirs: 'Tiroirs',
|
||||
penderie: 'Tringle penderie'
|
||||
};
|
||||
|
||||
/**
|
||||
* Get color from config or use default
|
||||
*/
|
||||
function getElementColor(elementType, colors) {
|
||||
if (colors && colors[elementType]) {
|
||||
if (colors[elementType].hexColor) {
|
||||
return colors[elementType].hexColor;
|
||||
}
|
||||
}
|
||||
return DEFAULT_COLORS[elementType] || '#cccccc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image URL for element texture (if available)
|
||||
*/
|
||||
function getElementImageUrl(elementType, colors) {
|
||||
if (colors && colors[elementType] && colors[elementType].imageUrl) {
|
||||
return colors[elementType].imageUrl;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global cache: image URL → extracted dominant color (hex string)
|
||||
* Avoids re-extracting colors for the same image URL
|
||||
*/
|
||||
const colorCache = new Map();
|
||||
|
||||
/**
|
||||
* Extract the dominant color from the center of a swatch image.
|
||||
* The Formex swatch images have black borders and watermarks,
|
||||
* so we sample only the center 40% to get the actual material color.
|
||||
* Returns a promise that resolves to a hex color string.
|
||||
*/
|
||||
function extractDominantColor(imageUrl) {
|
||||
if (colorCache.has(imageUrl)) {
|
||||
return colorCache.get(imageUrl);
|
||||
}
|
||||
|
||||
const promise = new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
// Sample center 40% of the image (avoids border + watermark)
|
||||
const cropFraction = 0.4;
|
||||
const sx = Math.floor(img.width * (1 - cropFraction) / 2);
|
||||
const sy = Math.floor(img.height * (1 - cropFraction) / 2);
|
||||
const sw = Math.floor(img.width * cropFraction);
|
||||
const sh = Math.floor(img.height * cropFraction);
|
||||
|
||||
// Draw cropped region to small canvas for fast sampling
|
||||
canvas.width = 16;
|
||||
canvas.height = 16;
|
||||
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, 16, 16);
|
||||
|
||||
const data = ctx.getImageData(0, 0, 16, 16).data;
|
||||
let r = 0, g = 0, b = 0, count = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
// Skip very dark pixels (might be border remnants)
|
||||
if (data[i] < 15 && data[i+1] < 15 && data[i+2] < 15) continue;
|
||||
r += data[i];
|
||||
g += data[i + 1];
|
||||
b += data[i + 2];
|
||||
count++;
|
||||
}
|
||||
if (count === 0) count = 1;
|
||||
r = Math.round(r / count);
|
||||
g = Math.round(g / count);
|
||||
b = Math.round(b / count);
|
||||
const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
resolve(hex);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = imageUrl;
|
||||
});
|
||||
|
||||
colorCache.set(imageUrl, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to extract the dominant color from a material swatch image URL.
|
||||
* Returns a hex color string or null while loading.
|
||||
*/
|
||||
function useExtractedColor(imageUrl) {
|
||||
const [extractedColor, setExtractedColor] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageUrl) {
|
||||
setExtractedColor(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
extractDominantColor(imageUrl).then((hex) => {
|
||||
if (!cancelled && hex) {
|
||||
setExtractedColor(hex);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [imageUrl]);
|
||||
|
||||
return extractedColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global texture cache — stores cropped CanvasTextures (borders removed)
|
||||
*/
|
||||
const textureCache = new Map();
|
||||
|
||||
/**
|
||||
* Load a material swatch image, crop out the borders/watermark,
|
||||
* and create a Three.js CanvasTexture from the center region.
|
||||
* Returns a cache entry { texture, error, loading }.
|
||||
*/
|
||||
function loadCroppedTexture(url) {
|
||||
if (textureCache.has(url)) {
|
||||
return textureCache.get(url);
|
||||
}
|
||||
|
||||
const entry = { texture: null, error: false, loading: true };
|
||||
textureCache.set(url, entry);
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
// Crop center 60% to remove black border + FORMEX watermark
|
||||
const cropFraction = 0.6;
|
||||
const sx = Math.floor(img.width * (1 - cropFraction) / 2);
|
||||
const sy = Math.floor(img.height * (1 - cropFraction) / 2);
|
||||
const sw = Math.floor(img.width * cropFraction);
|
||||
const sh = Math.floor(img.height * cropFraction);
|
||||
|
||||
canvas.width = sw;
|
||||
canvas.height = sh;
|
||||
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
tex.wrapS = THREE.RepeatWrapping;
|
||||
tex.wrapT = THREE.RepeatWrapping;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
tex.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
tex.magFilter = THREE.LinearFilter;
|
||||
tex.needsUpdate = true;
|
||||
|
||||
entry.texture = tex;
|
||||
entry.loading = false;
|
||||
} catch {
|
||||
entry.error = true;
|
||||
entry.loading = false;
|
||||
}
|
||||
};
|
||||
img.onerror = () => {
|
||||
entry.error = true;
|
||||
entry.loading = false;
|
||||
};
|
||||
img.src = url;
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to load a cropped texture from a swatch image URL.
|
||||
* Returns a cloned texture with proper repeat settings, or null.
|
||||
*/
|
||||
function useCroppedTexture(imageUrl, geometryArgs, geometryType) {
|
||||
const [texture, setTexture] = useState(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => { mountedRef.current = false; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageUrl) {
|
||||
setTexture((prev) => { if (prev) prev.dispose(); return null; });
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = loadCroppedTexture(imageUrl);
|
||||
|
||||
if (entry.texture) {
|
||||
const tex = entry.texture.clone();
|
||||
configureTextureRepeat(tex, geometryArgs, geometryType);
|
||||
setTexture((prev) => { if (prev) prev.dispose(); return tex; });
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.error) {
|
||||
setTexture((prev) => { if (prev) prev.dispose(); return null; });
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll until loaded
|
||||
const interval = setInterval(() => {
|
||||
if (!mountedRef.current) { clearInterval(interval); return; }
|
||||
if (entry.texture) {
|
||||
const tex = entry.texture.clone();
|
||||
configureTextureRepeat(tex, geometryArgs, geometryType);
|
||||
setTexture((prev) => { if (prev) prev.dispose(); return tex; });
|
||||
clearInterval(interval);
|
||||
} else if (entry.error) {
|
||||
setTexture((prev) => { if (prev) prev.dispose(); return null; });
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [imageUrl, geometryType, geometryArgs]);
|
||||
|
||||
// Update repeat when geometry changes (but same texture)
|
||||
useEffect(() => {
|
||||
if (texture) {
|
||||
configureTextureRepeat(texture, geometryArgs, geometryType);
|
||||
}
|
||||
}, [geometryArgs, geometryType]);
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure texture repeat based on geometry dimensions.
|
||||
* The cropped swatch represents approximately 0.3m x 0.3m of real material.
|
||||
*/
|
||||
function configureTextureRepeat(tex, geometryArgs, geometryType) {
|
||||
if (!tex || !geometryArgs) return;
|
||||
|
||||
const TEXTURE_PATCH_SIZE = 0.3; // meters
|
||||
let widthM, heightM;
|
||||
|
||||
switch (geometryType) {
|
||||
case 'box':
|
||||
widthM = geometryArgs[0] || 1;
|
||||
heightM = geometryArgs[1] || 1;
|
||||
break;
|
||||
case 'plane':
|
||||
widthM = geometryArgs[0] || 1;
|
||||
heightM = geometryArgs[1] || 1;
|
||||
break;
|
||||
case 'cylinder':
|
||||
widthM = 0.3;
|
||||
heightM = 0.3;
|
||||
break;
|
||||
default:
|
||||
widthM = 1;
|
||||
heightM = 1;
|
||||
}
|
||||
|
||||
tex.repeat.set(
|
||||
Math.max(1, widthM / TEXTURE_PATCH_SIZE),
|
||||
Math.max(1, heightM / TEXTURE_PATCH_SIZE)
|
||||
);
|
||||
tex.needsUpdate = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* WardrobeElement - A single clickable 3D mesh with optional texture
|
||||
*/
|
||||
export default function WardrobeElement({
|
||||
elementType,
|
||||
geometryType = 'box',
|
||||
geometryArgs = [1, 1, 1],
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
colors = {},
|
||||
onElementClick,
|
||||
castShadow = true,
|
||||
receiveShadow = true,
|
||||
transparent = false,
|
||||
opacity = 1,
|
||||
materialProps = {}
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { gl } = useThree();
|
||||
const pointerDownPos = useRef(null);
|
||||
const materialRef = useRef();
|
||||
|
||||
const color = useMemo(() =>
|
||||
getElementColor(elementType, colors),
|
||||
[elementType, colors]
|
||||
);
|
||||
|
||||
const imageUrl = useMemo(() =>
|
||||
getElementImageUrl(elementType, colors),
|
||||
[elementType, colors]
|
||||
);
|
||||
|
||||
// Extract dominant color from swatch image (immediate visual feedback)
|
||||
const extractedColor = useExtractedColor(imageUrl);
|
||||
|
||||
// Load cropped texture (for materials with visible grain/pattern)
|
||||
const texture = useCroppedTexture(imageUrl, geometryArgs, geometryType);
|
||||
|
||||
// Determine the display color: extracted > hex > default
|
||||
const baseColor = extractedColor || color;
|
||||
|
||||
// Hover effect - slightly brighter color
|
||||
const hoverColor = useMemo(() => {
|
||||
if (!hovered) return baseColor;
|
||||
const hex = baseColor.replace('#', '');
|
||||
const r = Math.min(255, parseInt(hex.slice(0, 2), 16) + 30);
|
||||
const g = Math.min(255, parseInt(hex.slice(2, 4), 16) + 30);
|
||||
const b = Math.min(255, parseInt(hex.slice(4, 6), 16) + 30);
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
}, [baseColor, hovered]);
|
||||
|
||||
const handlePointerOver = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
setHovered(true);
|
||||
gl.domElement.style.cursor = 'pointer';
|
||||
}, [gl]);
|
||||
|
||||
const handlePointerOut = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
setHovered(false);
|
||||
gl.domElement.style.cursor = 'auto';
|
||||
}, [gl]);
|
||||
|
||||
// Track pointer position on down to distinguish clicks from drags
|
||||
const handlePointerDown = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
pointerDownPos.current = { x: e.clientX, y: e.clientY };
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
if (!pointerDownPos.current) return;
|
||||
const dx = e.clientX - pointerDownPos.current.x;
|
||||
const dy = e.clientY - pointerDownPos.current.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
pointerDownPos.current = null;
|
||||
if (distance > 5) return; // Was a drag, not a click
|
||||
if (onElementClick) {
|
||||
onElementClick(elementType, ELEMENT_LABELS[elementType] || elementType);
|
||||
}
|
||||
}, [elementType, onElementClick]);
|
||||
|
||||
const geometry = useMemo(() => {
|
||||
switch (geometryType) {
|
||||
case 'plane':
|
||||
return <planeGeometry args={geometryArgs} />;
|
||||
case 'cylinder':
|
||||
return <cylinderGeometry args={geometryArgs} />;
|
||||
case 'box':
|
||||
default:
|
||||
return <boxGeometry args={geometryArgs} />;
|
||||
}
|
||||
}, [geometryType, geometryArgs]);
|
||||
|
||||
// Imperatively force material update when the texture map reference changes
|
||||
// (R3F sometimes misses null↔Texture transitions without a shader recompile)
|
||||
useEffect(() => {
|
||||
const mat = materialRef.current;
|
||||
if (!mat) return;
|
||||
mat.map = texture || null;
|
||||
mat.needsUpdate = true;
|
||||
}, [texture]);
|
||||
|
||||
// Determine current color for the material
|
||||
const displayColor = texture ? (hovered ? '#e8e8e8' : '#ffffff') : hoverColor;
|
||||
|
||||
return (
|
||||
<mesh
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerOver={handlePointerOver}
|
||||
onPointerOut={handlePointerOut}
|
||||
onClick={handleClick}
|
||||
castShadow={castShadow}
|
||||
receiveShadow={receiveShadow}
|
||||
>
|
||||
{geometry}
|
||||
<meshStandardMaterial
|
||||
ref={materialRef}
|
||||
key={imageUrl || 'flat'}
|
||||
transparent={transparent}
|
||||
opacity={opacity}
|
||||
roughness={materialProps.roughness ?? 0.6}
|
||||
metalness={materialProps.metalness ?? 0.05}
|
||||
color={displayColor}
|
||||
map={texture || null}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
// Export helper functions and constants
|
||||
export { DEFAULT_COLORS, ELEMENT_LABELS, getElementColor };
|
||||
@@ -0,0 +1,801 @@
|
||||
import { useRef, Suspense, useMemo, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
|
||||
import { Canvas, useThree } from '@react-three/fiber';
|
||||
import { OrbitControls, Html } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import Room3D, { calculateWardrobeTransform } from './Room3D';
|
||||
import WardrobeElement from './WardrobeElement';
|
||||
import styles from './WardrobePreview.module.css';
|
||||
|
||||
const SCALE = 1 / 1000;
|
||||
const PANEL_THICKNESS = 0.019; // 19mm in meters
|
||||
|
||||
/* ===================================================================
|
||||
ResizableSeparator - a draggable handle between two modules
|
||||
=================================================================== */
|
||||
function ResizableSeparator({
|
||||
position,
|
||||
height,
|
||||
depth,
|
||||
separatorIndex,
|
||||
colors,
|
||||
onElementClick,
|
||||
isResizing,
|
||||
onResizePointerDown,
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { gl } = useThree();
|
||||
const meshRef = useRef();
|
||||
|
||||
const handlePointerOver = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
setHovered(true);
|
||||
gl.domElement.style.cursor = 'col-resize';
|
||||
}, [gl]);
|
||||
|
||||
const handlePointerOut = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
if (!isResizing) {
|
||||
setHovered(false);
|
||||
gl.domElement.style.cursor = 'auto';
|
||||
}
|
||||
}, [gl, isResizing]);
|
||||
|
||||
const handlePointerDown = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
if (onResizePointerDown) {
|
||||
onResizePointerDown(separatorIndex, e);
|
||||
}
|
||||
}, [separatorIndex, onResizePointerDown]);
|
||||
|
||||
const baseColor = hovered || isResizing ? '#4f46e5' : '#d4c4a4';
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
{/* Visual panel (thin) */}
|
||||
<WardrobeElement
|
||||
elementType="panneauxInterieurs"
|
||||
geometryType="box"
|
||||
geometryArgs={[PANEL_THICKNESS, height, depth]}
|
||||
position={[0, 0, PANEL_THICKNESS / 2]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
{/* Invisible wider hit area for grabbing */}
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
onPointerOver={handlePointerOver}
|
||||
onPointerOut={handlePointerOut}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
<boxGeometry args={[PANEL_THICKNESS * 6, height, depth]} />
|
||||
<meshBasicMaterial transparent opacity={0} />
|
||||
</mesh>
|
||||
{/* Highlight overlay when hovered/resizing */}
|
||||
{(hovered || isResizing) && (
|
||||
<mesh>
|
||||
<boxGeometry args={[PANEL_THICKNESS * 1.5, height * 0.95, depth * 0.95]} />
|
||||
<meshBasicMaterial color={baseColor} transparent opacity={0.35} />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
WidthLabel - floating HTML label showing module width in mm
|
||||
=================================================================== */
|
||||
function WidthLabel({ position, widthMm }) {
|
||||
return (
|
||||
<Html position={position} center style={{ pointerEvents: 'none' }}>
|
||||
<div style={{
|
||||
background: 'rgba(79, 70, 229, 0.9)',
|
||||
color: '#fff',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
{Math.round(widthMm)}mm
|
||||
</div>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
DrawerBox - renders drawer front faces stacked vertically
|
||||
=================================================================== */
|
||||
function DrawerBox({ moduleCenter, moduleWidth, bodyH, plinthH, drawersCount, colors, onElementClick }) {
|
||||
const drawerHeight = 0.15 * SCALE * 1000; // ~150mm in scene units
|
||||
const gap = 0.008; // gap between drawers
|
||||
const totalDrawerZone = Math.min(bodyH * 0.6, drawersCount * (drawerHeight + gap));
|
||||
const actualDrawerH = (totalDrawerZone - gap * (drawersCount - 1)) / drawersCount;
|
||||
const startY = -bodyH / 2 + PANEL_THICKNESS;
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: drawersCount }).map((_, di) => {
|
||||
const y = startY + actualDrawerH / 2 + di * (actualDrawerH + gap);
|
||||
return (
|
||||
<WardrobeElement
|
||||
key={`drawer-${di}`}
|
||||
elementType="tiroirs"
|
||||
geometryType="box"
|
||||
geometryArgs={[moduleWidth * 0.92, actualDrawerH, PANEL_THICKNESS]}
|
||||
position={[moduleCenter, plinthH / 2 + y, moduleWidth > 0 ? 0.28 : 0]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
materialProps={{ roughness: 0.5, metalness: 0.05 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
WardrobeMesh - Full wardrobe model with drag/resize/drawers
|
||||
=================================================================== */
|
||||
function WardrobeMesh({
|
||||
config,
|
||||
colors = {},
|
||||
onElementClick,
|
||||
onModuleClick,
|
||||
selectedModuleIndex,
|
||||
modules = [],
|
||||
showDoors = true,
|
||||
onResizeModules,
|
||||
}) {
|
||||
const { gl, camera, raycaster } = useThree();
|
||||
const useModules = modules && modules.length > 0;
|
||||
|
||||
// --- Dimensions ---
|
||||
const totalWidth = useModules
|
||||
? modules.reduce((s, m) => s + (m.widthMm || 0), 0)
|
||||
: config.widthMm || 2400;
|
||||
|
||||
const width = totalWidth * SCALE;
|
||||
const height = (config.heightMm || 2400) * SCALE;
|
||||
const depth = (config.depthMm || 600) * SCALE;
|
||||
const plinthH = (config.plinthHeightMm || 100) * SCALE;
|
||||
const bodyH = height - plinthH;
|
||||
const isNiche = config.installationType === 'niche';
|
||||
const isSliding = config.doorType === 'sliding';
|
||||
|
||||
// --- Module positions ---
|
||||
const modulePositions = useMemo(() => {
|
||||
const items = useModules ? modules : [];
|
||||
const result = [];
|
||||
let offset = 0;
|
||||
items.forEach((item) => {
|
||||
const w = (item.widthMm || 0) * SCALE;
|
||||
result.push({ x0: offset, width: w, center: -width / 2 + offset + w / 2 });
|
||||
offset += w;
|
||||
});
|
||||
return result;
|
||||
}, [useModules, modules, width]);
|
||||
|
||||
// --- Separator positions (between modules) ---
|
||||
const separatorPositions = useMemo(() => {
|
||||
const items = useModules ? modules : [];
|
||||
return items.slice(0, -1).map((item, i) => {
|
||||
const x = modulePositions[i].x0 + modulePositions[i].width;
|
||||
return { x: -width / 2 + x, index: i };
|
||||
});
|
||||
}, [modulePositions, width, useModules, modules]);
|
||||
|
||||
// --- Hover state for width labels ---
|
||||
const [hoveredModule, setHoveredModule] = useState(null);
|
||||
const [resizeState, setResizeState] = useState(null); // { separatorIndex, startX }
|
||||
const planeRef = useRef(new THREE.Plane(new THREE.Vector3(0, 1, 0), 0));
|
||||
const intersectPoint = useRef(new THREE.Vector3());
|
||||
|
||||
// --- Helpers to project pointer to world X ---
|
||||
const getWorldX = useCallback((e) => {
|
||||
const rect = gl.domElement.getBoundingClientRect();
|
||||
const mouse = new THREE.Vector2(
|
||||
((e.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((e.clientY - rect.top) / rect.height) * 2 + 1
|
||||
);
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
planeRef.current.set(new THREE.Vector3(0, 1, 0), -height / 2);
|
||||
if (raycaster.ray.intersectPlane(planeRef.current, intersectPoint.current)) {
|
||||
return intersectPoint.current.x;
|
||||
}
|
||||
return null;
|
||||
}, [gl, camera, raycaster, height]);
|
||||
|
||||
// --- Resize pointer down ---
|
||||
const handleResizePointerDown = useCallback((separatorIndex, e) => {
|
||||
e.stopPropagation();
|
||||
const worldX = getWorldX(e);
|
||||
if (worldX === null) return;
|
||||
setResizeState({ separatorIndex, startX: worldX });
|
||||
}, [getWorldX]);
|
||||
|
||||
// Attach window-level pointer events for resize
|
||||
const onPointerMoveRef = useRef(null);
|
||||
const onPointerUpRef = useRef(null);
|
||||
|
||||
useMemo(() => {
|
||||
const handleMove = (e) => {
|
||||
if (resizeState) {
|
||||
const worldX = getWorldX(e);
|
||||
if (worldX !== null) {
|
||||
const deltaX = worldX - resizeState.startX;
|
||||
const deltaXmm = deltaX / SCALE;
|
||||
if (onResizeModules && Math.abs(deltaXmm) > 1) {
|
||||
onResizeModules(resizeState.separatorIndex, deltaXmm);
|
||||
setResizeState(prev => prev ? { ...prev, startX: worldX } : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
if (resizeState) {
|
||||
setResizeState(null);
|
||||
gl.domElement.style.cursor = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
onPointerMoveRef.current = handleMove;
|
||||
onPointerUpRef.current = handleUp;
|
||||
}, [resizeState, getWorldX, onResizeModules, gl]);
|
||||
|
||||
// Attach/detach event listeners
|
||||
useMemo(() => {
|
||||
const el = gl.domElement;
|
||||
const move = (e) => onPointerMoveRef.current?.(e);
|
||||
const up = (e) => onPointerUpRef.current?.(e);
|
||||
|
||||
el.addEventListener('pointermove', move);
|
||||
el.addEventListener('pointerup', up);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('pointermove', move);
|
||||
el.removeEventListener('pointerup', up);
|
||||
};
|
||||
}, [gl]);
|
||||
|
||||
// Disable OrbitControls during resize
|
||||
const interactionActive = resizeState !== null;
|
||||
|
||||
return (
|
||||
<group position={[0, height / 2, 0]}>
|
||||
{/* Plinthe bas */}
|
||||
<WardrobeElement
|
||||
elementType="plintheBas"
|
||||
geometryType="box"
|
||||
geometryArgs={[width, plinthH, depth]}
|
||||
position={[0, -height / 2 + plinthH / 2, 0]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
materialProps={{ roughness: 0.7, metalness: 0.1 }}
|
||||
/>
|
||||
|
||||
{/* Bandeau haut */}
|
||||
<WardrobeElement
|
||||
elementType="bandeauHaut"
|
||||
geometryType="box"
|
||||
geometryArgs={[width, PANEL_THICKNESS, depth]}
|
||||
position={[0, bodyH / 2 + plinthH / 2 - PANEL_THICKNESS / 2, 0]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
|
||||
{/* Dessus (top panel) */}
|
||||
<WardrobeElement
|
||||
elementType="dessus"
|
||||
geometryType="box"
|
||||
geometryArgs={[width - PANEL_THICKNESS * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS]}
|
||||
position={[0, bodyH / 2 + plinthH / 2 - PANEL_THICKNESS * 1.5, PANEL_THICKNESS / 2]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
|
||||
{/* Fond (bottom panel) */}
|
||||
<WardrobeElement
|
||||
elementType="fond"
|
||||
geometryType="box"
|
||||
geometryArgs={[width - PANEL_THICKNESS * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS]}
|
||||
position={[0, -height / 2 + plinthH + PANEL_THICKNESS / 2, PANEL_THICKNESS / 2]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
|
||||
{/* Côté gauche */}
|
||||
<WardrobeElement
|
||||
elementType="cotes"
|
||||
geometryType="box"
|
||||
geometryArgs={[PANEL_THICKNESS, bodyH, depth]}
|
||||
position={[-width / 2 + PANEL_THICKNESS / 2, plinthH / 2, 0]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
|
||||
{/* Côté droit */}
|
||||
<WardrobeElement
|
||||
elementType="cotes"
|
||||
geometryType="box"
|
||||
geometryArgs={[PANEL_THICKNESS, bodyH, depth]}
|
||||
position={[width / 2 - PANEL_THICKNESS / 2, plinthH / 2, 0]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
|
||||
{/* Dos (back panel) */}
|
||||
<WardrobeElement
|
||||
elementType="dos"
|
||||
geometryType="box"
|
||||
geometryArgs={[width - PANEL_THICKNESS * 2, bodyH - PANEL_THICKNESS * 2, PANEL_THICKNESS / 2]}
|
||||
position={[0, plinthH / 2, -depth / 2 + PANEL_THICKNESS / 4]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
materialProps={{ roughness: 0.8 }}
|
||||
/>
|
||||
|
||||
{/* Fileurs (si encastré) */}
|
||||
{isNiche && (
|
||||
<>
|
||||
<WardrobeElement
|
||||
elementType="fileur"
|
||||
geometryType="box"
|
||||
geometryArgs={[PANEL_THICKNESS / 2, bodyH, depth]}
|
||||
position={[-width / 2 - PANEL_THICKNESS / 4, plinthH / 2, 0]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
<WardrobeElement
|
||||
elementType="fileur"
|
||||
geometryType="box"
|
||||
geometryArgs={[PANEL_THICKNESS / 2, bodyH, depth]}
|
||||
position={[width / 2 + PANEL_THICKNESS / 4, plinthH / 2, 0]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Separators between modules (resizable) */}
|
||||
{useModules && onResizeModules
|
||||
? separatorPositions.map((pos, i) => (
|
||||
<ResizableSeparator
|
||||
key={`sep-resize-${i}`}
|
||||
position={[pos.x, plinthH / 2, 0]}
|
||||
height={bodyH - PANEL_THICKNESS * 2}
|
||||
depth={depth - PANEL_THICKNESS}
|
||||
separatorIndex={pos.index}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
isResizing={resizeState?.separatorIndex === pos.index}
|
||||
onResizePointerDown={handleResizePointerDown}
|
||||
/>
|
||||
))
|
||||
: separatorPositions.map((pos, i) => (
|
||||
<WardrobeElement
|
||||
key={`sep-${i}`}
|
||||
elementType="panneauxInterieurs"
|
||||
geometryType="box"
|
||||
geometryArgs={[PANEL_THICKNESS, bodyH - PANEL_THICKNESS * 2, depth - PANEL_THICKNESS]}
|
||||
position={[pos.x, plinthH / 2, PANEL_THICKNESS / 2]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
{/* Width labels -- show during resize or hover */}
|
||||
{modules.length > 0 && (resizeState !== null || hoveredModule !== null) && (
|
||||
<>
|
||||
{resizeState !== null && (
|
||||
<>
|
||||
{modules[resizeState.separatorIndex] && (
|
||||
<WidthLabel
|
||||
position={[modulePositions[resizeState.separatorIndex]?.center || 0, bodyH / 2 + plinthH / 2 + 0.06, depth / 2]}
|
||||
widthMm={modules[resizeState.separatorIndex].widthMm}
|
||||
/>
|
||||
)}
|
||||
{modules[resizeState.separatorIndex + 1] && (
|
||||
<WidthLabel
|
||||
position={[modulePositions[resizeState.separatorIndex + 1]?.center || 0, bodyH / 2 + plinthH / 2 + 0.06, depth / 2]}
|
||||
widthMm={modules[resizeState.separatorIndex + 1].widthMm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{resizeState === null && hoveredModule !== null && modules[hoveredModule] && (
|
||||
<WidthLabel
|
||||
position={[modulePositions[hoveredModule]?.center || 0, bodyH / 2 + plinthH / 2 + 0.06, depth / 2]}
|
||||
widthMm={modules[hoveredModule].widthMm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Module contents: doors, shelves, rods, drawers + click-to-select */}
|
||||
{useModules && modules.map((module, moduleIndex) => {
|
||||
const pos = modulePositions[moduleIndex];
|
||||
if (!pos) return null;
|
||||
const moduleCenter = pos.center;
|
||||
const moduleWidth = pos.width;
|
||||
const shelfCount = module.shelves || 0;
|
||||
const drawersCount = module.drawers || 0;
|
||||
const isSelected = selectedModuleIndex === moduleIndex;
|
||||
|
||||
return (
|
||||
<group key={`mod-${module.id}`}>
|
||||
{/* Invisible click/hover area for module selection */}
|
||||
<mesh
|
||||
position={[moduleCenter, plinthH / 2, 0]}
|
||||
onPointerOver={() => setHoveredModule(moduleIndex)}
|
||||
onPointerOut={() => setHoveredModule(prev => prev === moduleIndex ? null : prev)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onModuleClick) onModuleClick(moduleIndex);
|
||||
}}
|
||||
>
|
||||
<boxGeometry args={[moduleWidth - PANEL_THICKNESS * 2, bodyH - PANEL_THICKNESS * 2, depth * 0.5]} />
|
||||
<meshBasicMaterial transparent opacity={0} />
|
||||
</mesh>
|
||||
|
||||
{/* Selection highlight */}
|
||||
{isSelected && (
|
||||
<mesh position={[moduleCenter, plinthH / 2, depth / 2 + 0.005]}>
|
||||
<planeGeometry args={[moduleWidth * 0.98, bodyH * 0.98]} />
|
||||
<meshBasicMaterial color="#4f46e5" transparent opacity={0.08} />
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Portes battantes per module */}
|
||||
{showDoors && !isSliding && (
|
||||
<WardrobeElement
|
||||
elementType="portes"
|
||||
geometryType="plane"
|
||||
geometryArgs={[moduleWidth * 0.96, bodyH * 0.96]}
|
||||
position={[moduleCenter, plinthH / 2, depth / 2 + 0.002]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
transparent={true}
|
||||
opacity={0.9}
|
||||
materialProps={{ roughness: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Shelves */}
|
||||
{Array.from({ length: shelfCount }).map((_, shelfIndex) => {
|
||||
const shelfY = -height / 2 + plinthH + PANEL_THICKNESS +
|
||||
((bodyH - PANEL_THICKNESS * 2) / (shelfCount + 1)) * (shelfIndex + 1);
|
||||
return (
|
||||
<WardrobeElement
|
||||
key={`shelf-${moduleIndex}-${shelfIndex}`}
|
||||
elementType="panneauxInterieurs"
|
||||
geometryType="box"
|
||||
geometryArgs={[moduleWidth - PANEL_THICKNESS * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS * 2]}
|
||||
position={[moduleCenter, shelfY, 0]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Drawers */}
|
||||
{drawersCount > 0 && (
|
||||
<DrawerBox
|
||||
moduleCenter={moduleCenter}
|
||||
moduleWidth={moduleWidth}
|
||||
bodyH={bodyH}
|
||||
plinthH={plinthH}
|
||||
drawersCount={drawersCount}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rod (penderie) */}
|
||||
{module.hasRod && (
|
||||
<WardrobeElement
|
||||
elementType="penderie"
|
||||
geometryType="cylinder"
|
||||
geometryArgs={[0.012, 0.012, moduleWidth - 0.06, 16]}
|
||||
position={[moduleCenter, height / 2 - plinthH / 2 - 0.15, 0]}
|
||||
rotation={[0, 0, Math.PI / 2]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
materialProps={{ roughness: 0.3, metalness: 0.8 }}
|
||||
/>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Portes coulissantes (sliding) */}
|
||||
{showDoors && isSliding && (
|
||||
<WardrobeElement
|
||||
elementType="portes"
|
||||
geometryType="plane"
|
||||
geometryArgs={[width * 0.98, bodyH * 0.98]}
|
||||
position={[0, plinthH / 2, depth / 2 + 0.002]}
|
||||
colors={colors}
|
||||
onElementClick={onElementClick}
|
||||
transparent={true}
|
||||
opacity={0.85}
|
||||
materialProps={{ roughness: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Module content is rendered above in the module loop */}
|
||||
{/* Non-module door rendering is also above */}
|
||||
|
||||
{/* Pass interaction state to parent for OrbitControls toggling */}
|
||||
<InteractionBlocker active={interactionActive} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
InteractionBlocker - disables OrbitControls during drag/resize
|
||||
Uses useThree to access the controls
|
||||
=================================================================== */
|
||||
function InteractionBlocker({ active }) {
|
||||
const { controls } = useThree();
|
||||
useMemo(() => {
|
||||
if (controls) {
|
||||
controls.enabled = !active;
|
||||
}
|
||||
}, [active, controls]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
CameraController - exposes zoom/reset via ref
|
||||
=================================================================== */
|
||||
const CameraController = forwardRef(({ roomConfig, config }, ref) => {
|
||||
const { camera, gl } = useThree();
|
||||
const controlsRef = useRef();
|
||||
|
||||
const minDistance = roomConfig ? 1 : 0.5;
|
||||
const maxDistance = roomConfig ? 15 : 8;
|
||||
|
||||
const cameraTarget = useMemo(() => {
|
||||
if (roomConfig) {
|
||||
return [0, roomConfig.height / 2, 0];
|
||||
}
|
||||
return [0, (config.heightMm || 2400) * SCALE * 0.5, 0];
|
||||
}, [roomConfig, config]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
zoomIn: () => {
|
||||
if (controlsRef.current) {
|
||||
const currentDistance = camera.position.distanceTo(controlsRef.current.target);
|
||||
const newDistance = Math.max(minDistance, currentDistance * 0.8);
|
||||
const direction = camera.position.clone().sub(controlsRef.current.target).normalize();
|
||||
camera.position.copy(controlsRef.current.target).add(direction.multiplyScalar(newDistance));
|
||||
controlsRef.current.update();
|
||||
}
|
||||
},
|
||||
zoomOut: () => {
|
||||
if (controlsRef.current) {
|
||||
const currentDistance = camera.position.distanceTo(controlsRef.current.target);
|
||||
const newDistance = Math.min(maxDistance, currentDistance * 1.25);
|
||||
const direction = camera.position.clone().sub(controlsRef.current.target).normalize();
|
||||
camera.position.copy(controlsRef.current.target).add(direction.multiplyScalar(newDistance));
|
||||
controlsRef.current.update();
|
||||
}
|
||||
},
|
||||
resetView: () => {
|
||||
if (controlsRef.current) {
|
||||
const defaultDist = roomConfig ? Math.max(roomConfig.width, roomConfig.length) * 1.2 : 3;
|
||||
camera.position.set(defaultDist * 0.7, defaultDist * 0.5, defaultDist * 0.7);
|
||||
controlsRef.current.update();
|
||||
}
|
||||
}
|
||||
}), [camera, minDistance, maxDistance, roomConfig]);
|
||||
|
||||
return (
|
||||
<OrbitControls
|
||||
ref={controlsRef}
|
||||
enablePan={true}
|
||||
enableZoom={true}
|
||||
minDistance={minDistance}
|
||||
maxDistance={maxDistance}
|
||||
target={cameraTarget}
|
||||
makeDefault
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/* ===================================================================
|
||||
Scene - assembles everything
|
||||
=================================================================== */
|
||||
function Scene({ config, roomConfig, controllerRef, colors, modules, onElementClick, onModuleClick, selectedModuleIndex, showDoors, onResizeModules }) {
|
||||
const wardrobeTransform = useMemo(() => {
|
||||
if (roomConfig) {
|
||||
return calculateWardrobeTransform(roomConfig, config);
|
||||
}
|
||||
return { position: [0, 0, 0], rotation: [0, 0, 0] };
|
||||
}, [roomConfig, config]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ambientLight intensity={roomConfig ? 0.5 : 0.6} />
|
||||
<directionalLight
|
||||
position={[4, 6, 5]}
|
||||
intensity={roomConfig ? 1.0 : 1.2}
|
||||
castShadow
|
||||
shadow-mapSize={[1024, 1024]}
|
||||
shadow-camera-far={20}
|
||||
shadow-camera-left={-5}
|
||||
shadow-camera-right={5}
|
||||
shadow-camera-top={5}
|
||||
shadow-camera-bottom={-5}
|
||||
/>
|
||||
<directionalLight position={[-2, 4, 3]} intensity={0.4} />
|
||||
|
||||
{roomConfig && (
|
||||
<Room3D roomConfig={roomConfig} wardrobeConfig={config} />
|
||||
)}
|
||||
|
||||
<group
|
||||
position={wardrobeTransform.position}
|
||||
rotation={wardrobeTransform.rotation}
|
||||
>
|
||||
<WardrobeMesh
|
||||
config={config}
|
||||
colors={colors}
|
||||
modules={modules}
|
||||
onElementClick={onElementClick}
|
||||
onModuleClick={onModuleClick}
|
||||
selectedModuleIndex={selectedModuleIndex}
|
||||
showDoors={showDoors}
|
||||
onResizeModules={onResizeModules}
|
||||
/>
|
||||
</group>
|
||||
|
||||
<CameraController ref={controllerRef} roomConfig={roomConfig} config={config} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
WardrobePreview - main exported component
|
||||
=================================================================== */
|
||||
export default function WardrobePreview({
|
||||
config,
|
||||
roomConfig,
|
||||
colors = {},
|
||||
modules = [],
|
||||
onElementClick,
|
||||
onModuleClick,
|
||||
onResizeModules,
|
||||
selectedModuleIndex,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const controllerRef = useRef(null);
|
||||
const [selectedElement, setSelectedElement] = useState(null);
|
||||
const [showDoors, setShowDoors] = useState(true);
|
||||
|
||||
const cameraPosition = useMemo(() => {
|
||||
if (roomConfig) {
|
||||
const roomSize = Math.max(roomConfig.width, roomConfig.length);
|
||||
return [roomSize * 0.8, roomSize * 0.5, roomSize * 0.8];
|
||||
}
|
||||
return [2.5, 1.5, 2.5];
|
||||
}, [roomConfig]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
controllerRef.current?.zoomIn();
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
controllerRef.current?.zoomOut();
|
||||
}, []);
|
||||
|
||||
const handleResetView = useCallback(() => {
|
||||
controllerRef.current?.resetView();
|
||||
}, []);
|
||||
|
||||
const handleToggleDoors = useCallback(() => {
|
||||
setShowDoors(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const handleElementClick = useCallback((elementType, label) => {
|
||||
setSelectedElement({ type: elementType, label });
|
||||
if (onElementClick) {
|
||||
onElementClick(elementType, label);
|
||||
}
|
||||
}, [onElementClick]);
|
||||
|
||||
// All element types that can be color-picked (including back panel)
|
||||
const PICKABLE_ELEMENTS = useMemo(() => [
|
||||
{ type: 'portes', label: 'Portes' },
|
||||
{ type: 'cotes', label: 'Côtés' },
|
||||
{ type: 'dessus', label: 'Dessus' },
|
||||
{ type: 'fond', label: 'Fond (bas)' },
|
||||
{ type: 'dos', label: 'Dos (arrière)' },
|
||||
{ type: 'plintheBas', label: 'Plinthe' },
|
||||
{ type: 'bandeauHaut', label: 'Bandeau haut' },
|
||||
{ type: 'panneauxInterieurs', label: 'Panneaux int.' },
|
||||
{ type: 'separateurs', label: 'Séparateurs' },
|
||||
{ type: 'etageres', label: 'Étagères' },
|
||||
{ type: 'tiroirs', label: 'Tiroirs' },
|
||||
{ type: 'penderie', label: 'Tringle' },
|
||||
], []);
|
||||
|
||||
const handleElementSelect = useCallback((e) => {
|
||||
const val = e.target.value;
|
||||
if (!val) return;
|
||||
const item = PICKABLE_ELEMENTS.find(p => p.type === val);
|
||||
if (item) {
|
||||
handleElementClick(item.type, item.label);
|
||||
}
|
||||
// Reset select to placeholder
|
||||
e.target.value = '';
|
||||
}, [PICKABLE_ELEMENTS, handleElementClick]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.container} style={{ background: roomConfig ? '#87CEEB' : '#e8e4df' }}>
|
||||
{selectedElement && (
|
||||
<div className={styles.selectedElement}>
|
||||
Élément sélectionné: <strong>{selectedElement.label}</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className={styles.controls}>
|
||||
<button type="button" className={styles.controlBtn} onClick={handleZoomIn} title="Zoom avant">+</button>
|
||||
<button type="button" className={styles.controlBtn} onClick={handleZoomOut} title="Zoom arrière">−</button>
|
||||
<button type="button" className={styles.controlBtn} onClick={handleResetView} title="Réinitialiser la vue">◊</button>
|
||||
<div className={styles.controlDivider} />
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.controlBtn} ${!showDoors ? styles.controlBtnActive : ''}`}
|
||||
onClick={handleToggleDoors}
|
||||
title={showDoors ? 'Masquer les portes' : 'Afficher les portes'}
|
||||
>
|
||||
{showDoors ? '\u{1F6AA}' : '\u{1F441}\uFE0F'}
|
||||
</button>
|
||||
<div className={styles.controlDivider} />
|
||||
<select
|
||||
className={styles.elementSelect}
|
||||
onChange={handleElementSelect}
|
||||
defaultValue=""
|
||||
title="Choisir un élément pour changer sa couleur"
|
||||
>
|
||||
<option value="" disabled>🎨 Couleur…</option>
|
||||
{PICKABLE_ELEMENTS.map(el => (
|
||||
<option key={el.type} value={el.type}>{el.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<div className={styles.loading}>Chargement 3D…</div>}>
|
||||
<Canvas
|
||||
shadows
|
||||
camera={{ position: cameraPosition, fov: roomConfig ? 60 : 45 }}
|
||||
gl={{ antialias: true, alpha: false }}
|
||||
style={{ width: '100%', height: '100%', display: 'block' }}
|
||||
>
|
||||
<Scene
|
||||
config={config}
|
||||
roomConfig={roomConfig}
|
||||
controllerRef={controllerRef}
|
||||
colors={colors}
|
||||
modules={modules}
|
||||
onElementClick={handleElementClick}
|
||||
onModuleClick={onModuleClick}
|
||||
selectedModuleIndex={selectedModuleIndex}
|
||||
showDoors={showDoors}
|
||||
onResizeModules={onResizeModules}
|
||||
/>
|
||||
</Canvas>
|
||||
</Suspense>
|
||||
|
||||
{/* Hint */}
|
||||
<div className={styles.hint}>
|
||||
Cliquer un module = configurer {'\u2022'} Cliquer un {'\u00e9'}l{'\u00e9'}ment = couleur {'\u2022'} Tirer s{'\u00e9'}parateurs = redimensionner
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
.container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 280px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.controlBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #333;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.controlBtn:hover {
|
||||
background: #fff;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.controlBtn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 4px 12px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 12px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.selectedElement {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
padding: 8px 14px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #333;
|
||||
font-size: 0.85rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.selectedElement strong {
|
||||
color: var(--action);
|
||||
}
|
||||
|
||||
.controlDivider {
|
||||
width: 24px;
|
||||
height: 1px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
margin: 4px 6px;
|
||||
}
|
||||
|
||||
.controlBtnActive {
|
||||
background: var(--action);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.controlBtnActive:hover {
|
||||
background: var(--action-hover);
|
||||
}
|
||||
|
||||
.elementSelect {
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
max-width: 110px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.elementSelect:hover {
|
||||
border-color: var(--action);
|
||||
}
|
||||
|
||||
.elementSelect:focus {
|
||||
border-color: var(--action);
|
||||
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'admin';
|
||||
const ADMIN_PASSWORD = 'ketimapex';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [isAdmin, setIsAdmin] = useState(() => typeof window !== 'undefined' && sessionStorage.getItem(STORAGE_KEY) === 'true');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (isAdmin) sessionStorage.setItem(STORAGE_KEY, 'true');
|
||||
else sessionStorage.removeItem(STORAGE_KEY);
|
||||
}, [isAdmin]);
|
||||
|
||||
const login = (password) => {
|
||||
if (password === ADMIN_PASSWORD) {
|
||||
setIsAdmin(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const logout = () => setIsAdmin(false);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAdmin, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
:root {
|
||||
--bg: #fafaf9;
|
||||
--bg-card: #ffffff;
|
||||
--bg-subtle: #f9fafb;
|
||||
--text: #374151;
|
||||
--text-muted: #6b7280;
|
||||
--accent: #78716c;
|
||||
--wood: #a8a29e;
|
||||
--border: #e7e5e4;
|
||||
--border-light: #e5e7eb;
|
||||
--primary: #1c1917;
|
||||
--action: #4f46e5;
|
||||
--action-hover: #4338ca;
|
||||
--success: #059669;
|
||||
--warning: #f59e0b;
|
||||
--danger: #dc2626;
|
||||
--font: 'DM Sans', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input, select {
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,240 @@
|
||||
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 styles from './Admin.module.css';
|
||||
|
||||
export default function Admin() {
|
||||
const { isAdmin, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Formex sync state
|
||||
const [syncLogs, setSyncLogs] = useState([]);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState(null);
|
||||
|
||||
// SSE live progress state
|
||||
const [liveMessages, setLiveMessages] = useState([]);
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0, collection: '' });
|
||||
const logsEndRef = 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]) => {
|
||||
setOrders(ordersData);
|
||||
setSyncLogs(logsData);
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [isAdmin, navigate]);
|
||||
|
||||
// Auto-scroll to bottom of live logs
|
||||
useEffect(() => {
|
||||
if (logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [liveMessages]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/', { replace: true });
|
||||
};
|
||||
|
||||
const handleFormexSync = () => {
|
||||
setSyncing(true);
|
||||
setSyncResult(null);
|
||||
setLiveMessages([]);
|
||||
setProgress({ current: 0, total: 0, collection: '' });
|
||||
|
||||
const eventSource = new EventSource(getFormexSyncStreamUrl());
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'log') {
|
||||
setLiveMessages(prev => [...prev, data.message]);
|
||||
} else if (data.type === 'progress') {
|
||||
setProgress({
|
||||
current: data.current,
|
||||
total: data.total,
|
||||
collection: data.collection || ''
|
||||
});
|
||||
} else if (data.type === 'complete') {
|
||||
setSyncResult(data.result);
|
||||
setSyncing(false);
|
||||
eventSource.close();
|
||||
// Refresh sync logs
|
||||
fetchSyncLogs(10).then(setSyncLogs).catch(() => {});
|
||||
} else if (data.type === 'error') {
|
||||
setSyncResult({ success: false, error: data.error });
|
||||
setSyncing(false);
|
||||
eventSource.close();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('SSE parse error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
if (syncing) {
|
||||
setSyncResult({ success: false, error: 'Connexion perdue' });
|
||||
setSyncing(false);
|
||||
}
|
||||
eventSource.close();
|
||||
};
|
||||
};
|
||||
|
||||
const progressPercent = progress.total > 0
|
||||
? Math.round((progress.current / progress.total) * 100)
|
||||
: 0;
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.header}>
|
||||
<h1>Administration</h1>
|
||||
<button type="button" onClick={handleLogout} className={styles.logout}>
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
{loading && <p className={styles.muted}>Chargement…</p>}
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
{/* Formex Sync Section */}
|
||||
{!loading && !error && (
|
||||
<section className={styles.section}>
|
||||
<h2>Synchronisation Formex</h2>
|
||||
<div className={styles.syncActions}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFormexSync}
|
||||
disabled={syncing}
|
||||
className={styles.syncBtn}
|
||||
>
|
||||
{syncing ? 'Synchronisation en cours…' : 'Synchroniser Formex'}
|
||||
</button>
|
||||
{syncResult && !syncing && (
|
||||
<span className={syncResult.success ? styles.success : styles.error}>
|
||||
{syncResult.success
|
||||
? `${syncResult.itemsCount} produits synchronisés (${syncResult.duration}s)`
|
||||
: `Erreur: ${syncResult.error}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar and live logs during sync */}
|
||||
{syncing && (
|
||||
<div className={styles.liveSync}>
|
||||
<div className={styles.progressWrap}>
|
||||
<div className={styles.progressInfo}>
|
||||
<span>{progress.collection || 'Initialisation…'}</span>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
<div className={styles.progressBar}>
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.progressStats}>
|
||||
{progress.current} / {progress.total} collections
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.liveLogsPanel}>
|
||||
<div className={styles.liveLogsContent}>
|
||||
{liveMessages.map((msg, idx) => (
|
||||
<div key={idx} className={styles.logLine}>{msg}</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{syncLogs.length > 0 && !syncing && (
|
||||
<div className={styles.logsWrap}>
|
||||
<h3>Historique des synchronisations</h3>
|
||||
<table className={styles.logsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Source</th>
|
||||
<th>Statut</th>
|
||||
<th>Produits</th>
|
||||
<th>Erreur</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{syncLogs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td>{log.created_at ? new Date(log.created_at).toLocaleString('fr-CH') : '—'}</td>
|
||||
<td>{log.source}</td>
|
||||
<td className={log.status === 'success' ? styles.success : styles.error}>
|
||||
{log.status === 'success' ? 'Succès' : 'Erreur'}
|
||||
</td>
|
||||
<td>{log.items_count ?? '—'}</td>
|
||||
<td>{log.error || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Orders Section */}
|
||||
{!loading && !error && (
|
||||
<section className={styles.section}>
|
||||
<h2>Commandes</h2>
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Date</th>
|
||||
<th>Total CHF</th>
|
||||
<th>Nom</th>
|
||||
<th>Email</th>
|
||||
<th>Téléphone</th>
|
||||
<th>Remarques</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className={styles.empty}>
|
||||
Aucune commande.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
orders.map((o) => (
|
||||
<tr key={o.id}>
|
||||
<td>{o.id}</td>
|
||||
<td>{o.created_at ? new Date(o.created_at).toLocaleString('fr-CH') : '—'}</td>
|
||||
<td>{o.total_chf != null ? Number(o.total_chf).toLocaleString('fr-CH') : '—'}</td>
|
||||
<td>{o.name || '—'}</td>
|
||||
<td>{o.email || '—'}</td>
|
||||
<td>{o.phone || '—'}</td>
|
||||
<td>{o.notes || '—'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
.logout {
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logout:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-size: 0.9375rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Section styling */
|
||||
.section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
}
|
||||
|
||||
/* Sync actions */
|
||||
.syncActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.syncBtn {
|
||||
padding: 0.625rem 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;
|
||||
}
|
||||
|
||||
.syncBtn:hover:not(:disabled) {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.syncBtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
/* Sync logs table */
|
||||
.logsWrap {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.logsTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logsTable th,
|
||||
.logsTable td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.logsTable th {
|
||||
background: var(--bg);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.logsTable tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Live sync progress */
|
||||
.liveSync {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progressWrap {
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.progressInfo {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.progressInfo span:first-child {
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.progressInfo span:last-child {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progressStats {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.liveLogsPanel {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.liveLogsContent {
|
||||
padding: 0.75rem 1rem;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.logLine {
|
||||
color: #d4d4d4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import styles from './Home.module.css';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className={styles.home}>
|
||||
<section className={styles.hero}>
|
||||
<h1>Armoire sur mesure. Prix clair. Kit prêt à monter.</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Conçue par un menuisier suisse. Configurée en ligne, sans devis ni surprise.
|
||||
</p>
|
||||
<Link to="/configurateur" className={styles.cta}>Configurer mon armoire</Link>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h2>Pourquoi SIGNATURE</h2>
|
||||
<ul className={styles.features}>
|
||||
<li><strong>Blocs modulaires</strong> — Penderie, rayonnage, mixtes ; assemblez selon vos besoins.</li>
|
||||
<li><strong>Prix transparent</strong> — Tarif affiché en direct, sans surprise.</li>
|
||||
<li><strong>Services en option</strong> — Prise de mesure, montage, sur demande.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h2>Comment ça marche</h2>
|
||||
<ol className={styles.steps}>
|
||||
<li>Configurez en ligne (dimensions, blocs, portes, finitions).</li>
|
||||
<li>Voyez le prix en CHF en temps réel.</li>
|
||||
<li>Passez commande ; livraison en kit prêt à monter.</li>
|
||||
<li>Option : prise de mesure et montage par un pro.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h2>Le système modulaire</h2>
|
||||
<p className={styles.muted}>
|
||||
Armoire composée de blocs juxtaposés : penderie, rayonnage, blocs mixtes. Chaque bloc est paramétrable en largeur. Portes battantes ou coulissantes selon la largeur.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h2>Tarifs</h2>
|
||||
<p className={styles.price}>À partir de CHF 3'900.–</p>
|
||||
<p className={styles.muted}>Prix indicatif pour une configuration type. Le configurateur affiche le prix exact selon vos choix.</p>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h2>Services optionnels</h2>
|
||||
<ul className={styles.features}>
|
||||
<li>Prise de mesure sur site</li>
|
||||
<li>Montage par un menuisier</li>
|
||||
</ul>
|
||||
<p className={styles.muted}>Tarifs au forfait ou à l'heure ; sélectionnables dans le configurateur.</p>
|
||||
</section>
|
||||
|
||||
<section className={styles.ctaSection}>
|
||||
<Link to="/configurateur" className={styles.cta}>Configurer mon armoire</Link>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
.home {
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 4rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(1.75rem, 4vw, 2.5rem);
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
margin: 0 0 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 2rem;
|
||||
max-width: 36rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: inline-block;
|
||||
padding: 0.875rem 1.75rem;
|
||||
background: var(--primary);
|
||||
color: var(--bg) !important;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cta:hover {
|
||||
background: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 2.5rem 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.features, .steps {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.features li, .steps li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ctaSection {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import styles from './Login.module.css';
|
||||
|
||||
export default function Login() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const next = searchParams.get('next') || '/';
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (login(password)) {
|
||||
navigate(next, { replace: true });
|
||||
} else {
|
||||
setError('Mot de passe incorrect.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.card}>
|
||||
<h1>Connexion admin</h1>
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<label>
|
||||
Mot de passe
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<button type="submit">Se connecter</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
.page {
|
||||
min-height: 60vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.card h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.form label {
|
||||
display: block;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--primary);
|
||||
color: var(--bg);
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form button:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import {
|
||||
fetchFormexMaterials,
|
||||
fetchFormexCollections,
|
||||
deleteFormexMaterial
|
||||
} from '../api/client';
|
||||
import MaterialModal from '../components/MaterialModal';
|
||||
import styles from './Materials.module.css';
|
||||
|
||||
export default function Materials() {
|
||||
const { isAdmin } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Data state
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [collections, setCollections] = useState([]);
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 25, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Filter state
|
||||
const [search, setSearch] = useState('');
|
||||
const [collection, setCollection] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
|
||||
// Modal state
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingMaterial, setEditingMaterial] = useState(null);
|
||||
|
||||
// Delete confirmation
|
||||
const [deletingId, setDeletingId] = useState(null);
|
||||
|
||||
const loadMaterials = useCallback(async (page = 1) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await fetchFormexMaterials({ search, collection, page, limit: 25 });
|
||||
setMaterials(result.materials);
|
||||
setPagination(result.pagination);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, collection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
navigate('/login?next=/materiaux', { replace: true });
|
||||
return;
|
||||
}
|
||||
// Load collections for filter dropdown
|
||||
fetchFormexCollections()
|
||||
.then(setCollections)
|
||||
.catch(() => {});
|
||||
// Load materials
|
||||
loadMaterials(1);
|
||||
}, [isAdmin, navigate, loadMaterials]);
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
setSearch(searchInput);
|
||||
};
|
||||
|
||||
const handleCollectionChange = (e) => {
|
||||
setCollection(e.target.value);
|
||||
};
|
||||
|
||||
// Reload when filters change
|
||||
useEffect(() => {
|
||||
loadMaterials(1);
|
||||
}, [search, collection, loadMaterials]);
|
||||
|
||||
const handlePageChange = (newPage) => {
|
||||
loadMaterials(newPage);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingMaterial(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (material) => {
|
||||
setEditingMaterial(material);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setModalOpen(false);
|
||||
setEditingMaterial(null);
|
||||
};
|
||||
|
||||
const handleModalSave = () => {
|
||||
setModalOpen(false);
|
||||
setEditingMaterial(null);
|
||||
loadMaterials(pagination.page);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (id) => {
|
||||
setDeletingId(id);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deletingId) return;
|
||||
try {
|
||||
await deleteFormexMaterial(deletingId);
|
||||
setDeletingId(null);
|
||||
loadMaterials(pagination.page);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setDeletingId(null);
|
||||
};
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.header}>
|
||||
<h1>Matériaux Formex</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
|
||||
type="text"
|
||||
placeholder="Rechercher par nom ou code…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
<button type="submit" className={styles.searchBtn}>Rechercher</button>
|
||||
</form>
|
||||
<select
|
||||
value={collection}
|
||||
onChange={handleCollectionChange}
|
||||
className={styles.collectionSelect}
|
||||
>
|
||||
<option value="">Toutes les collections</option>
|
||||
{collections.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<p className={styles.muted}>Chargement…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Nom</th>
|
||||
<th>Collection</th>
|
||||
<th>Dimensions (mm)</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{materials.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className={styles.empty}>
|
||||
Aucun matériau trouvé.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
materials.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<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.width_mm} × ${m.height_mm} × ${m.thickness_mm}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEdit(m)}
|
||||
className={styles.editBtn}
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteClick(m.id)}
|
||||
className={styles.deleteBtn}
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className={styles.pagination}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePageChange(pagination.page - 1)}
|
||||
disabled={pagination.page <= 1}
|
||||
className={styles.pageBtn}
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
<span className={styles.pageInfo}>
|
||||
Page {pagination.page} sur {pagination.totalPages} ({pagination.total} résultats)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePageChange(pagination.page + 1)}
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
className={styles.pageBtn}
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{deletingId && (
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.dialog}>
|
||||
<p>Voulez-vous vraiment supprimer ce matériau ?</p>
|
||||
<div className={styles.dialogActions}>
|
||||
<button type="button" onClick={handleDeleteCancel} className={styles.cancelBtn}>
|
||||
Annuler
|
||||
</button>
|
||||
<button type="button" onClick={handleDeleteConfirm} className={styles.confirmDeleteBtn}>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{modalOpen && (
|
||||
<MaterialModal
|
||||
material={editingMaterial}
|
||||
collections={collections}
|
||||
onClose={handleModalClose}
|
||||
onSave={handleModalSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
.createBtn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.createBtn:hover {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.searchForm {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.searchBtn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.searchBtn:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.collectionSelect {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
background: #fff;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.collectionSelect:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
.table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.editBtn,
|
||||
.deleteBtn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.editBtn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
color: #b91c1c;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.deleteBtn:hover {
|
||||
background: #fef2f2;
|
||||
border-color: #b91c1c;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-size: 0.9375rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pageBtn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.pageBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Delete confirmation dialog */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: #fff;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.dialog p {
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.dialogActions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cancelBtn:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.confirmDeleteBtn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #b91c1c;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.confirmDeleteBtn:hover {
|
||||
background: #991b1b;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
# Build sur Windows — Dépannage
|
||||
|
||||
Le client utilise **Vite**, qui s’appuie sur **esbuild** (binaire natif fourni pour Windows : `@esbuild/win32-x64/esbuild.exe`). Aucune compilation C++ n’est nécessaire : ce sont des exécutables précompilés.
|
||||
|
||||
## Vous avez désinstallé « Windows Development » / « Windows Developer » (~2 Go)
|
||||
|
||||
Le logiciel d’environ **2 Go** dont le nom contient **Windows Development** ou **Windows Developer** est en général :
|
||||
|
||||
- **Visual Studio Build Tools** (outils de génération Visual Studio), ou
|
||||
- **Windows SDK** (Software Development Kit), ou
|
||||
- un composant **« Développement Windows »** de Visual Studio.
|
||||
|
||||
Pour ce projet (Vite + esbuild), **vous n’avez pas besoin de réinstaller tout ça**. Il suffit d’installer le **runtime** qui va avec, beaucoup plus léger.
|
||||
|
||||
### 1. Réinstaller uniquement le Visual C++ Redistributable (souvent suffisant)
|
||||
|
||||
Après la désinstallation du gros paquet « Windows Development », il peut manquer le **Visual C++ Redistributable** (quelques dizaines de Mo). C’est lui qui permet d’exécuter des .exe natifs comme `esbuild.exe`.
|
||||
|
||||
- Télécharger : [Microsoft Visual C++ Redistributable (dernière version)](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist)
|
||||
- Installer la version **x64** (et éventuellement x86 si vous utilisez du 32 bits).
|
||||
- Redémarrer le terminal, puis dans `client/` : `npm run build`.
|
||||
|
||||
Pas besoin de réinstaller les 2 Go de « Windows Development » pour faire tourner le build.
|
||||
|
||||
### 2. Réinstaller les dépendances du client
|
||||
|
||||
```powershell
|
||||
cd client
|
||||
Remove-Item -Recurse -Force node_modules
|
||||
Remove-Item -Force package-lock.json
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 3. Antivirus / Windows Defender
|
||||
|
||||
Parfois l’antivirus ou Windows Defender bloque l’exécution de `esbuild.exe`.
|
||||
|
||||
- Ajouter une exclusion pour le dossier du projet (ou au minimum `client\node_modules`) dans Windows Defender / antivirus.
|
||||
- Relancer `npm run build`.
|
||||
|
||||
### 4. Droits / exécution
|
||||
|
||||
- Lancer **PowerShell ou CMD en tant qu’administrateur** une fois, aller dans `client/` et lancer `npm run build` pour vérifier si le problème vient des droits.
|
||||
- Vérifier que le disque n’est pas en lecture seule et que le dossier n’est pas synchronisé par OneDrive/Dropbox au moment du build.
|
||||
|
||||
### 5. Node et npm à jour
|
||||
|
||||
```powershell
|
||||
node -v # 18+ recommandé
|
||||
npm -v
|
||||
```
|
||||
|
||||
Si besoin, réinstaller Node depuis [nodejs.org](https://nodejs.org/) (version LTS).
|
||||
|
||||
---
|
||||
|
||||
En résumé : après avoir désinstallé des outils de dev Windows, réinstaller le **Visual C++ Redistributable** et refaire un `npm install` dans `client/` règle souvent le problème.
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "apex",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "configurateur-armoires",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Chemin du fichier SQLite (optionnel, défaut : server/data/configurateur.db)
|
||||
# DATABASE_PATH=./data/configurateur.db
|
||||
PORT=3001
|
||||
|
||||
# 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
|
||||
|
||||
# OpenAI API Key for room photo analysis (GPT-4 Vision)
|
||||
OPENAI_API_KEY=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
|
||||
@@ -0,0 +1,62 @@
|
||||
import initSqlJs from 'sql.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
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');
|
||||
|
||||
let db = null;
|
||||
|
||||
function toSqlitePlaceholders(sql) {
|
||||
return sql.replace(/\$(\d+)/g, '?');
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (db && dbPath) {
|
||||
const data = db.export();
|
||||
fs.writeFileSync(dbPath, Buffer.from(data));
|
||||
}
|
||||
}
|
||||
|
||||
async function getDb() {
|
||||
if (db) return db;
|
||||
const SQL = await initSqlJs({
|
||||
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
|
||||
});
|
||||
let buffer;
|
||||
if (fs.existsSync(dbPath)) {
|
||||
buffer = fs.readFileSync(dbPath);
|
||||
}
|
||||
db = new SQL.Database(buffer || null);
|
||||
return db;
|
||||
}
|
||||
|
||||
export const dbReady = getDb();
|
||||
|
||||
export const query = async (sql, params = []) => {
|
||||
const database = await getDb();
|
||||
const sqliteSql = toSqlitePlaceholders(sql);
|
||||
const stmt = database.prepare(sqliteSql);
|
||||
stmt.bind(Array.isArray(params) ? params : []);
|
||||
const rows = [];
|
||||
while (stmt.step()) rows.push(stmt.getAsObject());
|
||||
stmt.free();
|
||||
return { rows };
|
||||
};
|
||||
|
||||
export const run = async (sql, params = []) => {
|
||||
const database = await getDb();
|
||||
const sqliteSql = toSqlitePlaceholders(sql);
|
||||
database.run(sqliteSql, Array.isArray(params) ? params : []);
|
||||
save();
|
||||
return {};
|
||||
};
|
||||
|
||||
const pool = {
|
||||
query: (sql, params) => query(sql, params)
|
||||
};
|
||||
|
||||
export default pool;
|
||||
@@ -0,0 +1,19 @@
|
||||
import initSqlJs from 'sql.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
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 SQL = await initSqlJs({
|
||||
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
|
||||
});
|
||||
const db = new SQL.Database();
|
||||
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
|
||||
db.exec(schema);
|
||||
fs.writeFileSync(dbPath, Buffer.from(db.export()));
|
||||
db.close();
|
||||
console.log('Schema applied.');
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Run pending migrations on an existing database
|
||||
*/
|
||||
import initSqlJs from 'sql.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dataDir = path.join(__dirname, '..', 'data');
|
||||
const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db');
|
||||
const migrationsDir = path.join(__dirname, 'migrations');
|
||||
|
||||
async function migrate() {
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
console.error(`Database not found at ${dbPath}. Run db:init first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const SQL = await initSqlJs({
|
||||
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
|
||||
});
|
||||
|
||||
const buffer = fs.readFileSync(dbPath);
|
||||
const db = new SQL.Database(buffer);
|
||||
|
||||
// Get migration files
|
||||
const migrationFiles = fs.readdirSync(migrationsDir)
|
||||
.filter(f => f.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
console.log(`Found ${migrationFiles.length} migration(s)`);
|
||||
|
||||
for (const file of migrationFiles) {
|
||||
console.log(`Running migration: ${file}`);
|
||||
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
|
||||
try {
|
||||
db.exec(sql);
|
||||
console.log(` ✓ ${file} applied`);
|
||||
} catch (err) {
|
||||
// IF NOT EXISTS should prevent errors on re-run
|
||||
console.log(` ✓ ${file} (already applied or no changes)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save database
|
||||
fs.writeFileSync(dbPath, Buffer.from(db.export()));
|
||||
db.close();
|
||||
|
||||
console.log('Migrations complete.');
|
||||
}
|
||||
|
||||
migrate().catch(err => {
|
||||
console.error('Migration failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Migration: Add Formex materials and sync logs tables
|
||||
-- Run this on existing databases to add crawler support
|
||||
|
||||
-- Matériaux Formex (données scrapées)
|
||||
CREATE TABLE IF NOT EXISTS formex_materials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
formex_code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
collection TEXT NOT NULL,
|
||||
width_mm INTEGER,
|
||||
height_mm INTEGER,
|
||||
thickness_mm INTEGER,
|
||||
formex_url TEXT,
|
||||
last_synced_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Journal de synchronisation
|
||||
CREATE TABLE IF NOT EXISTS sync_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
items_count INTEGER,
|
||||
error TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Migration: Enhanced Formex materials with dimensions, variants, images
|
||||
-- Adds support for multiple dimension formats and color variants per product
|
||||
|
||||
-- Add new columns to formex_materials
|
||||
ALTER TABLE formex_materials ADD COLUMN category TEXT;
|
||||
ALTER TABLE formex_materials ADD COLUMN image_url TEXT;
|
||||
ALTER TABLE formex_materials ADD COLUMN price_chf REAL;
|
||||
ALTER TABLE formex_materials ADD COLUMN active INTEGER DEFAULT 1;
|
||||
|
||||
-- Dimension variants (multiple dimensions per material)
|
||||
CREATE TABLE IF NOT EXISTS formex_dimensions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
material_id INTEGER NOT NULL REFERENCES formex_materials(id) ON DELETE CASCADE,
|
||||
width_mm INTEGER NOT NULL,
|
||||
height_mm INTEGER NOT NULL,
|
||||
thickness_mm INTEGER NOT NULL,
|
||||
is_default INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- Color/decor variants (linked products)
|
||||
CREATE TABLE IF NOT EXISTS formex_variants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
material_id INTEGER NOT NULL REFERENCES formex_materials(id) ON DELETE CASCADE,
|
||||
variant_code TEXT NOT NULL,
|
||||
variant_name TEXT NOT NULL,
|
||||
variant_url TEXT,
|
||||
image_url TEXT
|
||||
);
|
||||
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_formex_dimensions_material ON formex_dimensions(material_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_formex_variants_material ON formex_variants(material_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_formex_materials_active ON formex_materials(active);
|
||||
CREATE INDEX IF NOT EXISTS idx_formex_materials_collection ON formex_materials(collection);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Migration: Room configurations table for AI-analyzed room layouts
|
||||
-- Stores room analysis results linked to orders
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER REFERENCES orders(id),
|
||||
image_path TEXT,
|
||||
analysis_json TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_room_configs_order ON room_configs(order_id);
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Migration: Modules, hardware, and variants for V2 configurator
|
||||
|
||||
-- Catalogue de modules standards
|
||||
CREATE TABLE IF NOT EXISTS modules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL, -- 'penderie', 'rayonnage', 'tiroirs', 'mixte'
|
||||
width_mm INTEGER NOT NULL,
|
||||
height_mm INTEGER,
|
||||
depth_mm INTEGER,
|
||||
shelves_count INTEGER DEFAULT 0,
|
||||
drawers_count INTEGER DEFAULT 0,
|
||||
has_rod INTEGER DEFAULT 0, -- penderie
|
||||
customizable INTEGER DEFAULT 1,
|
||||
base_price_chf REAL NOT NULL,
|
||||
image_url TEXT,
|
||||
active INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Quincaillerie Blum
|
||||
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, -- 'drawer_system', 'hinge', 'lift_system', 'runner'
|
||||
variant TEXT, -- 'legrabox', 'tandembox', 'clip_top', etc.
|
||||
price_chf REAL NOT NULL,
|
||||
specs_json TEXT,
|
||||
image_url TEXT,
|
||||
active INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
-- Variantes poignées
|
||||
CREATE TABLE IF NOT EXISTS handle_variants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
style TEXT, -- 'moderne', 'classique', 'minimaliste', etc.
|
||||
length_mm INTEGER,
|
||||
price_chf REAL NOT NULL,
|
||||
image_url TEXT,
|
||||
active INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
-- Variantes charnières
|
||||
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, -- 'vissee', 'plantee'
|
||||
brand TEXT DEFAULT 'Blum',
|
||||
price_chf REAL NOT NULL,
|
||||
active INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
-- Configuration couleurs par élément (pour sauvegarder les configs)
|
||||
CREATE TABLE IF NOT EXISTS element_colors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE,
|
||||
element_type TEXT NOT NULL, -- 'plintheBas', 'portes', 'cotes', etc.
|
||||
finish_id INTEGER NOT NULL,
|
||||
finish_label TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_modules_category ON modules(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_modules_active ON modules(active);
|
||||
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);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Add dominant_color column to formex_materials for 3D preview color representation
|
||||
ALTER TABLE formex_materials ADD COLUMN dominant_color TEXT;
|
||||
|
||||
-- Create index for quick lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_formex_materials_dominant_color ON formex_materials(dominant_color);
|
||||
@@ -0,0 +1,202 @@
|
||||
-- Configurateur armoires sur mesure — Schéma V1 (SQLite)
|
||||
|
||||
-- Paramètres globaux (forfait kit, plinthe, épaisseur panneaux, etc.)
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Produits / gammes (ex. SIGNATURE)
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
active INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Types de blocs (penderie, rayonnage, mixte vertical, mixte horizontal)
|
||||
CREATE TABLE IF NOT EXISTS block_types (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
base_price_chf REAL NOT NULL,
|
||||
width_min_mm INTEGER NOT NULL,
|
||||
width_max_mm INTEGER NOT NULL,
|
||||
active INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
-- Formats de panneaux (par finition : dimensions + prix panneau entier)
|
||||
CREATE TABLE IF NOT EXISTS panel_formats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
width_mm INTEGER NOT NULL,
|
||||
height_mm INTEGER NOT NULL,
|
||||
price_chf REAL NOT NULL
|
||||
);
|
||||
|
||||
-- Finitions visibles (stratifié décor, couleur, effet bois, etc.)
|
||||
CREATE TABLE IF NOT EXISTS finishes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
panel_format_id INTEGER NOT NULL REFERENCES panel_formats(id),
|
||||
active INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
-- Options de service (prise de mesure, montage)
|
||||
CREATE TABLE IF NOT EXISTS service_options (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
price_chf REAL NOT NULL,
|
||||
price_type TEXT NOT NULL CHECK (price_type IN ('fixed', 'per_hour', 'per_block')),
|
||||
active INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
-- Commandes / leads (config snapshot + coordonnées)
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
config TEXT NOT NULL,
|
||||
total_chf REAL,
|
||||
email TEXT,
|
||||
name TEXT,
|
||||
phone TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Matériaux Formex (données scrapées)
|
||||
CREATE TABLE IF NOT EXISTS formex_materials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
formex_code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
collection TEXT NOT NULL,
|
||||
category TEXT,
|
||||
width_mm INTEGER,
|
||||
height_mm INTEGER,
|
||||
thickness_mm INTEGER,
|
||||
image_url TEXT,
|
||||
formex_url TEXT,
|
||||
price_chf REAL,
|
||||
dominant_color TEXT,
|
||||
active INTEGER DEFAULT 1,
|
||||
last_synced_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Dimension variants (multiple dimensions per material)
|
||||
CREATE TABLE IF NOT EXISTS formex_dimensions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
material_id INTEGER NOT NULL REFERENCES formex_materials(id) ON DELETE CASCADE,
|
||||
width_mm INTEGER NOT NULL,
|
||||
height_mm INTEGER NOT NULL,
|
||||
thickness_mm INTEGER NOT NULL,
|
||||
is_default INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- Color/decor variants (linked products)
|
||||
CREATE TABLE IF NOT EXISTS formex_variants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
material_id INTEGER NOT NULL REFERENCES formex_materials(id) ON DELETE CASCADE,
|
||||
variant_code TEXT NOT NULL,
|
||||
variant_name TEXT NOT NULL,
|
||||
variant_url TEXT,
|
||||
image_url TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_formex_dimensions_material ON formex_dimensions(material_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_formex_variants_material ON formex_variants(material_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_formex_materials_active ON formex_materials(active);
|
||||
CREATE INDEX IF NOT EXISTS idx_formex_materials_collection ON formex_materials(collection);
|
||||
|
||||
-- Journal de synchronisation
|
||||
CREATE TABLE IF NOT EXISTS sync_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
items_count INTEGER,
|
||||
error TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Room configurations (AI-analyzed room layouts)
|
||||
CREATE TABLE IF NOT EXISTS room_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER REFERENCES orders(id),
|
||||
image_path TEXT,
|
||||
analysis_json TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_room_configs_order ON room_configs(order_id);
|
||||
|
||||
-- Catalogue de modules standards
|
||||
CREATE TABLE IF NOT EXISTS modules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL, -- 'penderie', 'rayonnage', 'tiroirs', 'mixte'
|
||||
width_mm INTEGER NOT NULL,
|
||||
height_mm INTEGER,
|
||||
depth_mm INTEGER,
|
||||
shelves_count INTEGER DEFAULT 0,
|
||||
drawers_count INTEGER DEFAULT 0,
|
||||
has_rod INTEGER DEFAULT 0, -- penderie
|
||||
customizable INTEGER DEFAULT 1,
|
||||
base_price_chf REAL NOT NULL,
|
||||
image_url TEXT,
|
||||
active INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Quincaillerie Blum
|
||||
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, -- 'drawer_system', 'hinge', 'lift_system', 'runner'
|
||||
variant TEXT, -- 'legrabox', 'tandembox', 'clip_top', etc.
|
||||
price_chf REAL NOT NULL,
|
||||
specs_json TEXT,
|
||||
image_url TEXT,
|
||||
active INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
-- Variantes poignées
|
||||
CREATE TABLE IF NOT EXISTS handle_variants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
style TEXT, -- 'moderne', 'classique', 'minimaliste', etc.
|
||||
length_mm INTEGER,
|
||||
price_chf REAL NOT NULL,
|
||||
image_url TEXT,
|
||||
active INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
-- Variantes charnières
|
||||
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, -- 'vissee', 'plantee'
|
||||
brand TEXT DEFAULT 'Blum',
|
||||
price_chf REAL NOT NULL,
|
||||
active INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
-- Configuration couleurs par élément (pour sauvegarder les configs)
|
||||
CREATE TABLE IF NOT EXISTS element_colors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE,
|
||||
element_type TEXT NOT NULL, -- 'plintheBas', 'portes', 'cotes', etc.
|
||||
finish_id INTEGER NOT NULL,
|
||||
finish_label TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_modules_category ON modules(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_modules_active ON modules(active);
|
||||
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);
|
||||
@@ -0,0 +1,23 @@
|
||||
import initSqlJs from 'sql.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '..', 'data', 'configurateur.db');
|
||||
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
console.error('Database file not found. Run npm run db:init first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const SQL = await initSqlJs({
|
||||
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
|
||||
});
|
||||
const buffer = fs.readFileSync(dbPath);
|
||||
const db = new SQL.Database(buffer);
|
||||
const seedSql = fs.readFileSync(path.join(__dirname, 'seeds.sql'), 'utf8');
|
||||
db.exec(seedSql);
|
||||
fs.writeFileSync(dbPath, Buffer.from(db.export()));
|
||||
db.close();
|
||||
console.log('Seeds applied.');
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Données initiales V1 (SQLite)
|
||||
|
||||
INSERT OR REPLACE INTO settings (key, value) VALUES
|
||||
('kit_preparation_fee_chf', '350'),
|
||||
('panel_thickness_mm', '19'),
|
||||
('plinth_height_min_mm', '60'),
|
||||
('plinth_height_max_mm', '160'),
|
||||
('niche_clearance_multiplier', '2'),
|
||||
('min_clearance_mm', '20'),
|
||||
('sliding_doors_min_width_mm', '1200');
|
||||
|
||||
INSERT OR IGNORE INTO products (code, label, active) VALUES
|
||||
('SIGNATURE', 'Armoire SIGNATURE', 1);
|
||||
|
||||
INSERT OR IGNORE INTO block_types (code, label, base_price_chf, width_min_mm, width_max_mm) VALUES
|
||||
('penderie', 'Penderie', 550, 400, 1200),
|
||||
('rayonnage', 'Rayonnage', 550, 400, 1200),
|
||||
('mixte_vertical', 'Mixte vertical', 550, 400, 1200),
|
||||
('mixte_horizontal', 'Mixte horizontal', 550, 400, 1200);
|
||||
|
||||
INSERT INTO panel_formats (name, width_mm, height_mm, price_chf) VALUES
|
||||
('Stratifié standard', 2800, 2070, 85),
|
||||
('Stratifié couleur', 2800, 2070, 95),
|
||||
('Stratifié effet bois', 2800, 2070, 110),
|
||||
('Stratifié vieux bois', 2800, 2070, 120);
|
||||
|
||||
INSERT OR IGNORE INTO finishes (code, label, panel_format_id) VALUES
|
||||
('stratifie_decor', 'Stratifié décor', (SELECT id FROM panel_formats WHERE name = 'Stratifié standard' LIMIT 1)),
|
||||
('stratifie_couleur', 'Stratifié couleur', (SELECT id FROM panel_formats WHERE name = 'Stratifié couleur' LIMIT 1)),
|
||||
('stratifie_effet_bois', 'Stratifié effet bois', (SELECT id FROM panel_formats WHERE name = 'Stratifié effet bois' LIMIT 1)),
|
||||
('stratifie_vieux_bois', 'Stratifié vieux bois', (SELECT id FROM panel_formats WHERE name = 'Stratifié vieux bois' LIMIT 1));
|
||||
|
||||
INSERT OR IGNORE INTO service_options (code, label, price_chf, price_type) VALUES
|
||||
('prise_mesure', 'Prise de mesure', 95, 'per_hour'),
|
||||
('montage', 'Montage', 95, 'per_hour');
|
||||
|
||||
-- Modules standards V2
|
||||
INSERT OR IGNORE INTO modules (code, name, category, width_mm, height_mm, depth_mm, shelves_count, drawers_count, has_rod, customizable, base_price_chf) VALUES
|
||||
('penderie_simple', 'Penderie simple', 'penderie', 600, NULL, 580, 1, 0, 1, 1, 450),
|
||||
('penderie_double', 'Penderie double', 'penderie', 900, NULL, 580, 2, 0, 1, 1, 550),
|
||||
('penderie_large', 'Penderie large', 'penderie', 1200, NULL, 580, 2, 0, 1, 1, 650),
|
||||
('rayonnage_4', 'Rayonnage 4 étagères', 'rayonnage', 600, NULL, 580, 4, 0, 0, 1, 380),
|
||||
('rayonnage_5', 'Rayonnage 5 étagères', 'rayonnage', 600, NULL, 580, 5, 0, 0, 1, 420),
|
||||
('rayonnage_6', 'Rayonnage 6 étagères', 'rayonnage', 600, NULL, 580, 6, 0, 0, 1, 460),
|
||||
('tiroirs_3', 'Bloc 3 tiroirs', 'tiroirs', 600, 600, 580, 0, 3, 0, 0, 520),
|
||||
('tiroirs_4', 'Bloc 4 tiroirs', 'tiroirs', 600, 800, 580, 0, 4, 0, 0, 620),
|
||||
('tiroirs_5', 'Bloc 5 tiroirs', 'tiroirs', 600, 1000, 580, 0, 5, 0, 0, 720),
|
||||
('mixte_penderie_tiroirs', 'Penderie + tiroirs', 'mixte', 600, NULL, 580, 0, 2, 1, 1, 580),
|
||||
('mixte_rayonnage_tiroirs', 'Rayonnage + tiroirs', 'mixte', 600, NULL, 580, 3, 2, 0, 1, 550),
|
||||
('mixte_complet', 'Module complet', 'mixte', 900, NULL, 580, 2, 2, 1, 1, 720);
|
||||
|
||||
-- Quincaillerie Blum
|
||||
INSERT OR IGNORE INTO blum_hardware (code, name, type, variant, price_chf, specs_json) VALUES
|
||||
('legrabox_pure', 'LEGRABOX pure', 'drawer_system', 'legrabox', 85, '{"height_mm": 177, "load_kg": 70, "soft_close": true}'),
|
||||
('legrabox_free', 'LEGRABOX free', 'drawer_system', 'legrabox', 95, '{"height_mm": 177, "load_kg": 70, "soft_close": true, "tip_on": true}'),
|
||||
('tandembox_antaro', 'TANDEMBOX antaro', 'drawer_system', 'tandembox', 65, '{"height_mm": 83, "load_kg": 65, "soft_close": true}'),
|
||||
('tandembox_intivo', 'TANDEMBOX intivo', 'drawer_system', 'tandembox', 75, '{"height_mm": 83, "load_kg": 65, "soft_close": true}'),
|
||||
('movento_runner', 'MOVENTO coulisse', 'runner', 'movento', 45, '{"load_kg": 60, "soft_close": true}'),
|
||||
('clip_top_soft', 'CLIP top BLUMOTION', 'hinge', 'clip_top', 12, '{"opening_angle": 110, "soft_close": true}'),
|
||||
('clip_top_standard', 'CLIP top Standard', 'hinge', 'clip_top', 8, '{"opening_angle": 110, "soft_close": false}'),
|
||||
('aventos_hf', 'AVENTOS HF', 'lift_system', 'aventos', 180, '{"type": "bi-fold", "soft_close": true}'),
|
||||
('aventos_hl', 'AVENTOS HL', 'lift_system', 'aventos', 150, '{"type": "lift_up", "soft_close": true}');
|
||||
|
||||
-- Variantes poignées
|
||||
INSERT OR IGNORE INTO handle_variants (code, name, style, length_mm, price_chf) VALUES
|
||||
('barre_moderne', 'Barre moderne', 'moderne', 160, 25),
|
||||
('barre_longue', 'Barre longue', 'moderne', 320, 35),
|
||||
('bouton_rond', 'Bouton rond', 'classique', 35, 15),
|
||||
('coquille', 'Coquille encastrée', 'minimaliste', 100, 28),
|
||||
('profil_alu', 'Profil aluminium', 'minimaliste', NULL, 45);
|
||||
|
||||
-- Variantes charnières
|
||||
INSERT OR IGNORE INTO hinge_variants (code, name, type, brand, price_chf) VALUES
|
||||
('blum_vissee', 'Blum vissée', 'vissee', 'Blum', 12),
|
||||
('blum_plantee', 'Blum plantée (INSERTA)', 'plantee', 'Blum', 18);
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import dotenv from 'dotenv';
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import cors from 'cors';
|
||||
import { dbReady } from './db/index.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
dotenv.config({ path: path.join(__dirname, '.env') });
|
||||
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 { postPrice } from './routes/price.js';
|
||||
import { getOrders, postOrder } from './routes/orders.js';
|
||||
import {
|
||||
triggerFormexSync,
|
||||
streamFormexSync,
|
||||
getLogs,
|
||||
getStatus,
|
||||
getFormexMaterials,
|
||||
getFormexMaterial,
|
||||
createFormexMaterial,
|
||||
updateFormexMaterial,
|
||||
deleteFormexMaterial,
|
||||
getFormexCollections,
|
||||
getFormexCategories
|
||||
} from './routes/sync.js';
|
||||
import {
|
||||
uploadMiddleware,
|
||||
analyzeRoom,
|
||||
saveRoomConfig,
|
||||
getRoomConfig,
|
||||
checkApiStatus
|
||||
} from './routes/room.js';
|
||||
import {
|
||||
optimizeLayoutRoute,
|
||||
quickOptimizeRoute,
|
||||
suggestLayoutRoute
|
||||
} from './routes/layout.js';
|
||||
import {
|
||||
getModules,
|
||||
getModule,
|
||||
getModuleCategories,
|
||||
getHardware,
|
||||
getHardwareTypes,
|
||||
getHandles,
|
||||
getHinges
|
||||
} from './routes/modules.js';
|
||||
import {
|
||||
generatePlansRoute,
|
||||
generateCuttingListRoute,
|
||||
exportCuttingListText,
|
||||
exportPDFHtml
|
||||
} from './routes/plans.js';
|
||||
import {
|
||||
designTipsRoute,
|
||||
recommendMaterialsRoute,
|
||||
renderRoute
|
||||
} from './routes/ai.js';
|
||||
import { initScheduler } from './services/scheduler.js';
|
||||
|
||||
const clientDist = path.join(__dirname, '..', 'client', 'dist');
|
||||
const hasClientBuild = fs.existsSync(clientDist);
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(cors({ origin: true }));
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/api/products', getProducts);
|
||||
app.get('/api/block-types', getBlockTypes);
|
||||
app.get('/api/finishes', getFinishes);
|
||||
app.get('/api/service-options', getServiceOptions);
|
||||
app.get('/api/settings', getSettings);
|
||||
app.post('/api/price', postPrice);
|
||||
app.get('/api/orders', getOrders);
|
||||
app.post('/api/orders', postOrder);
|
||||
|
||||
// Sync routes (Formex crawler)
|
||||
app.post('/api/sync/formex', triggerFormexSync);
|
||||
app.get('/api/sync/formex/stream', streamFormexSync);
|
||||
app.get('/api/sync/logs', getLogs);
|
||||
app.get('/api/sync/status', getStatus);
|
||||
|
||||
// Formex materials CRUD
|
||||
app.get('/api/formex/materials', getFormexMaterials);
|
||||
app.get('/api/formex/materials/:id', getFormexMaterial);
|
||||
app.post('/api/formex/materials', createFormexMaterial);
|
||||
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);
|
||||
|
||||
// Room analysis routes (AI-powered)
|
||||
app.post('/api/room/analyze', uploadMiddleware, analyzeRoom);
|
||||
app.post('/api/room/save', saveRoomConfig);
|
||||
app.get('/api/room/config/:orderId', getRoomConfig);
|
||||
app.get('/api/room/status', checkApiStatus);
|
||||
|
||||
// Layout optimization routes (AI-powered)
|
||||
app.post('/api/layout/optimize', optimizeLayoutRoute);
|
||||
app.post('/api/layout/quick-optimize', quickOptimizeRoute);
|
||||
app.get('/api/layout/suggest', suggestLayoutRoute);
|
||||
|
||||
// Modules catalog routes
|
||||
app.get('/api/modules', getModules);
|
||||
app.get('/api/modules/categories', getModuleCategories);
|
||||
app.get('/api/modules/:id', getModule);
|
||||
|
||||
// Hardware routes
|
||||
app.get('/api/hardware', getHardware);
|
||||
app.get('/api/hardware/types', getHardwareTypes);
|
||||
app.get('/api/handles', getHandles);
|
||||
app.get('/api/hinges', getHinges);
|
||||
|
||||
// AI design assistant routes
|
||||
app.post('/api/ai/design-tips', designTipsRoute);
|
||||
app.post('/api/ai/recommend-materials', recommendMaterialsRoute);
|
||||
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/export-text', exportCuttingListText);
|
||||
app.post('/api/plans/export-pdf', exportPDFHtml);
|
||||
|
||||
if (hasClientBuild) {
|
||||
app.use(express.static(clientDist));
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(clientDist, 'index.html'));
|
||||
});
|
||||
} else {
|
||||
app.get('/', (req, res) => {
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>Configurateur</title></head>
|
||||
<body style="font-family:sans-serif;padding:2rem;">
|
||||
<h1>Application non construite</h1>
|
||||
<p>Depuis la racine du projet, exécutez :</p>
|
||||
<pre>npm run build</pre>
|
||||
<p>Puis rechargez cette page. Ou lancez le client en mode dev :</p>
|
||||
<pre>npm run client</pre>
|
||||
<p>et ouvrez <a href="http://localhost:5173">http://localhost:5173</a>.</p>
|
||||
</body></html>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
await dbReady;
|
||||
|
||||
// Initialize the scheduler for automated sync
|
||||
initScheduler();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`API configurateur armoires — http://localhost:${PORT}`);
|
||||
if (!hasClientBuild) {
|
||||
console.log('(Exécutez "npm run build" pour servir l\'app sur ce port)');
|
||||
}
|
||||
});
|
||||
Generated
+1623
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "configurateur-armoires-server",
|
||||
"version": "1.0.0",
|
||||
"description": "API Node/Express pour le configurateur d'armoires sur mesure",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node --watch index.js",
|
||||
"db:init": "node db/init.js",
|
||||
"db:seed": "node db/seed.js",
|
||||
"db:migrate": "node db/migrate.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cheerio": "^1.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"openai": "^4.28.0",
|
||||
"sql.js": "^1.11.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* AI Design Assistant routes
|
||||
* - POST /api/ai/design-tips → contextual design advice
|
||||
* - POST /api/ai/recommend-materials → material/color recommendations
|
||||
* - POST /api/ai/render → DALL-E 3 photorealistic render
|
||||
*/
|
||||
import { getDesignTip, recommendMaterials, generateRender } from '../services/designAdvisor.js';
|
||||
|
||||
/**
|
||||
* POST /api/ai/design-tips
|
||||
* Body: { config: { width, height, depth, columns, materialName, ... } }
|
||||
*/
|
||||
export async function designTipsRoute(req, res) {
|
||||
try {
|
||||
const { config } = req.body;
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Missing config in body' });
|
||||
}
|
||||
const result = await getDesignTip(config);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[AI design-tips]', err.message);
|
||||
if (err.message.includes('OPENAI_API_KEY')) {
|
||||
return res.status(503).json({ error: 'AI service not configured' });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to generate design tip' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/recommend-materials
|
||||
* Body: { config, finishes: [{ id, name, collection }], roomAnalysis? }
|
||||
*/
|
||||
export async function recommendMaterialsRoute(req, res) {
|
||||
try {
|
||||
const { config, finishes, roomAnalysis } = req.body;
|
||||
if (!config || !finishes) {
|
||||
return res.status(400).json({ error: 'Missing config or finishes in body' });
|
||||
}
|
||||
const result = await recommendMaterials(config, finishes, roomAnalysis);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[AI recommend-materials]', err.message);
|
||||
if (err.message.includes('OPENAI_API_KEY')) {
|
||||
return res.status(503).json({ error: 'AI service not configured' });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to generate recommendations' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/render
|
||||
* Body: { config, roomContext? }
|
||||
*/
|
||||
export async function renderRoute(req, res) {
|
||||
try {
|
||||
const { config, roomContext } = req.body;
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Missing config in body' });
|
||||
}
|
||||
const result = await generateRender(config, roomContext);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[AI render]', err.message);
|
||||
if (err.message.includes('OPENAI_API_KEY')) {
|
||||
return res.status(503).json({ error: 'AI service not configured' });
|
||||
}
|
||||
if (err.message.includes('billing') || err.message.includes('quota')) {
|
||||
return res.status(402).json({ error: 'AI quota exceeded' });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to generate render' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import pool from '../db/index.js';
|
||||
|
||||
export async function getBlockTypes(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, code, label, base_price_chf, width_min_mm, width_max_mm FROM block_types WHERE active = true ORDER BY id'
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import pool from '../db/index.js';
|
||||
|
||||
/**
|
||||
* GET /api/finishes
|
||||
* Returns active Formex materials as finishes for the configurator
|
||||
* Falls back to legacy finishes if no Formex materials exist
|
||||
*/
|
||||
export async function getFinishes(req, res) {
|
||||
try {
|
||||
// Check if we have active Formex materials
|
||||
const formexResult = await pool.query(
|
||||
`SELECT COUNT(*) as count FROM formex_materials WHERE active = 1`
|
||||
);
|
||||
const hasFormexMaterials = (formexResult.rows[0]?.count || 0) > 0;
|
||||
|
||||
if (hasFormexMaterials) {
|
||||
// Return Formex materials as finishes
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
formex_code as code,
|
||||
name as label,
|
||||
name,
|
||||
collection,
|
||||
category,
|
||||
width_mm,
|
||||
height_mm,
|
||||
thickness_mm,
|
||||
image_url,
|
||||
formex_url,
|
||||
price_chf,
|
||||
dominant_color as hexColor,
|
||||
'formex' as source
|
||||
FROM formex_materials
|
||||
WHERE active = 1
|
||||
ORDER BY collection, name
|
||||
`);
|
||||
|
||||
// For each material, get its dimension variants
|
||||
for (const material of rows) {
|
||||
const dims = await pool.query(
|
||||
`SELECT id, width_mm, height_mm, thickness_mm, is_default
|
||||
FROM formex_dimensions WHERE material_id = ?
|
||||
ORDER BY is_default DESC, width_mm`,
|
||||
[material.id]
|
||||
);
|
||||
material.dimensions = dims.rows;
|
||||
}
|
||||
|
||||
res.json(rows);
|
||||
} else {
|
||||
// Fallback to legacy finishes
|
||||
const { rows } = await pool.query(`
|
||||
SELECT f.id, f.code, f.label, f.panel_format_id,
|
||||
pf.width_mm, pf.height_mm, pf.price_chf,
|
||||
'legacy' as source
|
||||
FROM finishes f
|
||||
JOIN panel_formats pf ON pf.id = f.panel_format_id
|
||||
WHERE f.active = 1
|
||||
ORDER BY f.id
|
||||
`);
|
||||
res.json(rows);
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Layout optimization routes
|
||||
*/
|
||||
import { optimizeLayout, quickOptimize, suggestInitialLayout } from '../services/layoutOptimizer.js';
|
||||
import pool from '../db/index.js';
|
||||
|
||||
/**
|
||||
* POST /api/layout/optimize
|
||||
* Optimize module layout using AI
|
||||
*/
|
||||
export async function optimizeLayoutRoute(req, res) {
|
||||
try {
|
||||
const { config, lockedModuleIds = [] } = req.body;
|
||||
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
|
||||
// Fetch available modules from catalog
|
||||
const { rows: availableModules } = await pool.query(
|
||||
'SELECT * FROM modules WHERE active = 1 ORDER BY category, width_mm'
|
||||
);
|
||||
|
||||
if (availableModules.length === 0) {
|
||||
return res.status(400).json({ error: 'Aucun module disponible dans le catalogue' });
|
||||
}
|
||||
|
||||
const result = await optimizeLayout(config, availableModules, lockedModuleIds);
|
||||
res.json(result);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Layout optimization error:', error);
|
||||
res.status(500).json({ error: error.message || 'Erreur d\'optimisation' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/layout/quick-optimize
|
||||
* Quick local optimization without AI
|
||||
*/
|
||||
export async function quickOptimizeRoute(req, res) {
|
||||
try {
|
||||
const { config, lockedModuleIds = [] } = req.body;
|
||||
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
|
||||
const result = quickOptimize(config, lockedModuleIds);
|
||||
res.json(result);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Quick optimization error:', error);
|
||||
res.status(500).json({ error: error.message || 'Erreur d\'optimisation' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/layout/suggest?width=2400
|
||||
* Suggest initial layout for given width
|
||||
*/
|
||||
export async function suggestLayoutRoute(req, res) {
|
||||
try {
|
||||
const totalWidth = parseInt(req.query.width) || 2400;
|
||||
|
||||
// Fetch available modules
|
||||
const { rows: availableModules } = await pool.query(
|
||||
'SELECT * FROM modules WHERE active = 1 ORDER BY category, width_mm'
|
||||
);
|
||||
|
||||
if (availableModules.length === 0) {
|
||||
return res.status(400).json({ error: 'Aucun module disponible' });
|
||||
}
|
||||
|
||||
const suggestions = suggestInitialLayout(totalWidth, availableModules);
|
||||
res.json({ success: true, modules: suggestions, totalWidth });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Layout suggestion error:', error);
|
||||
res.status(500).json({ error: error.message || 'Erreur de suggestion' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Modules catalog and hardware routes
|
||||
*/
|
||||
import pool from '../db/index.js';
|
||||
|
||||
/**
|
||||
* GET /api/modules
|
||||
* Get all modules from catalog
|
||||
*/
|
||||
export async function getModules(req, res) {
|
||||
try {
|
||||
const { category, active } = req.query;
|
||||
|
||||
let sql = 'SELECT * FROM modules WHERE 1=1';
|
||||
const params = [];
|
||||
|
||||
if (category) {
|
||||
sql += ' AND category = ?';
|
||||
params.push(category);
|
||||
}
|
||||
|
||||
if (active !== undefined) {
|
||||
sql += ' AND active = ?';
|
||||
params.push(active === 'true' || active === '1' ? 1 : 0);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY category, width_mm, name';
|
||||
|
||||
const { rows } = await pool.query(sql, params);
|
||||
res.json(rows);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get modules error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/modules/:id
|
||||
* Get single module by ID
|
||||
*/
|
||||
export async function getModule(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { rows } = await pool.query('SELECT * FROM modules WHERE id = ?', [id]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Module non trouvé' });
|
||||
}
|
||||
|
||||
res.json(rows[0]);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get module error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/modules/categories
|
||||
* Get distinct module categories
|
||||
*/
|
||||
export async function getModuleCategories(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT DISTINCT category FROM modules WHERE active = 1 ORDER BY category'
|
||||
);
|
||||
res.json(rows.map(r => r.category));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get categories error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/hardware
|
||||
* Get all Blum hardware
|
||||
*/
|
||||
export async function getHardware(req, res) {
|
||||
try {
|
||||
const { type } = req.query;
|
||||
|
||||
let sql = 'SELECT * FROM blum_hardware WHERE active = 1';
|
||||
const params = [];
|
||||
|
||||
if (type) {
|
||||
sql += ' AND type = ?';
|
||||
params.push(type);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY type, name';
|
||||
|
||||
const { rows } = await pool.query(sql, params);
|
||||
|
||||
// Parse specs_json for each item
|
||||
const hardware = rows.map(h => ({
|
||||
...h,
|
||||
specs: h.specs_json ? JSON.parse(h.specs_json) : null
|
||||
}));
|
||||
|
||||
res.json(hardware);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get hardware error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/hardware/types
|
||||
* Get distinct hardware types
|
||||
*/
|
||||
export async function getHardwareTypes(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT DISTINCT type FROM blum_hardware WHERE active = 1 ORDER BY type'
|
||||
);
|
||||
res.json(rows.map(r => r.type));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get hardware types error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/handles
|
||||
* Get all handle variants
|
||||
*/
|
||||
export async function getHandles(req, res) {
|
||||
try {
|
||||
const { style } = req.query;
|
||||
|
||||
let sql = 'SELECT * FROM handle_variants WHERE active = 1';
|
||||
const params = [];
|
||||
|
||||
if (style) {
|
||||
sql += ' AND style = ?';
|
||||
params.push(style);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY style, name';
|
||||
|
||||
const { rows } = await pool.query(sql, params);
|
||||
res.json(rows);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get handles error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/hinges
|
||||
* Get all hinge variants
|
||||
*/
|
||||
export async function getHinges(req, res) {
|
||||
try {
|
||||
const { type } = req.query;
|
||||
|
||||
let sql = 'SELECT * FROM hinge_variants WHERE active = 1';
|
||||
const params = [];
|
||||
|
||||
if (type) {
|
||||
sql += ' AND type = ?';
|
||||
params.push(type);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY type, name';
|
||||
|
||||
const { rows } = await pool.query(sql, params);
|
||||
res.json(rows);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get hinges error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import pool, { run } from '../db/index.js';
|
||||
|
||||
export async function getOrders(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, config, total_chf, email, name, phone, notes, created_at FROM orders ORDER BY created_at DESC'
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function postOrder(req, res) {
|
||||
try {
|
||||
const { config, totalChf, email, name, phone, notes } = req.body || {};
|
||||
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]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Plans and cutting list routes
|
||||
*/
|
||||
import { generateAllPlans, generatePDFHtml } from '../services/planGenerator.js';
|
||||
import { generateCuttingList, formatCuttingListForExport } from '../services/cuttingEngine.js';
|
||||
|
||||
/**
|
||||
* POST /api/plans/generate
|
||||
* Generate all 2D plans for a configuration
|
||||
*/
|
||||
export async function generatePlansRoute(req, res) {
|
||||
try {
|
||||
const { config, modules = [], colors = {} } = req.body;
|
||||
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
|
||||
const plans = generateAllPlans(config, modules, colors);
|
||||
res.json({ success: true, plans });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Plan generation error:', error);
|
||||
res.status(500).json({ error: error.message || 'Erreur de génération des plans' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/plans/cutting-list
|
||||
* Generate cutting list for a configuration
|
||||
*/
|
||||
export async function generateCuttingListRoute(req, res) {
|
||||
try {
|
||||
const { config, modules = [], colors = {} } = req.body;
|
||||
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
|
||||
const cuttingList = generateCuttingList(config, colors, modules);
|
||||
res.json({ success: true, cuttingList });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Cutting list error:', error);
|
||||
res.status(500).json({ error: error.message || 'Erreur de génération de la liste de débitage' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/plans/export-text
|
||||
* Export cutting list as text file
|
||||
*/
|
||||
export async function exportCuttingListText(req, res) {
|
||||
try {
|
||||
const { config, modules = [], colors = {} } = req.body;
|
||||
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
|
||||
const cuttingList = generateCuttingList(config, colors, modules);
|
||||
const textContent = formatCuttingListForExport(cuttingList);
|
||||
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="liste-debitage.txt"');
|
||||
res.send(textContent);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Export text error:', error);
|
||||
res.status(500).json({ error: error.message || 'Erreur d\'export' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/plans/export-pdf
|
||||
* Export plans and cutting list as HTML (for PDF conversion)
|
||||
*/
|
||||
export async function exportPDFHtml(req, res) {
|
||||
try {
|
||||
const { config, modules = [], colors = {} } = req.body;
|
||||
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
|
||||
const cuttingList = generateCuttingList(config, colors, modules);
|
||||
const html = generatePDFHtml(config, modules, cuttingList);
|
||||
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(html);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Export PDF error:', error);
|
||||
res.status(500).json({ error: error.message || 'Erreur d\'export PDF' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import pool from '../db/index.js';
|
||||
import { calculatePrice } from '../services/priceEngine.js';
|
||||
|
||||
export async function postPrice(req, res) {
|
||||
try {
|
||||
const config = req.body || {};
|
||||
|
||||
// Fetch all required data
|
||||
const [
|
||||
products,
|
||||
blockTypes,
|
||||
legacyFinishes,
|
||||
formexMaterials,
|
||||
serviceOptions,
|
||||
settingsRows,
|
||||
modulesData,
|
||||
handlesData,
|
||||
hingesData,
|
||||
blumData
|
||||
] = await Promise.all([
|
||||
pool.query('SELECT id, code, label FROM products WHERE active = true'),
|
||||
pool.query('SELECT id, code, label, base_price_chf, width_min_mm, width_max_mm FROM block_types WHERE active = true'),
|
||||
pool.query(`
|
||||
SELECT f.id, f.code, f.label, pf.width_mm, pf.height_mm, pf.price_chf
|
||||
FROM finishes f JOIN panel_formats pf ON pf.id = f.panel_format_id WHERE f.active = true
|
||||
`),
|
||||
pool.query(`
|
||||
SELECT id, formex_code as code, name as label, name, collection, category,
|
||||
width_mm, height_mm, thickness_mm, image_url, price_chf,
|
||||
dominant_color as hexColor
|
||||
FROM formex_materials WHERE active = 1
|
||||
`),
|
||||
pool.query('SELECT id, code, label, price_chf, price_type FROM service_options WHERE active = true'),
|
||||
pool.query('SELECT key, value FROM settings'),
|
||||
pool.query('SELECT id, code, name, category, width_mm, shelves_count, drawers_count, has_rod, base_price_chf FROM modules WHERE active = 1'),
|
||||
pool.query('SELECT id, code, name, style, price_chf FROM handle_variants WHERE active = 1'),
|
||||
pool.query('SELECT id, code, name, type, price_chf FROM hinge_variants WHERE active = 1'),
|
||||
pool.query('SELECT id, code, name, type, variant, price_chf, specs_json FROM blum_hardware WHERE active = 1')
|
||||
]);
|
||||
|
||||
const settings = settingsRows.rows.map((r) => ({ key: r.key, value: r.value }));
|
||||
|
||||
// Use Formex materials if available, otherwise fall back to legacy finishes
|
||||
let finishes = legacyFinishes.rows;
|
||||
if (formexMaterials.rows.length > 0) {
|
||||
// Fetch dimension variants for Formex materials
|
||||
const materialsWithDims = await Promise.all(
|
||||
formexMaterials.rows.map(async (mat) => {
|
||||
const dims = await pool.query(
|
||||
'SELECT id, width_mm, height_mm, thickness_mm, is_default FROM formex_dimensions WHERE material_id = ?',
|
||||
[mat.id]
|
||||
);
|
||||
return {
|
||||
...mat,
|
||||
dimensions: dims.rows
|
||||
};
|
||||
})
|
||||
);
|
||||
finishes = materialsWithDims;
|
||||
}
|
||||
|
||||
const data = {
|
||||
settings,
|
||||
blockTypes: blockTypes.rows,
|
||||
finishes,
|
||||
serviceOptions: serviceOptions.rows,
|
||||
modules: modulesData.rows,
|
||||
handles: handlesData.rows,
|
||||
hinges: hingesData.rows,
|
||||
blumHardware: blumData.rows
|
||||
};
|
||||
|
||||
const result = calculatePrice(config, data);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('Price calculation error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import pool from '../db/index.js';
|
||||
|
||||
export async function getProducts(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, code, label, active FROM products WHERE active = true ORDER BY id'
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Room analysis API routes
|
||||
* Handles image upload and OpenAI Vision analysis
|
||||
*/
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { analyzeRoomPhoto, analysisTo3DConfig } from '../services/roomAnalyzer.js';
|
||||
import pool, { run } from '../db/index.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Configure multer for image uploads
|
||||
const storage = multer.memoryStorage();
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024 // 10MB max
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Type de fichier non supporté. Utilisez JPEG, PNG ou WebP.'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Directory for storing uploaded room photos
|
||||
const UPLOADS_DIR = path.join(__dirname, '..', '..', 'client', 'public', 'rooms');
|
||||
if (!fs.existsSync(UPLOADS_DIR)) {
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware for single image upload
|
||||
*/
|
||||
export const uploadMiddleware = upload.single('image');
|
||||
|
||||
/**
|
||||
* POST /api/room/analyze
|
||||
* Upload a room photo and analyze it with GPT-4 Vision
|
||||
*/
|
||||
export async function analyzeRoom(req, res) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'Aucune image fournie' });
|
||||
}
|
||||
|
||||
// Convert buffer to base64
|
||||
const imageBase64 = req.file.buffer.toString('base64');
|
||||
const mimeType = req.file.mimetype;
|
||||
|
||||
// Analyze with OpenAI
|
||||
console.log('Analyzing room photo with GPT-4 Vision...');
|
||||
const result = await analyzeRoomPhoto(imageBase64, mimeType);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: 'Échec de l\'analyse' });
|
||||
}
|
||||
|
||||
// Save the image for future reference
|
||||
const filename = `room_${Date.now()}.${mimeType.split('/')[1]}`;
|
||||
const filepath = path.join(UPLOADS_DIR, filename);
|
||||
fs.writeFileSync(filepath, req.file.buffer);
|
||||
|
||||
// Convert to 3D config
|
||||
const room3DConfig = analysisTo3DConfig(result.analysis);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
imagePath: `/rooms/${filename}`,
|
||||
analysis: result.analysis,
|
||||
room3DConfig,
|
||||
tokensUsed: result.tokensUsed
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Room analysis error:', error);
|
||||
res.status(500).json({
|
||||
error: error.message || 'Erreur lors de l\'analyse de la pièce'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/room/save
|
||||
* Save room configuration to an order
|
||||
*/
|
||||
export async function saveRoomConfig(req, res) {
|
||||
try {
|
||||
const { orderId, imagePath, analysis, room3DConfig } = req.body;
|
||||
|
||||
if (!analysis) {
|
||||
return res.status(400).json({ error: 'Configuration de pièce requise' });
|
||||
}
|
||||
|
||||
const analysisJson = JSON.stringify({ analysis, room3DConfig });
|
||||
|
||||
if (orderId) {
|
||||
// Update existing order
|
||||
await run(
|
||||
`INSERT INTO room_configs (order_id, image_path, analysis_json)
|
||||
VALUES (?, ?, ?)`,
|
||||
[orderId, imagePath || null, analysisJson]
|
||||
);
|
||||
}
|
||||
|
||||
// Return the config ID for reference
|
||||
const { rows } = await pool.query(
|
||||
'SELECT last_insert_rowid() as id'
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
roomConfigId: rows[0]?.id || null
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Save room config error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/room/config/:orderId
|
||||
* Get room configuration for an order
|
||||
*/
|
||||
export async function getRoomConfig(req, res) {
|
||||
try {
|
||||
const { orderId } = req.params;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM room_configs WHERE order_id = ? ORDER BY created_at DESC LIMIT 1',
|
||||
[orderId]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Configuration non trouvée' });
|
||||
}
|
||||
|
||||
const config = rows[0];
|
||||
const parsed = JSON.parse(config.analysis_json || '{}');
|
||||
|
||||
res.json({
|
||||
id: config.id,
|
||||
orderId: config.order_id,
|
||||
imagePath: config.image_path,
|
||||
analysis: parsed.analysis,
|
||||
room3DConfig: parsed.room3DConfig,
|
||||
createdAt: config.created_at
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get room config error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OpenAI API is configured
|
||||
*/
|
||||
export function checkApiStatus(req, res) {
|
||||
const isConfigured = !!process.env.OPENAI_API_KEY;
|
||||
res.json({
|
||||
openaiConfigured: isConfigured,
|
||||
features: {
|
||||
roomAnalysis: isConfigured
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import pool from '../db/index.js';
|
||||
|
||||
export async function getServiceOptions(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, code, label, price_chf, price_type FROM service_options WHERE active = true ORDER BY id'
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import pool from '../db/index.js';
|
||||
|
||||
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;
|
||||
}
|
||||
res.json(obj);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Sync routes for Formex crawler
|
||||
*/
|
||||
import { sync, getSyncLogs, getMaterials } from '../services/formexCrawler.js';
|
||||
import { getSchedulerStatus } from '../services/scheduler.js';
|
||||
import pool, { run } from '../db/index.js';
|
||||
|
||||
/**
|
||||
* POST /api/sync/formex
|
||||
* Trigger a manual Formex sync (non-streaming)
|
||||
*/
|
||||
export async function triggerFormexSync(req, res) {
|
||||
try {
|
||||
// Run sync (this may take a while)
|
||||
const result = await sync();
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Synchronisation terminée: ${result.itemsCount} produits`,
|
||||
itemsCount: result.itemsCount,
|
||||
duration: result.duration
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
itemsCount: result.itemsCount
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sync/formex/stream
|
||||
* SSE endpoint for real-time sync progress
|
||||
*/
|
||||
export async function streamFormexSync(req, res) {
|
||||
// Set SSE headers
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
|
||||
res.flushHeaders();
|
||||
|
||||
// Emitter function that sends SSE events
|
||||
const emit = (data) => {
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
// Handle client disconnect
|
||||
req.on('close', () => {
|
||||
// Client disconnected, cleanup if needed
|
||||
});
|
||||
|
||||
try {
|
||||
// Run sync with emitter
|
||||
await sync(emit);
|
||||
} catch (err) {
|
||||
emit({ type: 'error', error: err.message });
|
||||
}
|
||||
|
||||
// Close connection
|
||||
res.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sync/logs
|
||||
* Get sync history
|
||||
*/
|
||||
export async function getLogs(req, res) {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit || '20', 10);
|
||||
const logs = await getSyncLogs(limit);
|
||||
res.json(logs);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sync/status
|
||||
* Get scheduler status
|
||||
*/
|
||||
export function getStatus(req, res) {
|
||||
const status = getSchedulerStatus();
|
||||
res.json(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/formex/materials
|
||||
* Get Formex materials with filtering and pagination
|
||||
*/
|
||||
export async function getFormexMaterials(req, res) {
|
||||
try {
|
||||
const { search, collection, category, active, 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 ?)');
|
||||
params.push(`%${search}%`, `%${search}%`);
|
||||
}
|
||||
|
||||
if (collection) {
|
||||
whereClauses.push('collection = ?');
|
||||
params.push(collection);
|
||||
}
|
||||
|
||||
if (category) {
|
||||
whereClauses.push('category = ?');
|
||||
params.push(category);
|
||||
}
|
||||
|
||||
// For configurator: only active materials
|
||||
if (active === '1' || active === 'true') {
|
||||
whereClauses.push('active = 1');
|
||||
}
|
||||
|
||||
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}`,
|
||||
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 ?`,
|
||||
[...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/formex/materials/:id
|
||||
* Get a single material by ID with dimensions and variants
|
||||
*/
|
||||
export async function getFormexMaterial(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Get main material
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM formex_materials WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Matériau non trouvé' });
|
||||
}
|
||||
|
||||
const material = rows[0];
|
||||
|
||||
// Get dimension variants
|
||||
const dimensions = await pool.query(
|
||||
'SELECT id, width_mm, height_mm, thickness_mm, is_default FROM formex_dimensions WHERE material_id = ? ORDER BY is_default DESC, width_mm',
|
||||
[id]
|
||||
);
|
||||
material.dimensions = dimensions.rows;
|
||||
|
||||
// Get color/decor variants
|
||||
const variants = await pool.query(
|
||||
'SELECT id, variant_code, variant_name, variant_url, image_url FROM formex_variants WHERE material_id = ? ORDER BY variant_name',
|
||||
[id]
|
||||
);
|
||||
material.variants = variants.rows;
|
||||
|
||||
res.json(material);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/formex/materials
|
||||
* Create a new material
|
||||
*/
|
||||
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;
|
||||
|
||||
// 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]
|
||||
);
|
||||
if (existing.rows.length > 0) {
|
||||
return res.status(409).json({ error: 'Un matériau avec ce code existe déjà' });
|
||||
}
|
||||
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[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]
|
||||
);
|
||||
|
||||
// Get the created material
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM formex_materials WHERE formex_code = ?',
|
||||
[formex_code]
|
||||
);
|
||||
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/formex/materials/:id
|
||||
* Update a material
|
||||
*/
|
||||
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;
|
||||
|
||||
// Check if material exists
|
||||
const existing = await pool.query(
|
||||
'SELECT * FROM formex_materials WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
if (existing.rows.length === 0) {
|
||||
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 != ?',
|
||||
[formex_code, id]
|
||||
);
|
||||
if (duplicate.rows.length > 0) {
|
||||
return res.status(409).json({ error: 'Un matériau avec ce code existe déjà' });
|
||||
}
|
||||
}
|
||||
|
||||
await run(
|
||||
`UPDATE formex_materials
|
||||
SET formex_code = ?, name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
|
||||
image_url = ?, formex_url = ?, price_chf = ?, 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]
|
||||
);
|
||||
|
||||
// Get the updated material
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM formex_materials WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/formex/materials/:id
|
||||
* Delete a material
|
||||
*/
|
||||
export async function deleteFormexMaterial(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if material exists
|
||||
const existing = await pool.query(
|
||||
'SELECT * FROM formex_materials WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
if (existing.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Matériau non trouvé' });
|
||||
}
|
||||
|
||||
await run('DELETE FROM formex_materials WHERE id = ?', [id]);
|
||||
|
||||
res.json({ success: true, message: 'Matériau supprimé' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/formex/collections
|
||||
* Get distinct collection names for filter dropdown
|
||||
*/
|
||||
export async function getFormexCollections(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT DISTINCT collection FROM formex_materials ORDER BY collection'
|
||||
);
|
||||
res.json(rows.map(r => r.collection));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/formex/categories
|
||||
* Get distinct category names for filter dropdown
|
||||
*/
|
||||
export async function getFormexCategories(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT DISTINCT category FROM formex_materials WHERE category IS NOT NULL ORDER BY category'
|
||||
);
|
||||
res.json(rows.map(r => r.category));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Cutting Engine - Generate cutting list (liste de débitage) for wardrobe production
|
||||
* Calculates all panel dimensions based on configuration and color assignments
|
||||
*/
|
||||
|
||||
// Panel thickness in mm
|
||||
const PANEL_THICKNESS = 19;
|
||||
const BACK_PANEL_THICKNESS = 8;
|
||||
|
||||
// Edge banding settings
|
||||
const EDGE_BANDING = {
|
||||
visible: 2, // 2mm visible edge banding
|
||||
hidden: 0.5 // 0.5mm thin edge banding
|
||||
};
|
||||
|
||||
/**
|
||||
* Element dimensions calculator
|
||||
* Returns the dimensions for each element type based on wardrobe config
|
||||
*/
|
||||
function getElementDimensions(elementType, config, modules = []) {
|
||||
const { widthMm, heightMm, depthMm, plinthHeightMm, installationType } = config;
|
||||
const bodyHeight = heightMm - plinthHeightMm;
|
||||
const innerWidth = widthMm - (PANEL_THICKNESS * 2);
|
||||
const innerDepth = depthMm - PANEL_THICKNESS; // Back panel space
|
||||
|
||||
const isNiche = installationType === 'niche';
|
||||
|
||||
switch (elementType) {
|
||||
case 'plintheBas':
|
||||
return {
|
||||
width: widthMm,
|
||||
height: plinthHeightMm,
|
||||
depth: depthMm,
|
||||
quantity: 1,
|
||||
description: 'Plinthe de base'
|
||||
};
|
||||
|
||||
case 'bandeauHaut':
|
||||
return {
|
||||
width: widthMm,
|
||||
height: PANEL_THICKNESS,
|
||||
depth: depthMm,
|
||||
quantity: 1,
|
||||
description: 'Bandeau supérieur'
|
||||
};
|
||||
|
||||
case 'dessus':
|
||||
return {
|
||||
width: innerWidth,
|
||||
height: PANEL_THICKNESS,
|
||||
depth: innerDepth,
|
||||
quantity: 1,
|
||||
description: 'Panneau du dessus'
|
||||
};
|
||||
|
||||
case 'fond':
|
||||
return {
|
||||
width: innerWidth,
|
||||
height: PANEL_THICKNESS,
|
||||
depth: innerDepth,
|
||||
quantity: 1,
|
||||
description: 'Panneau du fond (bas)'
|
||||
};
|
||||
|
||||
case 'cotes':
|
||||
return {
|
||||
width: PANEL_THICKNESS,
|
||||
height: bodyHeight,
|
||||
depth: depthMm,
|
||||
quantity: 2,
|
||||
description: 'Côtés extérieurs (gauche et droit)'
|
||||
};
|
||||
|
||||
case 'cotesDoublage':
|
||||
// Only for niche installation
|
||||
if (!isNiche) return null;
|
||||
return {
|
||||
width: PANEL_THICKNESS / 2,
|
||||
height: bodyHeight,
|
||||
depth: depthMm,
|
||||
quantity: 2,
|
||||
description: 'Côtés de doublage (niche)'
|
||||
};
|
||||
|
||||
case 'fileur':
|
||||
// Only for niche installation
|
||||
if (!isNiche) return null;
|
||||
return {
|
||||
width: PANEL_THICKNESS / 2,
|
||||
height: bodyHeight,
|
||||
depth: depthMm,
|
||||
quantity: 2,
|
||||
description: 'Fileurs (bouche-trou niche)'
|
||||
};
|
||||
|
||||
case 'dos':
|
||||
return {
|
||||
width: innerWidth,
|
||||
height: bodyHeight - (PANEL_THICKNESS * 2),
|
||||
depth: BACK_PANEL_THICKNESS,
|
||||
quantity: 1,
|
||||
description: 'Dos (panneau arrière)'
|
||||
};
|
||||
|
||||
case 'portes':
|
||||
const doorCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
|
||||
const isSliding = config.doorType === 'sliding';
|
||||
|
||||
if (isSliding) {
|
||||
const panelCount = config.slidingPanelsCount || 2;
|
||||
const panelWidth = Math.ceil(widthMm / panelCount) + 50; // overlap
|
||||
return {
|
||||
width: panelWidth,
|
||||
height: bodyHeight - 20, // clearance
|
||||
depth: PANEL_THICKNESS,
|
||||
quantity: panelCount,
|
||||
description: `Portes coulissantes (${panelCount} vantaux)`
|
||||
};
|
||||
} else {
|
||||
// Hinged doors - one per module/block
|
||||
return {
|
||||
width: Math.floor(widthMm / doorCount) - 4, // 2mm gap each side
|
||||
height: bodyHeight - 4,
|
||||
depth: PANEL_THICKNESS,
|
||||
quantity: doorCount,
|
||||
description: `Portes battantes (${doorCount} portes)`
|
||||
};
|
||||
}
|
||||
|
||||
case 'panneauxInterieurs':
|
||||
// Vertical separators between modules
|
||||
const separatorCount = modules.length > 1
|
||||
? modules.length - 1
|
||||
: (config.blocks?.length || 1) - 1;
|
||||
|
||||
if (separatorCount <= 0) return null;
|
||||
|
||||
return {
|
||||
width: PANEL_THICKNESS,
|
||||
height: bodyHeight - (PANEL_THICKNESS * 2),
|
||||
depth: innerDepth,
|
||||
quantity: separatorCount,
|
||||
description: `Séparateurs verticaux (${separatorCount})`
|
||||
};
|
||||
|
||||
case 'etageres':
|
||||
// Count shelves from modules
|
||||
const totalShelves = modules.reduce((sum, m) => sum + (m.shelves || 0), 0);
|
||||
if (totalShelves <= 0) return null;
|
||||
|
||||
// Average shelf width based on modules
|
||||
const avgModuleWidth = modules.length > 0
|
||||
? modules.reduce((sum, m) => sum + (m.widthMm || 0), 0) / modules.length
|
||||
: innerWidth;
|
||||
|
||||
return {
|
||||
width: avgModuleWidth - (PANEL_THICKNESS * 2),
|
||||
height: PANEL_THICKNESS,
|
||||
depth: innerDepth - 20, // setback from front
|
||||
quantity: totalShelves,
|
||||
description: `Étagères (${totalShelves})`
|
||||
};
|
||||
|
||||
case 'champs':
|
||||
// Edge banding - calculated as linear meters
|
||||
// Include all visible edges
|
||||
const perimeter = (widthMm * 2) + (heightMm * 4) + (bodyHeight * 4);
|
||||
return {
|
||||
width: EDGE_BANDING.visible,
|
||||
height: perimeter,
|
||||
depth: 0,
|
||||
quantity: 1,
|
||||
unit: 'ml',
|
||||
description: 'Chant plaqué visible'
|
||||
};
|
||||
|
||||
case 'caisse':
|
||||
// The carcass/body - this is the assembled unit, not a cut piece
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get edge banding requirements for an element
|
||||
*/
|
||||
function getEdgeBanding(elementType) {
|
||||
const fullEdges = ['portes', 'cotes', 'dessus', 'plintheBas', 'bandeauHaut'];
|
||||
const partialEdges = ['etageres', 'panneauxInterieurs', 'fond'];
|
||||
|
||||
if (fullEdges.includes(elementType)) {
|
||||
return { type: 'visible', sides: 4 };
|
||||
} else if (partialEdges.includes(elementType)) {
|
||||
return { type: 'visible', sides: 1 }; // front edge only
|
||||
} else if (elementType === 'dos') {
|
||||
return { type: 'none', sides: 0 };
|
||||
}
|
||||
return { type: 'hidden', sides: 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate complete cutting list for a wardrobe configuration
|
||||
* @param {Object} config - Wardrobe configuration
|
||||
* @param {Object} colors - Color/finish assignments per element
|
||||
* @param {Array} modules - Module configurations (optional)
|
||||
* @returns {Object} Cutting list with pieces grouped by finish
|
||||
*/
|
||||
export function generateCuttingList(config, colors = {}, modules = []) {
|
||||
const pieces = [];
|
||||
|
||||
// All element types to process
|
||||
const elementTypes = [
|
||||
'plintheBas',
|
||||
'bandeauHaut',
|
||||
'dessus',
|
||||
'fond',
|
||||
'cotes',
|
||||
'cotesDoublage',
|
||||
'fileur',
|
||||
'dos',
|
||||
'portes',
|
||||
'panneauxInterieurs',
|
||||
'etageres'
|
||||
];
|
||||
|
||||
for (const elementType of elementTypes) {
|
||||
const dimensions = getElementDimensions(elementType, config, modules);
|
||||
|
||||
if (!dimensions) continue;
|
||||
|
||||
const finish = colors[elementType] || { finishId: null, label: 'Non défini' };
|
||||
const edgeBanding = getEdgeBanding(elementType);
|
||||
|
||||
pieces.push({
|
||||
id: `piece-${elementType}`,
|
||||
elementType,
|
||||
description: dimensions.description,
|
||||
finishId: finish.finishId,
|
||||
finishLabel: finish.label,
|
||||
widthMm: Math.round(dimensions.width),
|
||||
heightMm: Math.round(dimensions.height),
|
||||
thicknessMm: dimensions.depth <= PANEL_THICKNESS ? dimensions.depth : PANEL_THICKNESS,
|
||||
quantity: dimensions.quantity,
|
||||
unit: dimensions.unit || 'pcs',
|
||||
edgeBanding: edgeBanding,
|
||||
surfaceM2: calculateSurface(dimensions.width, dimensions.height, dimensions.quantity)
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate totals by finish
|
||||
const byFinish = {};
|
||||
for (const piece of pieces) {
|
||||
const key = piece.finishLabel || 'Non défini';
|
||||
if (!byFinish[key]) {
|
||||
byFinish[key] = {
|
||||
finishId: piece.finishId,
|
||||
finishLabel: key,
|
||||
pieces: [],
|
||||
totalSurfaceM2: 0,
|
||||
pieceCount: 0
|
||||
};
|
||||
}
|
||||
byFinish[key].pieces.push(piece);
|
||||
byFinish[key].totalSurfaceM2 += piece.surfaceM2;
|
||||
byFinish[key].pieceCount += piece.quantity;
|
||||
}
|
||||
|
||||
// Calculate edge banding totals
|
||||
const edgeBandingMl = pieces.reduce((total, piece) => {
|
||||
if (piece.edgeBanding.type !== 'none') {
|
||||
const perimeter = (piece.widthMm + piece.heightMm) * 2 * piece.quantity / 1000; // convert to meters
|
||||
return total + (perimeter * piece.edgeBanding.sides / 4);
|
||||
}
|
||||
return total;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
pieces,
|
||||
byFinish: Object.values(byFinish),
|
||||
summary: {
|
||||
totalPieces: pieces.reduce((sum, p) => sum + p.quantity, 0),
|
||||
totalSurfaceM2: pieces.reduce((sum, p) => sum + p.surfaceM2, 0),
|
||||
edgeBandingMl: Math.round(edgeBandingMl * 10) / 10,
|
||||
finishCount: Object.keys(byFinish).length
|
||||
},
|
||||
config: {
|
||||
widthMm: config.widthMm,
|
||||
heightMm: config.heightMm,
|
||||
depthMm: config.depthMm,
|
||||
installationType: config.installationType,
|
||||
doorType: config.doorType
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate surface area in m²
|
||||
*/
|
||||
function calculateSurface(widthMm, heightMm, quantity = 1) {
|
||||
return Math.round((widthMm * heightMm * quantity) / 1000000 * 1000) / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format cutting list for display or export
|
||||
*/
|
||||
export function formatCuttingListForExport(cuttingList) {
|
||||
const lines = [];
|
||||
|
||||
lines.push('LISTE DE DÉBITAGE');
|
||||
lines.push('=================');
|
||||
lines.push('');
|
||||
lines.push(`Configuration: ${cuttingList.config.widthMm}mm x ${cuttingList.config.heightMm}mm x ${cuttingList.config.depthMm}mm`);
|
||||
lines.push(`Type: ${cuttingList.config.installationType === 'niche' ? 'Encastrée' : 'Libre'}`);
|
||||
lines.push(`Portes: ${cuttingList.config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}`);
|
||||
lines.push('');
|
||||
lines.push('RÉSUMÉ');
|
||||
lines.push('------');
|
||||
lines.push(`Nombre total de pièces: ${cuttingList.summary.totalPieces}`);
|
||||
lines.push(`Surface totale: ${cuttingList.summary.totalSurfaceM2.toFixed(2)} m²`);
|
||||
lines.push(`Chant plaqué: ${cuttingList.summary.edgeBandingMl} ml`);
|
||||
lines.push(`Finitions différentes: ${cuttingList.summary.finishCount}`);
|
||||
lines.push('');
|
||||
|
||||
for (const group of cuttingList.byFinish) {
|
||||
lines.push(`FINITION: ${group.finishLabel}`);
|
||||
lines.push('-'.repeat(40));
|
||||
lines.push('Pièce | Dim. (LxH) | Ép. | Qté | Surface');
|
||||
|
||||
for (const piece of group.pieces) {
|
||||
lines.push(
|
||||
`${piece.description.padEnd(30)} | ${piece.widthMm}x${piece.heightMm} | ${piece.thicknessMm}mm | x${piece.quantity} | ${piece.surfaceM2.toFixed(3)}m²`
|
||||
);
|
||||
}
|
||||
lines.push(`Sous-total: ${group.pieceCount} pièces, ${group.totalSurfaceM2.toFixed(2)} m²`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export default { generateCuttingList, formatCuttingListForExport };
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* AI Design Advisor — GPT-4o powered design tips, material recommendations, and DALL-E 3 renders
|
||||
* Helps customers feel confident about their wardrobe design choices.
|
||||
*/
|
||||
import OpenAI from 'openai';
|
||||
import crypto from 'crypto';
|
||||
|
||||
let openaiClient = null;
|
||||
|
||||
function getOpenAIClient() {
|
||||
if (!openaiClient) {
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
throw new Error('OPENAI_API_KEY is not configured. Add it to server/.env');
|
||||
}
|
||||
openaiClient = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
}
|
||||
return openaiClient;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 1. Design Tips (GPT-4o text) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const DESIGN_TIP_PROMPT = `Tu es un designer d'intérieur suisse spécialisé dans les armoires sur mesure haut de gamme.
|
||||
À partir de la configuration ci-dessous, donne UN SEUL conseil ou commentaire positif en 1-2 phrases maximum.
|
||||
Le ton doit être chaleureux, professionnel et encourageant — le client doit se sentir confiant dans son choix.
|
||||
|
||||
Varie tes réponses : parfois un compliment sur la combinaison, parfois un conseil pratique, parfois une info sur les tendances.
|
||||
|
||||
Réponds UNIQUEMENT en JSON :
|
||||
{ "tip": "…votre conseil en français…", "category": "compliment" | "conseil" | "tendance" }`;
|
||||
|
||||
/**
|
||||
* Generate a design tip for the current configuration
|
||||
* @param {object} config - { width, height, depth, columns (array of {type, shelves, drawers, hasRod}), material, installationType }
|
||||
* @returns {{ tip: string, category: string }}
|
||||
*/
|
||||
export async function getDesignTip(config) {
|
||||
const client = getOpenAIClient();
|
||||
|
||||
const configSummary = buildConfigSummary(config);
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
temperature: 0.9,
|
||||
max_tokens: 200,
|
||||
messages: [
|
||||
{ role: 'system', content: DESIGN_TIP_PROMPT },
|
||||
{ role: 'user', content: configSummary }
|
||||
],
|
||||
response_format: { type: 'json_object' }
|
||||
});
|
||||
|
||||
const raw = response.choices[0]?.message?.content;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return { tip: raw, category: 'conseil' };
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 2. Material Recommendations (GPT-4o) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
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.
|
||||
|
||||
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éponds UNIQUEMENT en JSON :
|
||||
{
|
||||
"recommendations": [
|
||||
{ "finishId": <number>, "reason": "…raison courte en français…" }
|
||||
]
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Recommend materials that complement the current configuration
|
||||
* @param {object} config - Current wardrobe configuration
|
||||
* @param {Array} availableFinishes - [{ id, name, collection, code }]
|
||||
* @param {object} [roomAnalysis] - Optional room photo analysis
|
||||
* @returns {{ recommendations: Array<{ finishId: number, reason: string }> }}
|
||||
*/
|
||||
export async function recommendMaterials(config, availableFinishes, roomAnalysis) {
|
||||
const client = getOpenAIClient();
|
||||
|
||||
const configSummary = buildConfigSummary(config);
|
||||
|
||||
// Compact finish list (only names + ids to save tokens)
|
||||
const finishList = availableFinishes
|
||||
.slice(0, 80) // limit to avoid token overflow
|
||||
.map(f => `${f.id}: ${f.name || f.label} (${f.collection || '?'})`)
|
||||
.join('\n');
|
||||
|
||||
let userMessage = `CONFIGURATION ACTUELLE:\n${configSummary}\n\nMATÉRIAUX DISPONIBLES:\n${finishList}`;
|
||||
|
||||
if (roomAnalysis) {
|
||||
userMessage += `\n\nCONTEXTE PIÈCE:\n${JSON.stringify(roomAnalysis, null, 1)}`;
|
||||
}
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
temperature: 0.7,
|
||||
max_tokens: 400,
|
||||
messages: [
|
||||
{ role: 'system', content: MATERIAL_REC_PROMPT },
|
||||
{ role: 'user', content: userMessage }
|
||||
],
|
||||
response_format: { type: 'json_object' }
|
||||
});
|
||||
|
||||
const raw = response.choices[0]?.message?.content;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return { recommendations: (parsed.recommendations || []).slice(0, 5) };
|
||||
} catch {
|
||||
return { recommendations: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 3. Photorealistic Render (DALL-E 3) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
// Simple in-memory render cache (config hash → image URL)
|
||||
const renderCache = new Map();
|
||||
|
||||
/**
|
||||
* Generate a photorealistic render of the configured wardrobe using DALL-E 3
|
||||
* @param {object} config - Current wardrobe configuration
|
||||
* @param {object} [roomContext] - Optional room analysis for context
|
||||
* @returns {{ imageUrl: string, cached: boolean }}
|
||||
*/
|
||||
export async function generateRender(config, roomContext) {
|
||||
const client = getOpenAIClient();
|
||||
|
||||
// Cache by config hash
|
||||
const hash = crypto.createHash('md5').update(JSON.stringify(config)).digest('hex');
|
||||
if (renderCache.has(hash)) {
|
||||
return { imageUrl: renderCache.get(hash), cached: true };
|
||||
}
|
||||
|
||||
const prompt = buildRenderPrompt(config, roomContext);
|
||||
|
||||
const response = await client.images.generate({
|
||||
model: 'dall-e-3',
|
||||
prompt,
|
||||
n: 1,
|
||||
size: '1792x1024',
|
||||
quality: 'hd',
|
||||
style: 'natural'
|
||||
});
|
||||
|
||||
const imageUrl = response.data[0]?.url;
|
||||
|
||||
if (imageUrl) {
|
||||
renderCache.set(hash, imageUrl);
|
||||
// Auto-evict after 30 minutes (DALL-E URLs expire after ~1h)
|
||||
setTimeout(() => renderCache.delete(hash), 30 * 60 * 1000);
|
||||
}
|
||||
|
||||
return { imageUrl, cached: false };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function buildConfigSummary(config) {
|
||||
const columns = (config.columns || config.modules || [])
|
||||
.map((c, i) => ` Colonne ${i + 1}: ${c.type || c.moduleCode || '?'} — ${c.width || '?'}mm, ${c.shelves || 0} étagères, ${c.drawers || 0} tiroirs${c.hasRod ? ', tringle' : ''}`)
|
||||
.join('\n');
|
||||
|
||||
return `Armoire sur mesure SIGNATURE
|
||||
Dimensions: ${config.width || '?'}mm × ${config.height || '?'}mm × ${config.depth || '?'}mm
|
||||
Installation: ${config.installationType || 'libre'}
|
||||
Colonnes:\n${columns}
|
||||
Matériau principal: ${config.materialName || config.finishLabel || 'non défini'}
|
||||
Portes: ${config.doorType || 'battantes'}`;
|
||||
}
|
||||
|
||||
function buildRenderPrompt(config, roomContext) {
|
||||
const cols = (config.columns || config.modules || []);
|
||||
const colDesc = cols.map((c, i) => {
|
||||
const type = c.type || c.moduleCode || 'standard';
|
||||
const parts = [];
|
||||
if (c.hasRod) parts.push('a hanging rod');
|
||||
if (c.shelves > 0) parts.push(`${c.shelves} shelves`);
|
||||
if (c.drawers > 0) parts.push(`${c.drawers} drawers`);
|
||||
return `column ${i + 1}: ${type} with ${parts.join(', ') || 'empty space'}`;
|
||||
}).join('; ');
|
||||
|
||||
const widthM = ((config.width || 2400) / 1000).toFixed(1);
|
||||
const heightM = ((config.height || 2400) / 1000).toFixed(1);
|
||||
|
||||
let roomDesc = 'a modern, well-lit bedroom with neutral walls';
|
||||
if (roomContext) {
|
||||
const walls = roomContext.walls || [];
|
||||
const wallColors = walls.filter(w => w.color).map(w => w.color).join(', ');
|
||||
roomDesc = `a ${roomContext.roomType || 'bedroom'} with ${wallColors || 'neutral'} walls`;
|
||||
}
|
||||
|
||||
return `Professional interior design photograph of a premium Swiss-made custom wardrobe in ${roomDesc}.
|
||||
The wardrobe is ${widthM}m wide and ${heightM}m tall, built-in style, with ${cols.length} columns: ${colDesc}.
|
||||
Material: ${config.materialName || 'light oak wood finish'}. Doors are ${config.doorType === 'coulissantes' ? 'sliding' : 'hinged'}, partially open to reveal the interior.
|
||||
Style: clean Scandinavian-Swiss aesthetic, warm natural lighting, photorealistic quality. Shot with a wide-angle lens at eye level.
|
||||
The wardrobe should look premium, well-crafted, and perfectly integrated into the room.`;
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* Formex.ch web crawler service
|
||||
* Scrapes panel/material data from the Formex catalog
|
||||
*/
|
||||
import * as cheerio from 'cheerio';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import pool, { run } from '../db/index.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BASE_URL = 'https://www.formex.ch';
|
||||
const ASSORTIMENT_URL = `${BASE_URL}/fr/assortiment`;
|
||||
const CRAWL_DELAY = parseInt(process.env.FORMEX_CRAWL_DELAY_MS || '500', 10);
|
||||
|
||||
// Directory for storing downloaded material images
|
||||
const IMAGES_DIR = path.join(__dirname, '..', '..', 'client', 'public', 'materials');
|
||||
|
||||
// Ensure images directory exists
|
||||
if (!fs.existsSync(IMAGES_DIR)) {
|
||||
fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep helper for rate limiting
|
||||
*/
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a URL with error handling
|
||||
*/
|
||||
async function fetchPage(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; ConfigurateurArmoires/1.0)',
|
||||
'Accept': 'text/html,application/xhtml+xml',
|
||||
'Accept-Language': 'fr-CH,fr;q=0.9'
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an image and save it locally
|
||||
* @param {string} url - Image URL to download
|
||||
* @param {string} filename - Local filename (e.g., "12345.jpg")
|
||||
* @returns {string|null} - Local path or null if failed
|
||||
*/
|
||||
async function downloadImage(url, filename) {
|
||||
try {
|
||||
if (!url) return null;
|
||||
|
||||
// Ensure absolute URL
|
||||
const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${url}`;
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; ConfigurateurArmoires/1.0)',
|
||||
'Accept': 'image/*'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to download image ${fullUrl}: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
let ext = '.jpg';
|
||||
if (contentType.includes('png')) ext = '.png';
|
||||
else if (contentType.includes('webp')) ext = '.webp';
|
||||
|
||||
// Ensure filename has correct extension
|
||||
const finalFilename = filename.replace(/\.[^.]+$/, '') + ext;
|
||||
const filepath = path.join(IMAGES_DIR, finalFilename);
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
fs.writeFileSync(filepath, buffer);
|
||||
|
||||
// Return the public path (relative to client/public)
|
||||
return `/materials/${finalFilename}`;
|
||||
} catch (err) {
|
||||
console.warn(`Error downloading image ${url}:`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract collection URLs from the assortiment page
|
||||
*/
|
||||
async function getCollections() {
|
||||
const html = await fetchPage(ASSORTIMENT_URL);
|
||||
const $ = cheerio.load(html);
|
||||
const collections = [];
|
||||
|
||||
// Find collection links in the "Collections" section
|
||||
$('a[href*="/fr/shop/"]').each((_, el) => {
|
||||
const href = $(el).attr('href');
|
||||
const name = $(el).text().trim();
|
||||
|
||||
// Only include category pages (contain -c followed by numbers)
|
||||
if (href && href.match(/-c\d+$/) && name) {
|
||||
const fullUrl = href.startsWith('http') ? href : `${BASE_URL}${href}`;
|
||||
// Avoid duplicates
|
||||
if (!collections.find(c => c.url === fullUrl)) {
|
||||
collections.push({ name, url: fullUrl });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return collections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse dimensions from a string like "2800mm x 2070mm x 19mm"
|
||||
*/
|
||||
function parseDimensions(dimStr) {
|
||||
if (!dimStr) return { width: null, height: null, thickness: null };
|
||||
|
||||
const matches = dimStr.match(/(\d+)\s*mm\s*x\s*(\d+)\s*mm\s*x\s*(\d+)\s*mm/i);
|
||||
if (matches) {
|
||||
return {
|
||||
width: parseInt(matches[1], 10),
|
||||
height: parseInt(matches[2], 10),
|
||||
thickness: parseInt(matches[3], 10)
|
||||
};
|
||||
}
|
||||
return { width: null, height: null, thickness: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract decor code from product name or page content
|
||||
* e.g., "Panneaux mélaminés Duplex Plus 10000 Tilia" -> "10000"
|
||||
*/
|
||||
function extractDecorCode(text) {
|
||||
// Look for a 4-5 digit code
|
||||
const match = text.match(/\b(\d{4,5})\b/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of common color keywords found in Formex product names to representative hex colors
|
||||
*/
|
||||
const COLOR_KEYWORDS = {
|
||||
// Whites and off-whites
|
||||
'blanc': '#f5f5f5', 'white': '#f5f5f5', 'weiss': '#f5f5f5', 'bianco': '#f5f5f5',
|
||||
'crème': '#fffdd0', 'cream': '#fffdd0', 'ivoire': '#fffff0', 'ivory': '#fffff0',
|
||||
'magnolia': '#f8f4f0', 'perle': '#eae0c8',
|
||||
// Greys
|
||||
'gris': '#909090', 'grey': '#909090', 'gray': '#909090', 'grau': '#909090',
|
||||
'anthracite': '#383838', 'anthrazit': '#383838', 'graphite': '#4a4a4a',
|
||||
'platine': '#e5e4e2', 'argent': '#c0c0c0', 'silver': '#c0c0c0',
|
||||
'titane': '#878681',
|
||||
// Blacks
|
||||
'noir': '#1a1a1a', 'black': '#1a1a1a', 'schwarz': '#1a1a1a',
|
||||
// Browns and woods
|
||||
'chêne': '#b5895b', 'oak': '#b5895b', 'eiche': '#b5895b',
|
||||
'noyer': '#6f4e37', 'walnut': '#6f4e37', 'nussbaum': '#6f4e37',
|
||||
'hêtre': '#c9a46c', 'beech': '#c9a46c', 'buche': '#c9a46c',
|
||||
'érable': '#d4b572', 'maple': '#d4b572', 'ahorn': '#d4b572',
|
||||
'cerisier': '#9c3b17', 'cherry': '#9c3b17', 'kirsch': '#9c3b17',
|
||||
'teck': '#b08444', 'teak': '#b08444',
|
||||
'pin': '#dcc8a0', 'pine': '#dcc8a0',
|
||||
'orme': '#a07850', 'elm': '#a07850',
|
||||
'bouleau': '#e0c99c', 'birch': '#e0c99c',
|
||||
'frêne': '#c8b88a', 'ash': '#c8b88a',
|
||||
'acacia': '#b89244',
|
||||
'wenge': '#4a3728', 'wengé': '#4a3728',
|
||||
'mélèze': '#c4a06c', 'larch': '#c4a06c',
|
||||
'aulne': '#b07c4c', 'alder': '#b07c4c',
|
||||
'brun': '#8b4513', 'brown': '#8b4513', 'braun': '#8b4513',
|
||||
'marron': '#6d3e1c', 'chocolat': '#4a2c14',
|
||||
'cognac': '#9f381d', 'caramel': '#a67040',
|
||||
'noisette': '#ab7746',
|
||||
// Beiges and sandstones
|
||||
'beige': '#d9c8a9', 'sable': '#c2b280', 'sand': '#c2b280',
|
||||
'sahara': '#c4a661', 'champagne': '#f7e7ce',
|
||||
'vanille': '#f3e5ab', 'vanilla': '#f3e5ab',
|
||||
'cappuccino': '#a0785a', 'moka': '#6f4e37',
|
||||
// Reds
|
||||
'rouge': '#cc3333', 'red': '#cc3333', 'rot': '#cc3333',
|
||||
'bordeaux': '#722f37', 'burgundy': '#722f37',
|
||||
'terracotta': '#c65d3e', 'rouille': '#b7410e',
|
||||
// Blues
|
||||
'bleu': '#4169e1', 'blue': '#4169e1', 'blau': '#4169e1',
|
||||
'marine': '#000080', 'navy': '#000080',
|
||||
'ciel': '#87ceeb', 'azur': '#007fff',
|
||||
'cobalt': '#0047ab', 'indigo': '#4b0082',
|
||||
// Greens
|
||||
'vert': '#4caf50', 'green': '#4caf50', 'grün': '#4caf50',
|
||||
'olive': '#808000', 'sauge': '#9caf88', 'sage': '#9caf88',
|
||||
'emeraude': '#50c878', 'mousse': '#738276',
|
||||
// Yellows and golds
|
||||
'jaune': '#f0c040', 'yellow': '#f0c040',
|
||||
'or': '#d4af37', 'gold': '#d4af37',
|
||||
'miel': '#cd8032', 'honey': '#cd8032',
|
||||
// Stone and concrete
|
||||
'béton': '#a0a0a0', 'beton': '#a0a0a0', 'concrete': '#a0a0a0',
|
||||
'pierre': '#b0a898', 'stone': '#b0a898', 'stein': '#b0a898',
|
||||
'ardoise': '#5a5a5a', 'slate': '#5a5a5a',
|
||||
'marbre': '#e0d8d0', 'marble': '#e0d8d0',
|
||||
'granit': '#808080', 'granite': '#808080',
|
||||
'travertin': '#d1c6b0',
|
||||
// Others
|
||||
'rouille': '#b7410e', 'rust': '#b7410e',
|
||||
'cuivre': '#b87333', 'copper': '#b87333',
|
||||
'linen': '#e0d5c0', 'lin': '#e0d5c0',
|
||||
'taupe': '#8b7d6b',
|
||||
'mauve': '#e0b0ff', 'lilas': '#c8a2c8',
|
||||
'rose': '#ffc0cb', 'pink': '#ffc0cb', 'rosa': '#ffc0cb'
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract dominant color from product name by matching color keywords
|
||||
* @param {string} name - Product name
|
||||
* @returns {string|null} - Hex color code or null
|
||||
*/
|
||||
function extractDominantColor(name) {
|
||||
if (!name) return null;
|
||||
const lower = name.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
const nameLower = name.toLowerCase();
|
||||
|
||||
// Try exact keyword matching (longest match first for compound words)
|
||||
const sortedKeywords = Object.entries(COLOR_KEYWORDS)
|
||||
.sort((a, b) => b[0].length - a[0].length);
|
||||
|
||||
for (const [keyword, hex] of sortedKeywords) {
|
||||
// Check the raw name (preserving accents) and the normalized name
|
||||
if (nameLower.includes(keyword) || lower.includes(keyword.normalize('NFD').replace(/[\u0300-\u036f]/g, ''))) {
|
||||
return hex;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product details from a product detail page
|
||||
* Enhanced to capture category, images, dimension variants, and color variants
|
||||
*/
|
||||
async function getProductDetails(productUrl) {
|
||||
try {
|
||||
const html = await fetchPage(productUrl);
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Get product name from h1
|
||||
const name = $('h1').first().text().trim();
|
||||
|
||||
// Get decor code - look for "N° du décor:" or extract from name
|
||||
let decorCode = null;
|
||||
$('*').each((_, el) => {
|
||||
const text = $(el).text();
|
||||
const match = text.match(/N°\s*du\s*décor\s*:\s*(\d+)/i);
|
||||
if (match) {
|
||||
decorCode = match[1];
|
||||
return false; // break
|
||||
}
|
||||
});
|
||||
|
||||
if (!decorCode) {
|
||||
decorCode = extractDecorCode(name);
|
||||
}
|
||||
|
||||
// Get category from breadcrumb (second-to-last item usually)
|
||||
let category = null;
|
||||
const breadcrumbLinks = $('a[href*="/fr/shop/"]').filter((_, el) => {
|
||||
const href = $(el).attr('href') || '';
|
||||
return href.match(/-c\d+$/); // Collection links
|
||||
});
|
||||
if (breadcrumbLinks.length > 0) {
|
||||
category = $(breadcrumbLinks.last()).text().trim();
|
||||
}
|
||||
|
||||
// Get main product image
|
||||
let imageUrl = null;
|
||||
// Look for product image in various selectors
|
||||
$('img').each((_, el) => {
|
||||
const src = $(el).attr('src') || '';
|
||||
if (src.includes('/dekorbilder/') || src.includes('/sortiment/')) {
|
||||
imageUrl = src.startsWith('http') ? src : `${BASE_URL}${src}`;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
// Try data-src for lazy-loaded images
|
||||
if (!imageUrl) {
|
||||
$('img[data-src]').each((_, el) => {
|
||||
const src = $(el).attr('data-src') || '';
|
||||
if (src.includes('/dekorbilder/') || src.includes('/sortiment/')) {
|
||||
imageUrl = src.startsWith('http') ? src : `${BASE_URL}${src}`;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get ALL dimension variants (multiple formats like "3650mm x 600mm x 29mm")
|
||||
const dimensionVariants = [];
|
||||
const seenDimensions = new Set();
|
||||
$('a, button, span, div').each((_, el) => {
|
||||
const text = $(el).text().trim();
|
||||
const match = text.match(/^(\d+)\s*mm\s*x\s*(\d+)\s*mm\s*x\s*(\d+)\s*mm$/i);
|
||||
if (match) {
|
||||
const key = `${match[1]}-${match[2]}-${match[3]}`;
|
||||
if (!seenDimensions.has(key)) {
|
||||
seenDimensions.add(key);
|
||||
dimensionVariants.push({
|
||||
width: parseInt(match[1], 10),
|
||||
height: parseInt(match[2], 10),
|
||||
thickness: parseInt(match[3], 10)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Default dimensions (first variant or from legacy parsing)
|
||||
let dimensions = dimensionVariants[0] || { width: null, height: null, thickness: null };
|
||||
if (!dimensions.width) {
|
||||
$('a, span, div').each((_, el) => {
|
||||
const text = $(el).text();
|
||||
if (text.match(/\d+\s*mm\s*x\s*\d+\s*mm\s*x\s*\d+\s*mm/i)) {
|
||||
dimensions = parseDimensions(text);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get color/decor variants (from "Choisir la couleur" section)
|
||||
const colorVariants = [];
|
||||
$('a[href*="/fr/shop/"]').each((_, el) => {
|
||||
const href = $(el).attr('href') || '';
|
||||
// Look for product links that are variants (not the current product)
|
||||
if (href.match(/-p\d+$/) && !href.includes(productUrl.split('/').pop())) {
|
||||
const variantUrl = href.startsWith('http') ? href : `${BASE_URL}${href}`;
|
||||
const variantName = $(el).attr('title') || $(el).text().trim();
|
||||
const variantCode = extractDecorCode(variantName) || extractDecorCode(href);
|
||||
|
||||
// Look for variant thumbnail image
|
||||
let variantImage = null;
|
||||
const img = $(el).find('img').first();
|
||||
if (img.length) {
|
||||
variantImage = img.attr('src') || img.attr('data-src');
|
||||
if (variantImage && !variantImage.startsWith('http')) {
|
||||
variantImage = `${BASE_URL}${variantImage}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (variantCode && variantName && !colorVariants.find(v => v.code === variantCode)) {
|
||||
colorVariants.push({
|
||||
code: variantCode,
|
||||
name: variantName,
|
||||
url: variantUrl,
|
||||
imageUrl: variantImage
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Download main product image locally
|
||||
let localImageUrl = null;
|
||||
if (imageUrl && decorCode) {
|
||||
localImageUrl = await downloadImage(imageUrl, `${decorCode}.jpg`);
|
||||
}
|
||||
|
||||
// Download variant thumbnails
|
||||
for (const variant of colorVariants) {
|
||||
if (variant.imageUrl && variant.code) {
|
||||
const localPath = await downloadImage(variant.imageUrl, `${variant.code}_thumb.jpg`);
|
||||
if (localPath) {
|
||||
variant.imageUrl = localPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract a representative dominant color from the product name
|
||||
const dominantColor = extractDominantColor(name);
|
||||
|
||||
return {
|
||||
name,
|
||||
decorCode,
|
||||
dimensions,
|
||||
dimensionVariants,
|
||||
colorVariants,
|
||||
category,
|
||||
imageUrl: localImageUrl || imageUrl, // Prefer local path, fallback to URL
|
||||
dominantColor
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Error fetching product details from ${productUrl}:`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get products from a collection page
|
||||
* @param {string} collectionUrl
|
||||
* @param {string} collectionName
|
||||
* @param {Function} emit - Optional emitter for progress events
|
||||
*/
|
||||
async function getProductsFromCollection(collectionUrl, collectionName, emit = () => {}) {
|
||||
const products = [];
|
||||
const html = await fetchPage(collectionUrl);
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Find product links
|
||||
const productLinks = new Set();
|
||||
$('a[href*="/fr/shop/"]').each((_, el) => {
|
||||
const href = $(el).attr('href');
|
||||
// Product pages contain -p followed by numbers
|
||||
if (href && href.match(/-p\d+$/)) {
|
||||
const fullUrl = href.startsWith('http') ? href : `${BASE_URL}${href}`;
|
||||
productLinks.add(fullUrl);
|
||||
}
|
||||
});
|
||||
|
||||
const productCount = productLinks.size;
|
||||
console.log(` Found ${productCount} products in ${collectionName}`);
|
||||
emit({ type: 'log', message: ` ${productCount} produits trouvés dans ${collectionName}` });
|
||||
|
||||
// Fetch each product's details
|
||||
let productIndex = 0;
|
||||
for (const productUrl of productLinks) {
|
||||
await sleep(CRAWL_DELAY);
|
||||
const details = await getProductDetails(productUrl);
|
||||
productIndex++;
|
||||
|
||||
if (details && details.decorCode) {
|
||||
products.push({
|
||||
formex_code: details.decorCode,
|
||||
name: details.name,
|
||||
collection: collectionName,
|
||||
category: details.category || collectionName,
|
||||
width_mm: details.dimensions.width,
|
||||
height_mm: details.dimensions.height,
|
||||
thickness_mm: details.dimensions.thickness,
|
||||
image_url: details.imageUrl,
|
||||
formex_url: productUrl,
|
||||
dominant_color: details.dominantColor,
|
||||
dimensionVariants: details.dimensionVariants || [],
|
||||
colorVariants: details.colorVariants || []
|
||||
});
|
||||
const dimCount = details.dimensionVariants?.length || 0;
|
||||
const varCount = details.colorVariants?.length || 0;
|
||||
emit({ type: 'log', message: ` [${productIndex}/${productCount}] ${details.name} (${dimCount} dims, ${varCount} variants)` });
|
||||
}
|
||||
}
|
||||
|
||||
return products;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a material into the database with enhanced data
|
||||
*/
|
||||
async function upsertMaterial(material) {
|
||||
// Try to update first
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id FROM formex_materials WHERE formex_code = ?',
|
||||
[material.formex_code]
|
||||
);
|
||||
|
||||
let materialId;
|
||||
|
||||
if (rows.length > 0) {
|
||||
materialId = rows[0].id;
|
||||
// Update existing
|
||||
await run(
|
||||
`UPDATE formex_materials
|
||||
SET name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
|
||||
image_url = ?, formex_url = ?, dominant_color = COALESCE(?, dominant_color), last_synced_at = datetime('now')
|
||||
WHERE formex_code = ?`,
|
||||
[material.name, material.collection, material.category || null,
|
||||
material.width_mm, material.height_mm, material.thickness_mm,
|
||||
material.image_url || null, material.formex_url, material.dominant_color || null, material.formex_code]
|
||||
);
|
||||
} else {
|
||||
// Insert new
|
||||
await run(
|
||||
`INSERT INTO formex_materials
|
||||
(formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, dominant_color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[material.formex_code, material.name, material.collection, material.category || null,
|
||||
material.width_mm, material.height_mm, material.thickness_mm,
|
||||
material.image_url || null, material.formex_url, material.dominant_color || null]
|
||||
);
|
||||
|
||||
// Get the inserted ID
|
||||
const inserted = await pool.query(
|
||||
'SELECT id FROM formex_materials WHERE formex_code = ?',
|
||||
[material.formex_code]
|
||||
);
|
||||
materialId = inserted.rows[0]?.id;
|
||||
}
|
||||
|
||||
// Update dimension variants if we have a materialId
|
||||
if (materialId && material.dimensionVariants && material.dimensionVariants.length > 0) {
|
||||
// Delete existing dimensions
|
||||
await run('DELETE FROM formex_dimensions WHERE material_id = ?', [materialId]);
|
||||
|
||||
// Insert new dimensions
|
||||
for (let i = 0; i < material.dimensionVariants.length; i++) {
|
||||
const dim = material.dimensionVariants[i];
|
||||
await run(
|
||||
`INSERT INTO formex_dimensions (material_id, width_mm, height_mm, thickness_mm, is_default)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[materialId, dim.width, dim.height, dim.thickness, i === 0 ? 1 : 0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update color variants if we have a materialId
|
||||
if (materialId && material.colorVariants && material.colorVariants.length > 0) {
|
||||
// Delete existing variants
|
||||
await run('DELETE FROM formex_variants WHERE material_id = ?', [materialId]);
|
||||
|
||||
// Insert new variants
|
||||
for (const variant of material.colorVariants) {
|
||||
await run(
|
||||
`INSERT INTO formex_variants (material_id, variant_code, variant_name, variant_url, image_url)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[materialId, variant.code, variant.name, variant.url || null, variant.imageUrl || null]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return materialId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log sync result to database
|
||||
*/
|
||||
async function logSync(source, status, itemsCount, error = null) {
|
||||
await run(
|
||||
`INSERT INTO sync_logs (source, status, items_count, error) VALUES (?, ?, ?, ?)`,
|
||||
[source, status, itemsCount, error]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main sync function - crawls Formex and updates database
|
||||
* @param {Function} emit - Optional emitter callback for SSE progress events
|
||||
* Called with { type: 'log'|'progress'|'complete'|'error', ... }
|
||||
*/
|
||||
export async function sync(emit = () => {}) {
|
||||
console.log('Starting Formex sync...');
|
||||
emit({ type: 'log', message: 'Démarrage de la synchronisation Formex...' });
|
||||
|
||||
const startTime = Date.now();
|
||||
let totalProducts = 0;
|
||||
|
||||
try {
|
||||
// Get all collections
|
||||
console.log('Fetching collections...');
|
||||
emit({ type: 'log', message: 'Récupération des collections...' });
|
||||
const collections = await getCollections();
|
||||
const totalCollections = collections.length;
|
||||
console.log(`Found ${totalCollections} collections`);
|
||||
emit({ type: 'log', message: `${totalCollections} collections trouvées` });
|
||||
emit({ type: 'progress', current: 0, total: totalCollections, collection: '' });
|
||||
|
||||
// Process each collection
|
||||
for (let i = 0; i < collections.length; i++) {
|
||||
const collection = collections[i];
|
||||
console.log(`Processing: ${collection.name}`);
|
||||
emit({ type: 'log', message: `Traitement: ${collection.name}` });
|
||||
emit({ type: 'progress', current: i, total: totalCollections, collection: collection.name });
|
||||
|
||||
await sleep(CRAWL_DELAY);
|
||||
|
||||
try {
|
||||
const products = await getProductsFromCollection(collection.url, collection.name, emit);
|
||||
|
||||
// Upsert each product
|
||||
for (const product of products) {
|
||||
await upsertMaterial(product);
|
||||
totalProducts++;
|
||||
}
|
||||
|
||||
emit({ type: 'log', message: ` ${products.length} produits enregistrés pour ${collection.name}` });
|
||||
} catch (err) {
|
||||
console.error(` Error processing ${collection.name}:`, err.message);
|
||||
emit({ type: 'log', message: ` Erreur: ${err.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log(`Sync complete: ${totalProducts} products in ${elapsed}s`);
|
||||
|
||||
await logSync('formex', 'success', totalProducts);
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
itemsCount: totalProducts,
|
||||
duration: elapsed
|
||||
};
|
||||
|
||||
emit({ type: 'progress', current: totalCollections, total: totalCollections, collection: '' });
|
||||
emit({ type: 'complete', result });
|
||||
|
||||
return result;
|
||||
|
||||
} catch (err) {
|
||||
console.error('Sync failed:', err.message);
|
||||
await logSync('formex', 'error', totalProducts, err.message);
|
||||
|
||||
const result = {
|
||||
success: false,
|
||||
error: err.message,
|
||||
itemsCount: totalProducts
|
||||
};
|
||||
|
||||
emit({ type: 'error', error: err.message });
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync logs from database
|
||||
*/
|
||||
export async function getSyncLogs(limit = 20) {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM sync_logs ORDER BY created_at DESC LIMIT ?',
|
||||
[limit]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all scraped materials
|
||||
*/
|
||||
export async function getMaterials() {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM formex_materials ORDER BY collection, name'
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Layout Optimizer using OpenAI GPT-4
|
||||
* Proposes optimal module arrangement within available width,
|
||||
* respecting locked modules and optimizing for functionality and aesthetics
|
||||
*/
|
||||
import OpenAI from 'openai';
|
||||
|
||||
// Lazy initialization to avoid errors when API key is not set
|
||||
let openaiClient = null;
|
||||
|
||||
function getOpenAIClient() {
|
||||
if (!openaiClient) {
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
throw new Error('OPENAI_API_KEY is not configured. Add it to server/.env');
|
||||
}
|
||||
openaiClient = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY
|
||||
});
|
||||
}
|
||||
return openaiClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt template for layout optimization
|
||||
*/
|
||||
const LAYOUT_OPTIMIZATION_PROMPT = `Tu es un expert en aménagement d'armoires sur mesure avec 20 ans d'expérience.
|
||||
Propose un layout optimal pour les modules dans l'espace disponible.
|
||||
|
||||
CONTRAINTES:
|
||||
- Largeur totale disponible: {totalWidth}mm
|
||||
- Hauteur armoire: {height}mm
|
||||
- Profondeur: {depth}mm
|
||||
- Type d'installation: {installationType}
|
||||
|
||||
MODULES DISPONIBLES DANS LE CATALOGUE:
|
||||
{availableModules}
|
||||
|
||||
MODULES ACTUELLEMENT PLACÉS:
|
||||
{currentModules}
|
||||
|
||||
MODULES FIGÉS (ne pas déplacer, garder leur position exacte):
|
||||
{lockedModules}
|
||||
|
||||
RÈGLES D'OPTIMISATION:
|
||||
1. La somme des largeurs des modules doit égaler exactement la largeur totale
|
||||
2. Les modules figés (locked) doivent rester à leur position exacte
|
||||
3. Favoriser l'équilibre visuel (penderies aux extrémités, tiroirs au centre)
|
||||
4. Regrouper les éléments de même type si possible
|
||||
5. Pour une penderie principale, prévoir au moins 600mm de largeur
|
||||
6. Les tiroirs sont plus pratiques à hauteur accessible (partie basse)
|
||||
7. Laisser au moins un module rayonnage pour le linge plié
|
||||
|
||||
Retourne un JSON valide avec cette structure:
|
||||
{
|
||||
"layout": [
|
||||
{
|
||||
"moduleCode": "<code du module du catalogue>",
|
||||
"moduleId": "<id unique du module dans la config, ou null si nouveau>",
|
||||
"position": <index de position 0, 1, 2...>,
|
||||
"widthMm": <largeur en mm>,
|
||||
"locked": <true si figé, false sinon>,
|
||||
"customizations": {
|
||||
"shelves": <nombre d'étagères si personnalisable>,
|
||||
"drawers": <nombre de tiroirs si applicable>,
|
||||
"hasRod": <true/false pour tringle penderie>
|
||||
}
|
||||
}
|
||||
],
|
||||
"totalWidth": <somme des largeurs en mm>,
|
||||
"reasoning": "<Explication en français du choix d'agencement>",
|
||||
"alternatives": [
|
||||
{
|
||||
"description": "<description courte>",
|
||||
"layout": [<même structure que layout principal>]
|
||||
}
|
||||
],
|
||||
"warnings": ["<avertissements éventuels>"]
|
||||
}
|
||||
|
||||
IMPORTANT: Retourne UNIQUEMENT du JSON valide, sans texte supplémentaire.`;
|
||||
|
||||
/**
|
||||
* Build the prompt with actual data
|
||||
*/
|
||||
function buildPrompt(config, availableModules, lockedModuleIds = []) {
|
||||
const currentModules = config.modules || [];
|
||||
const lockedModules = currentModules.filter(m => lockedModuleIds.includes(m.id) || m.locked);
|
||||
const unlockedModules = currentModules.filter(m => !lockedModuleIds.includes(m.id) && !m.locked);
|
||||
|
||||
const modulesCatalog = availableModules.map(m =>
|
||||
`- ${m.code}: ${m.name} (${m.category}, largeur: ${m.width_mm}mm, ${m.shelves_count} étagères, ${m.drawers_count} tiroirs, penderie: ${m.has_rod ? 'oui' : 'non'}, prix: ${m.base_price_chf} CHF)`
|
||||
).join('\n');
|
||||
|
||||
const currentModulesStr = currentModules.map((m, i) =>
|
||||
`Position ${i}: ${m.moduleCode || 'module'} - ${m.widthMm}mm${m.locked ? ' [FIGÉ]' : ''}`
|
||||
).join('\n') || 'Aucun module placé';
|
||||
|
||||
const lockedModulesStr = lockedModules.map((m, i) =>
|
||||
`Position ${currentModules.indexOf(m)}: ${m.moduleCode || 'module'} - ${m.widthMm}mm`
|
||||
).join('\n') || 'Aucun module figé';
|
||||
|
||||
return LAYOUT_OPTIMIZATION_PROMPT
|
||||
.replace('{totalWidth}', config.widthMm || 2400)
|
||||
.replace('{height}', config.heightMm || 2400)
|
||||
.replace('{depth}', config.depthMm || 600)
|
||||
.replace('{installationType}', config.installationType || 'free')
|
||||
.replace('{availableModules}', modulesCatalog)
|
||||
.replace('{currentModules}', currentModulesStr)
|
||||
.replace('{lockedModules}', lockedModulesStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize layout using GPT-4
|
||||
* @param {Object} config - Current wardrobe configuration
|
||||
* @param {Array} availableModules - List of available modules from catalog
|
||||
* @param {Array} lockedModuleIds - IDs of modules that should not be moved
|
||||
* @returns {Object} Optimized layout proposal
|
||||
*/
|
||||
export async function optimizeLayout(config, availableModules, lockedModuleIds = []) {
|
||||
const openai = getOpenAIClient();
|
||||
|
||||
const prompt = buildPrompt(config, availableModules, lockedModuleIds);
|
||||
|
||||
try {
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
max_tokens: 2000,
|
||||
response_format: { type: 'json_object' }
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error('No response from OpenAI');
|
||||
}
|
||||
|
||||
const result = JSON.parse(content);
|
||||
|
||||
// Validate response structure
|
||||
if (!result.layout || !Array.isArray(result.layout)) {
|
||||
throw new Error('Invalid layout response structure');
|
||||
}
|
||||
|
||||
// Validate total width matches
|
||||
const totalLayoutWidth = result.layout.reduce((sum, m) => sum + (m.widthMm || 0), 0);
|
||||
if (Math.abs(totalLayoutWidth - (config.widthMm || 2400)) > 10) {
|
||||
result.warnings = result.warnings || [];
|
||||
result.warnings.push(`La largeur totale (${totalLayoutWidth}mm) ne correspond pas exactement à la largeur demandée (${config.widthMm}mm)`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...result,
|
||||
tokensUsed: response.usage?.total_tokens || 0
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Layout optimization error:', error);
|
||||
|
||||
if (error.code === 'invalid_api_key') {
|
||||
throw new Error('Invalid OpenAI API key');
|
||||
}
|
||||
|
||||
if (error.code === 'rate_limit_exceeded') {
|
||||
throw new Error('API rate limit exceeded. Please try again later.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick local optimization without AI (for simple cases)
|
||||
* Distributes remaining space among unlocked modules
|
||||
* @param {Object} config - Current configuration
|
||||
* @param {Array} lockedModuleIds - IDs of locked modules
|
||||
* @returns {Object} Optimized layout
|
||||
*/
|
||||
export function quickOptimize(config, lockedModuleIds = []) {
|
||||
const modules = [...(config.modules || [])];
|
||||
const totalWidth = config.widthMm || 2400;
|
||||
|
||||
// Calculate locked width
|
||||
const lockedWidth = modules
|
||||
.filter(m => lockedModuleIds.includes(m.id) || m.locked)
|
||||
.reduce((sum, m) => sum + (m.widthMm || 0), 0);
|
||||
|
||||
// Get unlocked modules
|
||||
const unlockedModules = modules.filter(m => !lockedModuleIds.includes(m.id) && !m.locked);
|
||||
const remainingWidth = totalWidth - lockedWidth;
|
||||
|
||||
if (unlockedModules.length === 0) {
|
||||
return { success: true, layout: modules, reasoning: 'Tous les modules sont figés' };
|
||||
}
|
||||
|
||||
// Distribute remaining width equally among unlocked modules
|
||||
const widthPerModule = Math.floor(remainingWidth / unlockedModules.length);
|
||||
const remainder = remainingWidth - (widthPerModule * unlockedModules.length);
|
||||
|
||||
let extraAdded = 0;
|
||||
const optimizedModules = modules.map(m => {
|
||||
if (lockedModuleIds.includes(m.id) || m.locked) {
|
||||
return m;
|
||||
}
|
||||
const extra = extraAdded < remainder ? 1 : 0;
|
||||
extraAdded += extra;
|
||||
return {
|
||||
...m,
|
||||
widthMm: widthPerModule + extra
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
layout: optimizedModules,
|
||||
totalWidth: totalWidth,
|
||||
reasoning: 'Répartition équitable de l\'espace entre les modules non-figés'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest initial layout for empty configuration
|
||||
* @param {number} totalWidth - Total available width in mm
|
||||
* @param {Array} availableModules - Available module catalog
|
||||
* @returns {Array} Suggested initial modules
|
||||
*/
|
||||
export function suggestInitialLayout(totalWidth, availableModules) {
|
||||
const suggestions = [];
|
||||
let remainingWidth = totalWidth;
|
||||
|
||||
// Standard layout: penderie - rayonnage - penderie for typical wardrobe
|
||||
const penderie = availableModules.find(m => m.category === 'penderie' && m.width_mm <= remainingWidth / 3);
|
||||
const rayonnage = availableModules.find(m => m.category === 'rayonnage');
|
||||
|
||||
if (penderie && rayonnage) {
|
||||
// Two penderies on sides
|
||||
const penderieWidth = Math.min(penderie.width_mm, Math.floor(totalWidth * 0.3));
|
||||
const rayonnageWidth = totalWidth - (penderieWidth * 2);
|
||||
|
||||
suggestions.push({
|
||||
moduleCode: penderie.code,
|
||||
moduleTypeId: penderie.id,
|
||||
widthMm: penderieWidth,
|
||||
position: 0,
|
||||
locked: false,
|
||||
shelves: penderie.shelves_count,
|
||||
drawers: 0,
|
||||
hasRod: true
|
||||
});
|
||||
|
||||
suggestions.push({
|
||||
moduleCode: rayonnage.code,
|
||||
moduleTypeId: rayonnage.id,
|
||||
widthMm: rayonnageWidth,
|
||||
position: 1,
|
||||
locked: false,
|
||||
shelves: rayonnage.shelves_count,
|
||||
drawers: 0,
|
||||
hasRod: false
|
||||
});
|
||||
|
||||
suggestions.push({
|
||||
moduleCode: penderie.code,
|
||||
moduleTypeId: penderie.id,
|
||||
widthMm: penderieWidth,
|
||||
position: 2,
|
||||
locked: false,
|
||||
shelves: penderie.shelves_count,
|
||||
drawers: 0,
|
||||
hasRod: true
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
export default { optimizeLayout, quickOptimize, suggestInitialLayout };
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Plan Generator - Generate 2D assembly and parts plans in SVG format
|
||||
* Also handles PDF export
|
||||
*/
|
||||
|
||||
// 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>
|
||||
.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; }
|
||||
.dim-text { font-family: Arial, sans-serif; font-size: 10px; fill: #0066cc; }
|
||||
.label { font-family: Arial, sans-serif; font-size: 9px; fill: #333; }
|
||||
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
|
||||
.plinth { fill: #e0d0c0; stroke: #333; stroke-width: 1; }
|
||||
.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>`;
|
||||
|
||||
// Title
|
||||
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de face - Élévation</text>`;
|
||||
|
||||
const offsetX = MARGIN;
|
||||
const offsetY = MARGIN;
|
||||
|
||||
// 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" />`;
|
||||
}
|
||||
}
|
||||
|
||||
// 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" />`;
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a dimension line with arrows and text
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all plans for a configuration
|
||||
*/
|
||||
export function generateAllPlans(config, modules = [], colors = {}) {
|
||||
return {
|
||||
assembly: generateAssemblyPlan(config, modules),
|
||||
side: generateSidePlan(config),
|
||||
top: generateTopPlan(config, modules),
|
||||
metadata: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
config: {
|
||||
widthMm: config.widthMm,
|
||||
heightMm: config.heightMm,
|
||||
depthMm: config.depthMm,
|
||||
installationType: config.installationType,
|
||||
doorType: config.doorType,
|
||||
moduleCount: modules.length
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML page with all plans for PDF export
|
||||
*/
|
||||
export function generatePDFHtml(config, modules = [], cuttingList = null) {
|
||||
const plans = generateAllPlans(config, modules);
|
||||
|
||||
let html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Plans Armoire</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 15mm; }
|
||||
body { font-family: Arial, sans-serif; font-size: 11px; }
|
||||
.page { page-break-after: always; padding: 20px; }
|
||||
.header { border-bottom: 2px solid #333; padding-bottom: 10px; margin-bottom: 20px; }
|
||||
.title { font-size: 20px; font-weight: bold; margin: 0; }
|
||||
.subtitle { font-size: 12px; color: #666; margin: 5px 0 0; }
|
||||
.plan-container { margin: 20px 0; text-align: center; }
|
||||
.plan-title { font-size: 14px; font-weight: bold; margin-bottom: 10px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }
|
||||
th { background: #f5f5f5; font-weight: bold; }
|
||||
.summary { background: #f0f7ff; padding: 15px; border-radius: 8px; margin: 20px 0; }
|
||||
.summary h3 { margin: 0 0 10px; }
|
||||
.specs { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.spec-item { display: flex; justify-content: space-between; }
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<h3>Spécifications</h3>
|
||||
<div class="specs">
|
||||
<div class="spec-item"><span>Largeur:</span><strong>${config.widthMm} mm</strong></div>
|
||||
<div class="spec-item"><span>Hauteur:</span><strong>${config.heightMm} mm</strong></div>
|
||||
<div class="spec-item"><span>Profondeur:</span><strong>${config.depthMm} mm</strong></div>
|
||||
<div class="spec-item"><span>Plinthe:</span><strong>${config.plinthHeightMm} mm</strong></div>
|
||||
<div class="spec-item"><span>Type:</span><strong>${config.installationType === 'niche' ? 'Encastrée' : 'Libre'}</strong></div>
|
||||
<div class="spec-item"><span>Portes:</span><strong>${config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plan-container">
|
||||
<div class="plan-title">Vue de face (Élévation)</div>
|
||||
${plans.assembly}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<div class="plan-container">
|
||||
<div class="plan-title">Vue de côté</div>
|
||||
${plans.side}
|
||||
</div>
|
||||
|
||||
<div class="plan-container">
|
||||
<div class="plan-title">Vue de dessus</div>
|
||||
${plans.top}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Cutting list page
|
||||
if (cuttingList) {
|
||||
html += `
|
||||
<div class="page">
|
||||
<h2>Liste de débitage</h2>
|
||||
<div class="summary">
|
||||
<h3>Résumé</h3>
|
||||
<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>Finitions:</span><strong>${cuttingList.summary.finishCount}</strong></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
for (const group of cuttingList.byFinish) {
|
||||
html += `
|
||||
<h3>Finition: ${group.finishLabel}</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Largeur</th>
|
||||
<th>Hauteur</th>
|
||||
<th>Ép.</th>
|
||||
<th>Qté</th>
|
||||
<th>Surface</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
|
||||
for (const piece of group.pieces) {
|
||||
html += `
|
||||
<tr>
|
||||
<td>${piece.description}</td>
|
||||
<td>${piece.widthMm} mm</td>
|
||||
<td>${piece.heightMm} mm</td>
|
||||
<td>${piece.thicknessMm} mm</td>
|
||||
<td>${piece.quantity}</td>
|
||||
<td>${piece.surfaceM2.toFixed(3)} m²</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th colspan="4">Sous-total</th>
|
||||
<th>${group.pieceCount}</th>
|
||||
<th>${group.totalSurfaceM2.toFixed(2)} m²</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
export default {
|
||||
generateAssemblyPlan,
|
||||
generateSidePlan,
|
||||
generateTopPlan,
|
||||
generateAllPlans,
|
||||
generatePDFHtml
|
||||
};
|
||||
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* Moteur de calcul de prix — configurateur armoires sur mesure.
|
||||
* Règles : panneaux entiers (chute intégrée), blocs, kit, options.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calcule la surface utile des éléments visibles (portes, fileurs, bandeau, plinthe, côtés doublage).
|
||||
* Épaisseur panneaux 19 mm (référence).
|
||||
* @param {Object} config - configuration (installation, width, height, depth, plinthHeightMm, blocks, doorType, slidingPanels)
|
||||
* @param {number} panelThicknessMm - épaisseur panneaux (19)
|
||||
*/
|
||||
function calculateVisibleSurface(config, panelThicknessMm = 19) {
|
||||
const { installationType, widthMm, heightMm, depthMm, plinthHeightMm, blocks = [], doorType, slidingPanelsCount } = config;
|
||||
const isNiche = installationType === 'niche';
|
||||
let surface = 0;
|
||||
|
||||
// Portes (surface face visible)
|
||||
if (doorType === 'sliding' && slidingPanelsCount) {
|
||||
const panelWidth = widthMm / slidingPanelsCount;
|
||||
surface += slidingPanelsCount * (panelWidth * (heightMm - plinthHeightMm)) * 2; // deux faces par vantail
|
||||
} else {
|
||||
const doorsCount = blocks.length <= 1 ? Math.min(2, Math.ceil((blocks[0]?.widthMm || widthMm) / 600)) : blocks.length * 2;
|
||||
const doorWidth = widthMm / Math.max(1, doorsCount);
|
||||
surface += doorsCount * (doorWidth * (heightMm - plinthHeightMm)) * 2;
|
||||
}
|
||||
|
||||
// Plinthe (face avant)
|
||||
surface += widthMm * plinthHeightMm;
|
||||
|
||||
// Bandeau et fileurs si niche
|
||||
if (isNiche) {
|
||||
surface += widthMm * plinthHeightMm; // bandeau haut
|
||||
surface += 2 * plinthHeightMm * (heightMm - plinthHeightMm); // fileurs gauche/droite
|
||||
}
|
||||
|
||||
// Côtés de doublage (coulissant)
|
||||
if (doorType === 'sliding') {
|
||||
surface += 2 * (depthMm * (heightMm - plinthHeightMm));
|
||||
}
|
||||
|
||||
return surface / 1_000_000; // m²
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre de panneaux entiers nécessaires (arrondi supérieur).
|
||||
* Coût = nombre_panneaux × prix_panneau.
|
||||
*/
|
||||
function panelCost(surfaceM2, panelWidthMm, panelHeightMm, pricePerPanelChf) {
|
||||
const panelSurfaceM2 = (panelWidthMm * panelHeightMm) / 1_000_000;
|
||||
const count = Math.ceil(surfaceM2 / panelSurfaceM2);
|
||||
return { panels: Math.max(0, count), costChf: count * pricePerPanelChf };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the optimal dimension variant that minimizes cost (fewest panels needed).
|
||||
* @param {number} surfaceM2 - Required surface in square meters
|
||||
* @param {Array} dimensions - Array of dimension variants [{width_mm, height_mm, thickness_mm, id}]
|
||||
* @param {number} pricePerPanel - Price per panel in CHF
|
||||
* @returns {Object} - Optimal dimension with cost details and alternatives
|
||||
*/
|
||||
function findOptimalDimension(surfaceM2, dimensions, pricePerPanel) {
|
||||
if (!dimensions || dimensions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const price = pricePerPanel || 0;
|
||||
const results = [];
|
||||
|
||||
for (const dim of dimensions) {
|
||||
if (!dim.width_mm || !dim.height_mm) continue;
|
||||
|
||||
const panelSurfaceM2 = (dim.width_mm * dim.height_mm) / 1_000_000;
|
||||
const panelsNeeded = Math.ceil(surfaceM2 / panelSurfaceM2);
|
||||
const totalCost = panelsNeeded * price;
|
||||
const totalSurface = panelsNeeded * panelSurfaceM2;
|
||||
const wasteM2 = totalSurface - surfaceM2;
|
||||
const wastePercent = (wasteM2 / totalSurface) * 100;
|
||||
|
||||
results.push({
|
||||
dimension: {
|
||||
id: dim.id,
|
||||
width_mm: dim.width_mm,
|
||||
height_mm: dim.height_mm,
|
||||
thickness_mm: dim.thickness_mm
|
||||
},
|
||||
panelsNeeded,
|
||||
costChf: totalCost,
|
||||
panelSurfaceM2,
|
||||
wasteM2: Math.round(wasteM2 * 1000) / 1000,
|
||||
wastePercent: Math.round(wastePercent * 10) / 10
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by cost (lowest first), then by waste (lowest first)
|
||||
results.sort((a, b) => {
|
||||
if (a.costChf !== b.costChf) return a.costChf - b.costChf;
|
||||
return a.wasteM2 - b.wasteM2;
|
||||
});
|
||||
|
||||
if (results.length === 0) return null;
|
||||
|
||||
const optimal = results[0];
|
||||
const alternatives = results.slice(1);
|
||||
|
||||
return {
|
||||
selectedDimension: optimal.dimension,
|
||||
panelsNeeded: optimal.panelsNeeded,
|
||||
costChf: optimal.costChf,
|
||||
wasteM2: optimal.wasteM2,
|
||||
wastePercent: optimal.wastePercent,
|
||||
alternatives: alternatives.map(alt => ({
|
||||
dimension: alt.dimension,
|
||||
panelsNeeded: alt.panelsNeeded,
|
||||
costChf: alt.costChf,
|
||||
wasteM2: alt.wasteM2,
|
||||
wastePercent: alt.wastePercent
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate module costs (V2 mode)
|
||||
* @param {Array} modules - Array of module configurations
|
||||
* @param {Array} moduleCatalog - Available modules from database
|
||||
* @returns {Object} - Module costs breakdown
|
||||
*/
|
||||
function calculateModuleCosts(modules, moduleCatalog) {
|
||||
let totalChf = 0;
|
||||
const breakdown = [];
|
||||
|
||||
for (const module of modules) {
|
||||
const catalogModule = moduleCatalog.find(m => m.id === module.moduleTypeId || m.code === module.moduleCode);
|
||||
const basePrice = catalogModule?.base_price_chf || 450;
|
||||
|
||||
// Adjust price based on customizations
|
||||
let adjustedPrice = basePrice;
|
||||
|
||||
// Extra shelves cost (beyond default)
|
||||
const defaultShelves = catalogModule?.shelves_count || 0;
|
||||
const extraShelves = Math.max(0, (module.shelves || 0) - defaultShelves);
|
||||
adjustedPrice += extraShelves * 25; // 25 CHF per extra shelf
|
||||
|
||||
// Extra drawers cost (beyond default)
|
||||
const defaultDrawers = catalogModule?.drawers_count || 0;
|
||||
const extraDrawers = Math.max(0, (module.drawers || 0) - defaultDrawers);
|
||||
adjustedPrice += extraDrawers * 45; // 45 CHF per extra drawer
|
||||
|
||||
// Width adjustment (if different from default)
|
||||
const defaultWidth = catalogModule?.width_mm || 600;
|
||||
const widthDiff = (module.widthMm || defaultWidth) - defaultWidth;
|
||||
if (widthDiff > 0) {
|
||||
adjustedPrice += widthDiff * 0.15; // 0.15 CHF per extra mm
|
||||
}
|
||||
|
||||
breakdown.push({
|
||||
moduleCode: module.moduleCode || catalogModule?.code,
|
||||
moduleName: catalogModule?.name || 'Module',
|
||||
basePrice,
|
||||
adjustedPrice: Math.round(adjustedPrice * 100) / 100,
|
||||
adjustments: {
|
||||
extraShelves,
|
||||
extraDrawers,
|
||||
widthDiff
|
||||
}
|
||||
});
|
||||
|
||||
totalChf += adjustedPrice;
|
||||
}
|
||||
|
||||
return {
|
||||
totalChf: Math.round(totalChf * 100) / 100,
|
||||
breakdown
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate hardware costs (V2)
|
||||
* @param {Object} hardware - Hardware selection {handleVariantId, hingeVariantId, drawerSystemId}
|
||||
* @param {Object} hardwareData - Hardware data from DB {handles, hinges, blum}
|
||||
* @param {Object} config - Config with door and drawer counts
|
||||
* @returns {Object} - Hardware costs breakdown
|
||||
*/
|
||||
function calculateHardwareCosts(hardware, hardwareData, config) {
|
||||
const { handles = [], hinges = [], blum = [] } = hardwareData;
|
||||
const breakdown = [];
|
||||
let totalChf = 0;
|
||||
|
||||
// Handle costs - one per door
|
||||
if (hardware.handleVariantId) {
|
||||
const handle = handles.find(h => h.id === hardware.handleVariantId);
|
||||
if (handle) {
|
||||
const doorCount = config.doorType === 'sliding'
|
||||
? (config.slidingPanelsCount || 2)
|
||||
: (config.modules?.length || config.blocks?.length || 2);
|
||||
const cost = handle.price_chf * doorCount;
|
||||
totalChf += cost;
|
||||
breakdown.push({
|
||||
type: 'handle',
|
||||
name: handle.name,
|
||||
unitPrice: handle.price_chf,
|
||||
quantity: doorCount,
|
||||
totalChf: cost
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Hinge costs - typically 3 per hinged door
|
||||
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 quantity = doorCount * hingesPerDoor;
|
||||
const cost = hinge.price_chf * quantity;
|
||||
totalChf += cost;
|
||||
breakdown.push({
|
||||
type: 'hinge',
|
||||
name: hinge.name,
|
||||
unitPrice: hinge.price_chf,
|
||||
quantity,
|
||||
totalChf: cost
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Drawer system costs - one per drawer
|
||||
if (hardware.drawerSystemId) {
|
||||
const system = blum.find(h => h.id === hardware.drawerSystemId && h.type === 'drawer_system');
|
||||
if (system) {
|
||||
const modules = config.modules || [];
|
||||
const drawerCount = modules.reduce((sum, m) => sum + (m.drawers || 0), 0);
|
||||
if (drawerCount > 0) {
|
||||
const cost = system.price_chf * drawerCount;
|
||||
totalChf += cost;
|
||||
breakdown.push({
|
||||
type: 'drawer_system',
|
||||
name: system.name,
|
||||
unitPrice: system.price_chf,
|
||||
quantity: drawerCount,
|
||||
totalChf: cost
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalChf: Math.round(totalChf * 100) / 100,
|
||||
breakdown
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide la configuration et calcule le prix total.
|
||||
* @param {Object} config - configuration client
|
||||
* @param {Object} data - données DB (settings, blockTypes, finishes, serviceOptions, modules, hardware)
|
||||
*/
|
||||
export function calculatePrice(config, data) {
|
||||
const errors = [];
|
||||
const settingsArr = data.settings || [];
|
||||
const settings = {};
|
||||
for (const r of settingsArr) {
|
||||
let v = r.value;
|
||||
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 {
|
||||
installationType, widthMm, heightMm, depthMm, plinthHeightMm,
|
||||
blocks = [], modules = [], hardware = {},
|
||||
doorType, slidingPanelsCount, finishId, serviceOptionIds = []
|
||||
} = config;
|
||||
|
||||
// Determine if using modules (V2) or blocks (legacy)
|
||||
const useModules = modules && modules.length > 0;
|
||||
|
||||
// Plinthe 60–160 mm
|
||||
if (plinthHeightMm < plinthMin || plinthHeightMm > plinthMax) {
|
||||
errors.push({ field: 'plinthHeightMm', message: `La hauteur de plinthe doit être entre ${plinthMin} et ${plinthMax} mm.` });
|
||||
}
|
||||
|
||||
// Portes coulissantes : largeur totale >= 1200 mm
|
||||
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' && (slidingPanelsCount < 2 || slidingPanelsCount > 3)) {
|
||||
errors.push({ field: 'slidingPanelsCount', message: 'Choisissez 2 ou 3 vantaux coulissants.' });
|
||||
}
|
||||
|
||||
// Calculate block/module costs
|
||||
let blocksChf = 0;
|
||||
let modulesBreakdown = null;
|
||||
|
||||
if (useModules) {
|
||||
// V2: Use module-based pricing
|
||||
const moduleCatalog = data.modules || [];
|
||||
const moduleCosts = calculateModuleCosts(modules, moduleCatalog);
|
||||
blocksChf = moduleCosts.totalChf;
|
||||
modulesBreakdown = moduleCosts.breakdown;
|
||||
|
||||
// Validate module widths
|
||||
const totalModuleWidth = modules.reduce((sum, m) => sum + (m.widthMm || 0), 0);
|
||||
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).` });
|
||||
}
|
||||
} else {
|
||||
// Legacy: Use block-based pricing
|
||||
const totalWidth = blocks.reduce((sum, b) => sum + (b.widthMm || 0), 0);
|
||||
if (Math.abs(totalWidth - widthMm) > 5 && blocks.length > 0) {
|
||||
errors.push({ field: 'blocks', message: 'La somme des largeurs des blocs doit correspondre à la largeur totale.' });
|
||||
}
|
||||
|
||||
const blockTypes = data.blockTypes || [];
|
||||
for (const block of blocks) {
|
||||
const bt = blockTypes.find((b) => b.id === block.blockTypeId || b.code === block.blockTypeCode);
|
||||
if (bt) blocksChf += Number(bt.base_price_chf) || 550;
|
||||
else blocksChf += 550;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate hardware costs (V2)
|
||||
let hardwareChf = 0;
|
||||
let hardwareBreakdown = null;
|
||||
|
||||
if (hardware && (hardware.handleVariantId || hardware.hingeVariantId || hardware.drawerSystemId)) {
|
||||
const hardwareData = {
|
||||
handles: data.handles || [],
|
||||
hinges: data.hinges || [],
|
||||
blum: data.blumHardware || []
|
||||
};
|
||||
const hardwareCosts = calculateHardwareCosts(hardware, hardwareData, config);
|
||||
hardwareChf = hardwareCosts.totalChf;
|
||||
hardwareBreakdown = hardwareCosts.breakdown;
|
||||
}
|
||||
|
||||
let panelsChf = 0;
|
||||
let panelOptimization = null;
|
||||
|
||||
const surfaceM2 = calculateVisibleSurface({
|
||||
...config,
|
||||
widthMm: widthMm || blocks.reduce((sum, b) => sum + (b.widthMm || 0), 0),
|
||||
plinthHeightMm: plinthHeightMm || 100,
|
||||
blocks: useModules ? modules.map(m => ({ widthMm: m.widthMm })) : blocks,
|
||||
doorType: doorType || 'hinged',
|
||||
slidingPanelsCount: slidingPanelsCount || 2
|
||||
});
|
||||
|
||||
// Find finish/material by ID or code
|
||||
const finish = (data.finishes || []).find((f) => f.id === finishId || f.code === config.finishCode);
|
||||
|
||||
if (finish) {
|
||||
const priceChf = finish.price_chf;
|
||||
|
||||
// Check if we have multiple dimension variants for optimization
|
||||
if (finish.dimensions && finish.dimensions.length > 0 && priceChf != null) {
|
||||
// Auto-optimize: find the best dimension that minimizes cost
|
||||
panelOptimization = findOptimalDimension(surfaceM2, finish.dimensions, Number(priceChf));
|
||||
|
||||
if (panelOptimization) {
|
||||
panelsChf = panelOptimization.costChf;
|
||||
}
|
||||
} else if (finish.width_mm && finish.height_mm && priceChf != null) {
|
||||
// Legacy: single dimension from finish
|
||||
const result = panelCost(surfaceM2, finish.width_mm, finish.height_mm, Number(priceChf));
|
||||
panelsChf = result.costChf;
|
||||
|
||||
// Create optimization info for legacy finishes
|
||||
const panelSurfaceM2 = (finish.width_mm * finish.height_mm) / 1_000_000;
|
||||
const wasteM2 = (result.panels * panelSurfaceM2) - surfaceM2;
|
||||
panelOptimization = {
|
||||
selectedDimension: {
|
||||
width_mm: finish.width_mm,
|
||||
height_mm: finish.height_mm,
|
||||
thickness_mm: null
|
||||
},
|
||||
panelsNeeded: result.panels,
|
||||
costChf: result.costChf,
|
||||
wasteM2: Math.round(wasteM2 * 1000) / 1000,
|
||||
wastePercent: Math.round((wasteM2 / (result.panels * panelSurfaceM2)) * 1000) / 10,
|
||||
alternatives: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let optionsChf = 0;
|
||||
const services = data.serviceOptions || [];
|
||||
for (const optId of serviceOptionIds) {
|
||||
const opt = services.find((s) => s.id === optId || s.code === optId);
|
||||
if (opt) optionsChf += Number(opt.price_chf) || 0;
|
||||
}
|
||||
|
||||
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf;
|
||||
|
||||
return {
|
||||
totalChf: Math.round(totalChf * 100) / 100,
|
||||
surfaceM2: Math.round(surfaceM2 * 1000) / 1000,
|
||||
details: {
|
||||
blocksChf,
|
||||
modulesBreakdown,
|
||||
kitChf: kitFeeChf,
|
||||
panelsChf,
|
||||
optionsChf,
|
||||
hardwareChf,
|
||||
hardwareBreakdown,
|
||||
panelOptimization
|
||||
},
|
||||
errors: errors.length ? errors : undefined
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
calculatePrice,
|
||||
calculateVisibleSurface,
|
||||
panelCost,
|
||||
findOptimalDimension,
|
||||
calculateModuleCosts,
|
||||
calculateHardwareCosts
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Room Photo Analyzer using OpenAI GPT-4 Vision
|
||||
* Analyzes room photos to extract dimensions, detect doors/windows, and suggest wardrobe placement
|
||||
*/
|
||||
import OpenAI from 'openai';
|
||||
|
||||
// Lazy initialization to avoid errors when API key is not set
|
||||
let openaiClient = null;
|
||||
|
||||
function getOpenAIClient() {
|
||||
if (!openaiClient) {
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
throw new Error('OPENAI_API_KEY is not configured. Add it to server/.env');
|
||||
}
|
||||
openaiClient = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY
|
||||
});
|
||||
}
|
||||
return openaiClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for GPT-4 Vision to analyze room photos
|
||||
*/
|
||||
const ROOM_ANALYSIS_PROMPT = `You are a professional interior designer and architect specializing in custom wardrobe installations. Analyze this room photo with a STRONG FOCUS on the visible wall surfaces (parois) and ceiling (plafond).
|
||||
|
||||
PRIORITY: Carefully examine every visible wall and the ceiling. Detect their exact colors, materials, and textures as they appear in the photo. This is critical for accurate 3D room reconstruction.
|
||||
|
||||
Return a JSON object with the following structure:
|
||||
{
|
||||
"roomDimensions": {
|
||||
"widthM": <estimated width in meters, e.g., 4.2>,
|
||||
"lengthM": <estimated length/depth in meters, e.g., 3.8>,
|
||||
"heightM": <estimated ceiling height in meters, typically 2.4-2.7>
|
||||
},
|
||||
"walls": [
|
||||
{
|
||||
"side": "back" | "left" | "right",
|
||||
"visible": <boolean - is this wall clearly visible in the photo?>,
|
||||
"color": "<EXACT hex color code sampled from the wall surface, e.g., #f5f5f0>",
|
||||
"material": "paint" | "wallpaper" | "brick" | "wood_panel" | "concrete" | "plaster" | "stone" | "tile",
|
||||
"texture": "smooth" | "textured" | "patterned" | "rough" | "glossy",
|
||||
"hasWindow": <boolean>,
|
||||
"hasDoor": <boolean>,
|
||||
"surfaceArea": <approximate visible surface area percentage 0-100, accounting for windows/doors>
|
||||
}
|
||||
],
|
||||
"windows": [
|
||||
{
|
||||
"wall": "back" | "left" | "right",
|
||||
"xPercent": <horizontal position from left edge, 0-100>,
|
||||
"yPercent": <vertical position from floor, 0-100>,
|
||||
"widthPercent": <width as percentage of wall, e.g., 25>,
|
||||
"heightPercent": <height as percentage of wall, e.g., 40>,
|
||||
"type": "window" | "french_window" | "skylight" | "bay_window"
|
||||
}
|
||||
],
|
||||
"doors": [
|
||||
{
|
||||
"wall": "back" | "left" | "right",
|
||||
"xPercent": <horizontal position from left edge, 0-100>,
|
||||
"widthPercent": <width as percentage of wall, typically 15-25>,
|
||||
"heightPercent": <height as percentage of wall, typically 80-90>,
|
||||
"type": "door" | "double_door" | "opening" | "closet" | "sliding_door"
|
||||
}
|
||||
],
|
||||
"floor": {
|
||||
"color": "<exact hex color code>",
|
||||
"material": "wood" | "tile" | "carpet" | "concrete" | "laminate" | "vinyl" | "parquet"
|
||||
},
|
||||
"ceiling": {
|
||||
"visible": <boolean - is the ceiling visible in the photo?>,
|
||||
"color": "<EXACT hex color code detected from the ceiling surface>",
|
||||
"type": "flat" | "sloped" | "beamed" | "coffered" | "vaulted",
|
||||
"material": "paint" | "plaster" | "wood" | "concrete",
|
||||
"slopeAngleDeg": <angle in degrees if sloped, 0 for flat, null if not applicable>,
|
||||
"slopeDirection": "front_to_back" | "left_to_right" | null
|
||||
},
|
||||
"lighting": {
|
||||
"natural": "bright" | "moderate" | "dim",
|
||||
"windowSide": "left" | "right" | "back" | "front" | null
|
||||
},
|
||||
"suggestedWardrobeWall": "back" | "left" | "right",
|
||||
"suggestedWardrobePosition": {
|
||||
"xPercent": <start position from left, 0-100>,
|
||||
"widthPercent": <suggested width as percentage of wall, e.g., 50>,
|
||||
"reason": "<brief explanation of why this position is recommended>"
|
||||
},
|
||||
"roomType": "bedroom" | "living_room" | "hallway" | "dressing" | "other",
|
||||
"confidence": <confidence score 0-100>
|
||||
}
|
||||
|
||||
IMPORTANT GUIDELINES FOR WALL AND CEILING DETECTION:
|
||||
- ALWAYS include all three walls (back, left, right). Set visible=false only if a wall is truly not visible at all.
|
||||
- Sample the EXACT hex color from the dominant area of each wall surface — do NOT default to #f5f5f0.
|
||||
- Pay close attention to paint color nuances: beige vs cream vs off-white vs warm grey etc.
|
||||
- Detect the wall material: painted plaster, wallpaper, exposed brick, wood paneling, etc.
|
||||
- For the ceiling: detect its actual color (it may not be pure white), its type, and material.
|
||||
- If the ceiling is sloped (mansarde/sous-pente), estimate the slope angle and direction.
|
||||
- Estimate dimensions based on standard furniture sizes if visible (beds, doors, windows).
|
||||
- Standard door height is ~2.1m, standard door width is ~0.8-0.9m.
|
||||
- Standard ceiling height is 2.4-2.7m in residential.
|
||||
- For wardrobe placement, prefer walls without windows/doors and with enough clear space.
|
||||
- Return ONLY valid JSON, no additional text.`;
|
||||
|
||||
/**
|
||||
* Analyze a room photo using GPT-4 Vision
|
||||
* @param {string} imageBase64 - Base64 encoded image data
|
||||
* @param {string} mimeType - Image MIME type (image/jpeg, image/png, etc.)
|
||||
* @returns {Object} Room analysis result
|
||||
*/
|
||||
export async function analyzeRoomPhoto(imageBase64, mimeType = 'image/jpeg') {
|
||||
const openai = getOpenAIClient();
|
||||
|
||||
try {
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: ROOM_ANALYSIS_PROMPT },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:${mimeType};base64,${imageBase64}`,
|
||||
detail: 'high'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
max_tokens: 2000,
|
||||
response_format: { type: 'json_object' }
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error('No response from OpenAI');
|
||||
}
|
||||
|
||||
const analysis = JSON.parse(content);
|
||||
|
||||
// Validate required fields
|
||||
if (!analysis.roomDimensions || !analysis.suggestedWardrobeWall) {
|
||||
throw new Error('Incomplete room analysis');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
analysis,
|
||||
tokensUsed: response.usage?.total_tokens || 0
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Room analysis error:', error);
|
||||
|
||||
if (error.code === 'invalid_api_key') {
|
||||
throw new Error('Invalid OpenAI API key');
|
||||
}
|
||||
|
||||
if (error.code === 'rate_limit_exceeded') {
|
||||
throw new Error('API rate limit exceeded. Please try again later.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert analysis to 3D room configuration for Three.js
|
||||
* @param {Object} analysis - Room analysis from GPT-4 Vision
|
||||
* @returns {Object} 3D room configuration
|
||||
*/
|
||||
export function analysisTo3DConfig(analysis) {
|
||||
const { roomDimensions, walls, windows, doors, floor, ceiling, suggestedWardrobeWall, suggestedWardrobePosition } = analysis;
|
||||
|
||||
return {
|
||||
// Room dimensions in meters
|
||||
width: roomDimensions.widthM || 4,
|
||||
length: roomDimensions.lengthM || 4,
|
||||
height: roomDimensions.heightM || 2.5,
|
||||
|
||||
// Wall configurations with material info
|
||||
walls: {
|
||||
back: (() => {
|
||||
const w = walls?.find(w => w.side === 'back');
|
||||
return w ? { color: w.color || '#f5f5f0', visible: w.visible !== false, material: w.material || 'paint', texture: w.texture || 'smooth' }
|
||||
: { color: '#f5f5f0', visible: true, material: 'paint', texture: 'smooth' };
|
||||
})(),
|
||||
left: (() => {
|
||||
const w = walls?.find(w => w.side === 'left');
|
||||
return w ? { color: w.color || '#f5f5f0', visible: w.visible !== false, material: w.material || 'paint', texture: w.texture || 'smooth' }
|
||||
: { color: '#f5f5f0', visible: true, material: 'paint', texture: 'smooth' };
|
||||
})(),
|
||||
right: (() => {
|
||||
const w = walls?.find(w => w.side === 'right');
|
||||
return w ? { color: w.color || '#f5f5f0', visible: w.visible !== false, material: w.material || 'paint', texture: w.texture || 'smooth' }
|
||||
: { color: '#f5f5f0', visible: true, material: 'paint', texture: 'smooth' };
|
||||
})()
|
||||
},
|
||||
|
||||
// Windows for each wall
|
||||
windows: (windows || []).map(w => ({
|
||||
wall: w.wall,
|
||||
x: (w.xPercent || 50) / 100,
|
||||
y: (w.yPercent || 30) / 100,
|
||||
width: (w.widthPercent || 25) / 100,
|
||||
height: (w.heightPercent || 40) / 100,
|
||||
type: w.type || 'window'
|
||||
})),
|
||||
|
||||
// Doors for each wall
|
||||
doors: (doors || []).map(d => ({
|
||||
wall: d.wall,
|
||||
x: (d.xPercent || 10) / 100,
|
||||
width: (d.widthPercent || 20) / 100,
|
||||
height: (d.heightPercent || 85) / 100,
|
||||
type: d.type || 'door'
|
||||
})),
|
||||
|
||||
// Floor and ceiling
|
||||
floor: {
|
||||
color: floor?.color || '#c4a574',
|
||||
material: floor?.material || 'wood'
|
||||
},
|
||||
ceiling: {
|
||||
color: ceiling?.color || '#ffffff',
|
||||
type: ceiling?.type || 'flat',
|
||||
visible: ceiling?.visible !== false,
|
||||
material: ceiling?.material || 'paint',
|
||||
slopeAngleDeg: ceiling?.slopeAngleDeg || 0,
|
||||
slopeDirection: ceiling?.slopeDirection || null
|
||||
},
|
||||
|
||||
// Wardrobe placement
|
||||
wardrobePlacement: {
|
||||
wall: suggestedWardrobeWall || 'back',
|
||||
xPercent: suggestedWardrobePosition?.xPercent || 25,
|
||||
widthPercent: suggestedWardrobePosition?.widthPercent || 50
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default { analyzeRoomPhoto, analysisTo3DConfig };
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Scheduler service using node-cron
|
||||
* Handles scheduled tasks like Formex sync
|
||||
*/
|
||||
import cron from 'node-cron';
|
||||
import { sync as formexSync } from './formexCrawler.js';
|
||||
|
||||
let formexJob = null;
|
||||
|
||||
/**
|
||||
* Initialize the scheduler with configured jobs
|
||||
*/
|
||||
export function initScheduler() {
|
||||
const cronExpression = process.env.FORMEX_SYNC_CRON || '0 3 * * *'; // Default: 3:00 AM daily
|
||||
|
||||
// Validate cron expression
|
||||
if (!cron.validate(cronExpression)) {
|
||||
console.error(`Invalid FORMEX_SYNC_CRON expression: ${cronExpression}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule Formex sync
|
||||
formexJob = cron.schedule(cronExpression, async () => {
|
||||
console.log(`[${new Date().toISOString()}] Running scheduled Formex sync...`);
|
||||
try {
|
||||
const result = await formexSync();
|
||||
console.log(`[${new Date().toISOString()}] Scheduled sync complete:`, result);
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toISOString()}] Scheduled sync failed:`, err.message);
|
||||
}
|
||||
}, {
|
||||
scheduled: true,
|
||||
timezone: 'Europe/Zurich' // Swiss timezone
|
||||
});
|
||||
|
||||
console.log(`Formex sync scheduled: ${cronExpression} (Europe/Zurich)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all scheduled jobs
|
||||
*/
|
||||
export function stopScheduler() {
|
||||
if (formexJob) {
|
||||
formexJob.stop();
|
||||
formexJob = null;
|
||||
console.log('Scheduler stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduler status
|
||||
*/
|
||||
export function getSchedulerStatus() {
|
||||
return {
|
||||
formexSync: {
|
||||
running: formexJob !== null,
|
||||
schedule: process.env.FORMEX_SYNC_CRON || '0 3 * * *'
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user