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