@@ -1,6 +1,10 @@
|
||||
node_modules/
|
||||
server/data/
|
||||
.env
|
||||
.claude/
|
||||
*.docx
|
||||
~$*
|
||||
~WRL*.tmp
|
||||
*.db
|
||||
*.sqlite
|
||||
.DS_Store
|
||||
|
||||
Generated
+1
-11
@@ -48,7 +48,6 @@
|
||||
"version": "7.28.6",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
@@ -300,7 +299,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -903,7 +901,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz",
|
||||
"integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.8",
|
||||
"@types/react-reconciler": "^0.26.7",
|
||||
@@ -1397,7 +1394,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
|
||||
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1422,7 +1418,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz",
|
||||
"integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
@@ -1537,7 +1532,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2013,7 +2007,6 @@
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -2036,7 +2029,6 @@
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -2262,8 +2254,7 @@
|
||||
"version": "0.169.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz",
|
||||
"integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.7.8",
|
||||
@@ -2422,7 +2413,6 @@
|
||||
"version": "5.4.21",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
|
||||
@@ -7,6 +7,7 @@ import Configurator from './pages/Configurator';
|
||||
import Login from './pages/Login';
|
||||
import Admin from './pages/Admin';
|
||||
import Materials from './pages/Materials';
|
||||
import Settings from './pages/Settings';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -19,6 +20,7 @@ export default function App() {
|
||||
<Route path="/configurateur" element={<ProtectedRoute><Configurator /></ProtectedRoute>} />
|
||||
<Route path="/materiaux" element={<ProtectedRoute><Materials /></ProtectedRoute>} />
|
||||
<Route path="/admin" element={<ProtectedRoute><Admin /></ProtectedRoute>} />
|
||||
<Route path="/parametres" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</AuthProvider>
|
||||
|
||||
+169
-3
@@ -30,6 +30,83 @@ export async function fetchSettings() {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateSettings(data) {
|
||||
const res = await fetch(`${API_BASE}/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Engineering rules CRUD
|
||||
export async function fetchEngineeringRules() {
|
||||
const res = await fetch(`${API_BASE}/engineering-rules`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createEngineeringRule(data) {
|
||||
const res = await fetch(`${API_BASE}/engineering-rules`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateEngineeringRule(id, data) {
|
||||
const res = await fetch(`${API_BASE}/engineering-rules/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteEngineeringRule(id) {
|
||||
const res = await fetch(`${API_BASE}/engineering-rules/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Suppliers CRUD
|
||||
export async function fetchSuppliers() {
|
||||
const res = await fetch(`${API_BASE}/suppliers`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createSupplier(data) {
|
||||
const res = await fetch(`${API_BASE}/suppliers`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateSupplier(id, data) {
|
||||
const res = await fetch(`${API_BASE}/suppliers/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteSupplier(id) {
|
||||
const res = await fetch(`${API_BASE}/suppliers/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
export async function calculatePrice(config) {
|
||||
const res = await fetch(`${API_BASE}/price`, {
|
||||
method: 'POST',
|
||||
@@ -46,6 +123,57 @@ export async function fetchOrders() {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchOrderSupplierVouchers(orderId) {
|
||||
const res = await fetch(`${API_BASE}/orders/${orderId}/supplier-vouchers`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchOrderPlanExports(orderId) {
|
||||
const res = await fetch(`${API_BASE}/orders/${orderId}/plan-exports`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function saveOrderPlanExport(orderId, payload) {
|
||||
const res = await fetch(`${API_BASE}/orders/${orderId}/plan-exports`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function generateOrderPlanExports(orderId) {
|
||||
const res = await fetch(`${API_BASE}/orders/${orderId}/plan-exports/generate`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchOrderProductionDetails(orderId) {
|
||||
const res = await fetch(`${API_BASE}/orders/${orderId}/production-details`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function getOrderPlanExportDownloadUrl(orderId, exportId) {
|
||||
return `${API_BASE}/orders/${orderId}/plan-exports/${exportId}/download`;
|
||||
}
|
||||
|
||||
export function getOrderPlanExportViewUrl(orderId, exportId) {
|
||||
return `${API_BASE}/orders/${orderId}/plan-exports/${exportId}/download?disposition=inline`;
|
||||
}
|
||||
|
||||
export async function deleteOrderPlanExport(orderId, exportId) {
|
||||
const res = await fetch(`${API_BASE}/orders/${orderId}/plan-exports/${exportId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
export async function submitOrder(order) {
|
||||
const res = await fetch(`${API_BASE}/orders`, {
|
||||
method: 'POST',
|
||||
@@ -70,8 +198,27 @@ export async function triggerFormexSync() {
|
||||
/**
|
||||
* Get the SSE stream URL for real-time sync progress
|
||||
*/
|
||||
export function getFormexSyncStreamUrl() {
|
||||
return `${API_BASE}/sync/formex/stream`;
|
||||
export function getFormexSyncStreamUrl(force = false) {
|
||||
const url = `${API_BASE}/sync/formex/stream`;
|
||||
return force ? `${url}?force=1` : url;
|
||||
}
|
||||
|
||||
export function getHaefeleSyncStreamUrl(urls = []) {
|
||||
const cleaned = (urls || []).map((v) => (typeof v === 'string' ? v.trim() : '')).filter(Boolean);
|
||||
const params = new URLSearchParams();
|
||||
for (const url of cleaned) params.append('url', url);
|
||||
const query = params.toString();
|
||||
return query ? `${API_BASE}/sync/haefele/stream?${query}` : `${API_BASE}/sync/haefele/stream`;
|
||||
}
|
||||
|
||||
export async function triggerHaefeleSync(payload) {
|
||||
const res = await fetch(`${API_BASE}/sync/haefele`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload || {})
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchSyncLogs(limit = 20) {
|
||||
@@ -86,13 +233,26 @@ export async function fetchSyncStatus() {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchHaefeleMaterials({ search = '', active = null, page = 1, limit = 25 } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('search', search);
|
||||
if (active !== null) params.set('active', active ? '1' : '0');
|
||||
params.set('page', String(page));
|
||||
params.set('limit', String(limit));
|
||||
|
||||
const res = await fetch(`${API_BASE}/haefele/materials?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Formex Materials CRUD
|
||||
|
||||
export async function fetchFormexMaterials({ search = '', collection = '', category = '', active = null, page = 1, limit = 25 } = {}) {
|
||||
export async function fetchFormexMaterials({ search = '', collection = '', category = '', supplier = '', active = null, page = 1, limit = 25 } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('search', search);
|
||||
if (collection) params.set('collection', collection);
|
||||
if (category) params.set('category', category);
|
||||
if (supplier) params.set('supplier', supplier);
|
||||
if (active !== null) params.set('active', active ? '1' : '0');
|
||||
params.set('page', String(page));
|
||||
params.set('limit', String(limit));
|
||||
@@ -102,6 +262,12 @@ export async function fetchFormexMaterials({ search = '', collection = '', categ
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchMaterialSuppliers() {
|
||||
const res = await fetch(`${API_BASE}/formex/suppliers`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchFormexMaterial(id) {
|
||||
const res = await fetch(`${API_BASE}/formex/materials/${id}`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
.wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #f8fafc;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.axisBar {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.axisLabel {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.axisBtn {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 5px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #334155;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-variant-numeric: tabular-nums;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.axisBtn:hover {
|
||||
background: #fff;
|
||||
border-color: #94a3b8;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.axisBtn:focus-visible {
|
||||
outline: 2px solid #6366f1;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
@@ -7,6 +7,33 @@ import styles from './ColorPickerModal.module.css';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
const PRIORITY_STORAGE_KEY = 'apex-ai-rec-priorities';
|
||||
|
||||
const DEFAULT_PRIORITIES = { price: 3, quality: 3, durability: 3, aesthetics: 3 };
|
||||
|
||||
function loadStoredPriorities() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PRIORITY_STORAGE_KEY);
|
||||
if (!raw) return { ...DEFAULT_PRIORITIES };
|
||||
const p = JSON.parse(raw);
|
||||
return {
|
||||
price: Math.min(5, Math.max(1, Number(p.price) || DEFAULT_PRIORITIES.price)),
|
||||
quality: Math.min(5, Math.max(1, Number(p.quality) || DEFAULT_PRIORITIES.quality)),
|
||||
durability: Math.min(5, Math.max(1, Number(p.durability) || DEFAULT_PRIORITIES.durability)),
|
||||
aesthetics: Math.min(5, Math.max(1, Number(p.aesthetics) || DEFAULT_PRIORITIES.aesthetics))
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_PRIORITIES };
|
||||
}
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS = {
|
||||
price: 'Prix & rapport qualité-prix',
|
||||
quality: 'Qualité & finition',
|
||||
durability: 'Durabilité & entretien',
|
||||
aesthetics: 'Esthétique & harmonie'
|
||||
};
|
||||
|
||||
const ELEMENT_LABELS = {
|
||||
plintheBas: 'Plinthe bas',
|
||||
fileur: 'Fileur',
|
||||
@@ -42,8 +69,17 @@ export default function ColorPickerModal({
|
||||
const [hoveredFinish, setHoveredFinish] = useState(null);
|
||||
const [aiRecs, setAiRecs] = useState([]);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [priorities, setPriorities] = useState(loadStoredPriorities);
|
||||
const tooltipRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(PRIORITY_STORAGE_KEY, JSON.stringify(priorities));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [priorities]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedFinish(currentFinish);
|
||||
@@ -51,18 +87,30 @@ export default function ColorPickerModal({
|
||||
setSelectedCollection('all');
|
||||
setHoveredFinish(null);
|
||||
setAiRecs([]);
|
||||
// Fetch AI recommendations
|
||||
if (config && finishes.length > 0) {
|
||||
}
|
||||
}, [isOpen, currentFinish]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setAiLoading(false);
|
||||
return undefined;
|
||||
}
|
||||
if (!config || finishes.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
setAiLoading(true);
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => {
|
||||
fetch(`${API_BASE}/api/ai/recommend-materials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
priorities,
|
||||
config: {
|
||||
width: config.totalWidth,
|
||||
height: config.height,
|
||||
depth: config.depth,
|
||||
installationType: config.installationType,
|
||||
columns: config.modules?.map(m => ({
|
||||
type: m.type || m.moduleCode,
|
||||
width: m.width
|
||||
@@ -70,22 +118,37 @@ export default function ColorPickerModal({
|
||||
materialName: config.finishLabel || null,
|
||||
doorType: config.doorType
|
||||
},
|
||||
finishes: finishes.map(f => ({ id: f.id, name: f.name || f.label, collection: f.collection, code: f.code }))
|
||||
finishes: finishes.map(f => ({
|
||||
id: f.id,
|
||||
name: f.name || f.label,
|
||||
collection: f.collection,
|
||||
code: f.code,
|
||||
price_chf: f.price_chf != null ? Number(f.price_chf) : undefined
|
||||
}))
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then(data => {
|
||||
if (data?.recommendations) {
|
||||
setAiRecs(data.recommendations);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setAiLoading(false));
|
||||
return () => controller.abort();
|
||||
}
|
||||
}
|
||||
}, [isOpen, currentFinish]);
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setAiLoading(false);
|
||||
});
|
||||
}, 320);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
controller.abort();
|
||||
};
|
||||
}, [isOpen, currentFinish, priorities, config, finishes]);
|
||||
|
||||
const setPriority = useCallback((key, value) => {
|
||||
const n = Math.min(5, Math.max(1, Number(value) || 3));
|
||||
setPriorities(prev => ({ ...prev, [key]: n }));
|
||||
}, []);
|
||||
|
||||
const collections = useMemo(() => {
|
||||
const cols = [...new Set(finishes.map(f => f.collection).filter(Boolean))];
|
||||
@@ -169,12 +232,41 @@ export default function ColorPickerModal({
|
||||
|
||||
{/* Swatch grid */}
|
||||
<div className={styles.swatchArea}>
|
||||
{/* AI Recommendations */}
|
||||
{(aiRecs.length > 0 || aiLoading) && (
|
||||
{config && finishes.length > 0 && (
|
||||
<div className={styles.priorityPanel}>
|
||||
<div className={styles.priorityTitleRow}>
|
||||
<span className={styles.aiBadge}>IA</span>
|
||||
<span className={styles.priorityTitle}>Priorités de recommandation</span>
|
||||
</div>
|
||||
<p className={styles.priorityHint}>
|
||||
1 = peu important, 5 = très important. L'IA pondère prix, qualité, durabilité et style
|
||||
et explique chaque suggestion en fonction de vos réglages.
|
||||
</p>
|
||||
<div className={styles.priorityGrid}>
|
||||
{(['price', 'quality', 'durability', 'aesthetics']).map((key) => (
|
||||
<label key={key} className={styles.priorityRow}>
|
||||
<span className={styles.priorityLabel}>{PRIORITY_LABELS[key]}</span>
|
||||
<input
|
||||
type="range"
|
||||
className={styles.priorityRange}
|
||||
min={1}
|
||||
max={5}
|
||||
step={1}
|
||||
value={priorities[key]}
|
||||
onChange={(e) => setPriority(key, e.target.value)}
|
||||
/>
|
||||
<span className={styles.priorityValue}>{priorities[key]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(aiRecs.length > 0 || aiLoading) && config && finishes.length > 0 && (
|
||||
<div className={styles.aiSection}>
|
||||
<div className={styles.aiHeader}>
|
||||
<span className={styles.aiBadge}>IA</span>
|
||||
<span className={styles.aiLabel}>Recommandations</span>
|
||||
<span className={styles.aiLabel}>Suggestions (justifiées selon vos priorités)</span>
|
||||
</div>
|
||||
{aiLoading ? (
|
||||
<div className={styles.aiShimmer}>
|
||||
@@ -184,28 +276,37 @@ export default function ColorPickerModal({
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.aiSwatches}>
|
||||
{aiRecs.map(rec => {
|
||||
{aiRecs.map((rec) => {
|
||||
const finish = finishes.find(f => f.id === rec.finishId);
|
||||
if (!finish) return null;
|
||||
const isSelected = selectedFinish?.id === finish.id;
|
||||
const summary = rec.summary || rec.reason || '';
|
||||
const detail = {
|
||||
summary,
|
||||
criteria: rec.criteria || {}
|
||||
};
|
||||
return (
|
||||
<div key={`ai-${finish.id}`} className={styles.aiSwatchCard}>
|
||||
<button
|
||||
key={`ai-${finish.id}`}
|
||||
type="button"
|
||||
className={`${styles.swatch} ${isSelected ? styles.swatchSelected : ''} ${styles.aiSwatch}`}
|
||||
onClick={() => setSelectedFinish(finish)}
|
||||
onMouseEnter={() => setHoveredFinish({ ...finish, aiReason: rec.reason })}
|
||||
onMouseEnter={() => setHoveredFinish({ ...finish, aiDetail: detail })}
|
||||
onMouseLeave={() => setHoveredFinish(null)}
|
||||
title={rec.reason}
|
||||
title={summary}
|
||||
>
|
||||
{finish.image_url ? (
|
||||
<img src={finish.image_url} alt="" className={styles.swatchImg} loading="lazy" />
|
||||
) : (
|
||||
<div className={styles.swatchColor} style={{ backgroundColor: finish.hexColor || '#ccc' }} />
|
||||
)}
|
||||
{isSelected && <span className={styles.swatchCheck}>✓</span>}
|
||||
<span className={styles.aiSwatchBadge}>★</span>
|
||||
{isSelected && <span className={styles.swatchCheck}>✓</span>}
|
||||
<span className={styles.aiSwatchBadge}>★</span>
|
||||
</button>
|
||||
{summary ? (
|
||||
<p className={styles.aiCardSummary}>{summary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -253,6 +354,7 @@ export default function ColorPickerModal({
|
||||
{/* Hover detail strip -- shows info of hovered swatch */}
|
||||
{hoveredFinish && (
|
||||
<div className={styles.hoverDetail} ref={tooltipRef}>
|
||||
<div className={styles.hoverDetailTop}>
|
||||
{hoveredFinish.image_url && (
|
||||
<img src={hoveredFinish.image_url} alt="" className={styles.hoverImg} />
|
||||
)}
|
||||
@@ -265,6 +367,43 @@ export default function ColorPickerModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hoveredFinish.aiDetail?.summary && (
|
||||
<div className={styles.aiExplain}>
|
||||
<p className={styles.aiExplainLead}>{hoveredFinish.aiDetail.summary}</p>
|
||||
{(hoveredFinish.aiDetail.criteria?.price ||
|
||||
hoveredFinish.aiDetail.criteria?.quality ||
|
||||
hoveredFinish.aiDetail.criteria?.durability ||
|
||||
hoveredFinish.aiDetail.criteria?.aesthetics) && (
|
||||
<dl className={styles.aiExplainDl}>
|
||||
{hoveredFinish.aiDetail.criteria.price ? (
|
||||
<>
|
||||
<dt>Prix</dt>
|
||||
<dd>{hoveredFinish.aiDetail.criteria.price}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{hoveredFinish.aiDetail.criteria.quality ? (
|
||||
<>
|
||||
<dt>Qualité</dt>
|
||||
<dd>{hoveredFinish.aiDetail.criteria.quality}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{hoveredFinish.aiDetail.criteria.durability ? (
|
||||
<>
|
||||
<dt>Durabilité</dt>
|
||||
<dd>{hoveredFinish.aiDetail.criteria.durability}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{hoveredFinish.aiDetail.criteria.aesthetics ? (
|
||||
<>
|
||||
<dt>Style</dt>
|
||||
<dd>{hoveredFinish.aiDetail.criteria.aesthetics}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -220,8 +220,9 @@
|
||||
/* --- Hover detail strip (bottom of swatch area) --- */
|
||||
.hoverDetail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 8px 10px;
|
||||
margin-top: 8px;
|
||||
background: var(--bg-subtle);
|
||||
@@ -230,6 +231,12 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hoverDetailTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hoverImg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@@ -411,9 +418,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* AI Recommendations section */
|
||||
/* AI Recommendations section — compact strip (swatches use fixed size, not grid) */
|
||||
.aiSection {
|
||||
padding: 8px 12px;
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 8px;
|
||||
background: linear-gradient(135deg, #f8f7ff 0%, #eef2ff 100%);
|
||||
border-radius: 8px;
|
||||
@@ -446,12 +455,17 @@
|
||||
|
||||
.aiSwatches {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Override .swatch { width: 100% } — in flex layout that made each rec span the full row */
|
||||
.aiSwatch {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
max-width: 40px;
|
||||
flex: 0 0 40px;
|
||||
border-color: rgba(79, 70, 229, 0.3) !important;
|
||||
}
|
||||
|
||||
@@ -477,8 +491,8 @@
|
||||
}
|
||||
|
||||
.aiShimmerDot {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
|
||||
background-size: 200% 100%;
|
||||
@@ -489,3 +503,126 @@
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* IA — priorités utilisateur */
|
||||
.priorityPanel {
|
||||
padding: 8px 10px 10px;
|
||||
margin-bottom: 8px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
align-self: flex-start;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.priorityTitleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.priorityTitle {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.priorityHint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.priorityGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.priorityGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.priorityRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(60px, 1fr) 22px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.priorityLabel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.priorityRange {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
accent-color: var(--action, #4f46e5);
|
||||
}
|
||||
|
||||
.priorityValue {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--action, #4f46e5);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.aiSwatchCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 152px;
|
||||
}
|
||||
|
||||
.aiCardSummary {
|
||||
margin: 0;
|
||||
font-size: 0.65rem;
|
||||
line-height: 1.3;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.aiExplain {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.aiExplainLead {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.aiExplainDl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(52px, auto) 1fr;
|
||||
gap: 4px 10px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.aiExplainDl dt {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.aiExplainDl dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,334 @@
|
||||
* CuttingListTable - Display cutting list in a table format
|
||||
* Shows pieces grouped by finish with dimensions and quantities
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM,
|
||||
CARCASS_FASTENERS,
|
||||
CONNECTORS,
|
||||
tourillonFaceDrillDepthMm
|
||||
} from '@shared/manufacturing.js';
|
||||
import Cnc3DViewer, { getCncPartsForPiece } from './Cnc3DViewer';
|
||||
import styles from './CuttingListTable.module.css';
|
||||
|
||||
export default function CuttingListTable({ cuttingList, onExport }) {
|
||||
const CNC_MODAL_STORAGE_KEY = 'apex.cnc3d.modal';
|
||||
|
||||
/** Must match server `generatePiecePlanSvg` in cuttingEngine.js (multi-view row layout). */
|
||||
const PIECE_PLAN_LAYOUT = {
|
||||
margin: 28,
|
||||
viewW: 330,
|
||||
viewH: 220,
|
||||
panelGap: 24,
|
||||
yA: 42
|
||||
};
|
||||
|
||||
function getPieceDepthMm(piece, cuttingListConfigDepthMm) {
|
||||
const t = piece?.elementType;
|
||||
const width = piece?.widthMm;
|
||||
const height = piece?.heightMm;
|
||||
|
||||
// For readability in the cutting list, "Profondeur" is the dimension along wardrobe depth.
|
||||
// Depending on the panel type, this maps to either width or height.
|
||||
const depthIsWidth = new Set(['cotes', 'cotesDoublage', 'fileur', 'habillageFinitionGauche', 'habillageFinitionDroite']);
|
||||
const depthIsHeight = new Set(['dessus', 'fond', 'habillageFinitionDessus']);
|
||||
|
||||
if (depthIsWidth.has(t)) return width ?? null;
|
||||
if (depthIsHeight.has(t)) return height ?? null;
|
||||
|
||||
const configDepth = Number(cuttingListConfigDepthMm);
|
||||
if (Number.isFinite(configDepth) && configDepth > 0) {
|
||||
if (Number(width) === configDepth) return width;
|
||||
if (Number(height) === configDepth) return height;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Nombre d'exemplaires MPR pour une ligne (aligné sur le serveur). */
|
||||
function countMprInstancesForPiece(piece) {
|
||||
const qty = Math.max(1, Math.round(Number(piece?.quantity) || 1));
|
||||
if (
|
||||
piece?.elementType === 'cotes' &&
|
||||
qty === 2 &&
|
||||
piece.id === 'piece-cotes' &&
|
||||
String(piece.description || '').includes('gauche et droit')
|
||||
) {
|
||||
return 2;
|
||||
}
|
||||
return qty;
|
||||
}
|
||||
|
||||
function getPiecePlanPanelPercents(svgEl) {
|
||||
let vbW = PIECE_PLAN_LAYOUT.margin + 3 * PIECE_PLAN_LAYOUT.viewW + 2 * PIECE_PLAN_LAYOUT.panelGap + PIECE_PLAN_LAYOUT.margin;
|
||||
let vbH = 478;
|
||||
if (svgEl) {
|
||||
if (svgEl.viewBox && svgEl.viewBox.baseVal.width > 0) {
|
||||
vbW = svgEl.viewBox.baseVal.width;
|
||||
vbH = svgEl.viewBox.baseVal.height;
|
||||
} else if (svgEl.width && svgEl.width.baseVal) {
|
||||
vbW = svgEl.width.baseVal.value;
|
||||
vbH = svgEl.height?.baseVal?.value || vbH;
|
||||
}
|
||||
}
|
||||
const { margin, viewW, panelGap, yA, viewH } = PIECE_PLAN_LAYOUT;
|
||||
const xA = margin;
|
||||
const xB = xA + viewW + panelGap;
|
||||
const xC = xB + viewW + panelGap;
|
||||
return {
|
||||
vbW,
|
||||
vbH,
|
||||
panels: [
|
||||
{ leftPct: (xA / vbW) * 100, widthPct: (viewW / vbW) * 100, topPct: (yA / vbH) * 100, heightPct: (viewH / vbH) * 100 },
|
||||
{ leftPct: (xB / vbW) * 100, widthPct: (viewW / vbW) * 100, topPct: (yA / vbH) * 100, heightPct: (viewH / vbH) * 100 },
|
||||
{ leftPct: (xC / vbW) * 100, widthPct: (viewW / vbW) * 100, topPct: (yA / vbH) * 100, heightPct: (viewH / vbH) * 100 }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export default function CuttingListTable({ cuttingList, onExportMprPiece, cncProgram = '', planRestoreKey = '' }) {
|
||||
const [previewPiece, setPreviewPiece] = useState(null);
|
||||
const [cncModal, setCncModal] = useState(null); // { piece, segments, instanceIndex }
|
||||
const cncRestoreDone = useRef(false);
|
||||
const [lightbox, setLightbox] = useState(null); // { views, index, title }
|
||||
const piecePlanHostRef = useRef(null);
|
||||
const [planPanelLayout, setPlanPanelLayout] = useState(() => getPiecePlanPanelPercents(null));
|
||||
const [mprInstanceByPieceId, setMprInstanceByPieceId] = useState({});
|
||||
const [mprExportingPieceId, setMprExportingPieceId] = useState(null);
|
||||
|
||||
function openPiecePlan(piece) {
|
||||
if (!piece?.piecePlanSvg) return;
|
||||
setPreviewPiece(piece);
|
||||
}
|
||||
|
||||
function openCnc3d(piece) {
|
||||
const segments = getCncPartsForPiece(cuttingList, piece, cncProgram);
|
||||
if (!segments.length) return;
|
||||
setCncModal({ piece, segments, instanceIndex: 0 });
|
||||
}
|
||||
|
||||
async function handleExportMprRow(piece) {
|
||||
if (!onExportMprPiece || !piece?.id) return;
|
||||
const instChoice = mprInstanceByPieceId[piece.id] ?? 'all';
|
||||
const instanceIndex = instChoice === 'all' ? null : Number(instChoice);
|
||||
setMprExportingPieceId(piece.id);
|
||||
try {
|
||||
await onExportMprPiece(piece, instanceIndex);
|
||||
} finally {
|
||||
setMprExportingPieceId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function closeCncModal() {
|
||||
setCncModal(null);
|
||||
try {
|
||||
sessionStorage.removeItem(CNC_MODAL_STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!planRestoreKey || !cuttingList?.pieces?.length || !cncProgram) return;
|
||||
if (cncRestoreDone.current) return;
|
||||
cncRestoreDone.current = true;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(CNC_MODAL_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const data = JSON.parse(raw);
|
||||
if (data.configKey !== planRestoreKey) {
|
||||
sessionStorage.removeItem(CNC_MODAL_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
const piece = cuttingList.pieces.find((p) => p.id === data.pieceId);
|
||||
if (!piece) return;
|
||||
const segments = getCncPartsForPiece(cuttingList, piece, cncProgram);
|
||||
if (!segments.length) return;
|
||||
const idx = Math.min(Math.max(0, Number(data.instanceIndex) || 0), segments.length - 1);
|
||||
setCncModal({ piece, segments, instanceIndex: idx });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [cuttingList, cncProgram, planRestoreKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!planRestoreKey || !cncModal) return;
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
CNC_MODAL_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
pieceId: cncModal.piece.id,
|
||||
instanceIndex: cncModal.instanceIndex,
|
||||
configKey: planRestoreKey
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [cncModal, planRestoreKey]);
|
||||
|
||||
function getPieceMaterialColor(piece) {
|
||||
const explicit =
|
||||
piece?.hexColor ||
|
||||
piece?.colorHex ||
|
||||
piece?.finishHexColor ||
|
||||
piece?.finishColor ||
|
||||
piece?.finish?.hexColor ||
|
||||
piece?.finish?.colorHex;
|
||||
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
|
||||
|
||||
const key = String(piece?.finishLabel || piece?.finishId || piece?.elementType || 'material');
|
||||
let hash = 0;
|
||||
for (let i = 0; i < key.length; i++) hash = ((hash << 5) - hash) + key.charCodeAt(i);
|
||||
const palette = ['#c4a574', '#b89a6a', '#a98a5e', '#d4c4a4', '#b09060', '#8b7355'];
|
||||
return palette[Math.abs(hash) % palette.length];
|
||||
}
|
||||
|
||||
function buildManufacturingRows(piece) {
|
||||
const m = piece?.machining || {};
|
||||
const rows = [
|
||||
['Dimensions finies', `${piece.widthMm} x ${piece.heightMm} x ${piece.thicknessMm} mm`],
|
||||
['Dimensions brutes CNC', `${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm`],
|
||||
['Chants', `${piece.edgeBanding?.sides || 0} côté(s) · épaisseur ${piece.edgeBanding?.thickness || 0} mm`]
|
||||
];
|
||||
|
||||
if (m.system32) {
|
||||
rows.push([
|
||||
'System 32',
|
||||
`Ø${m.system32.holeDiameter} · ${m.system32.throughHole ? 'traversant' : `prof. ${m.system32.holeDepth || 12} mm`} · pas ${m.system32.pitch || 32} mm · rangées ${m.system32.rows || 2}`
|
||||
]);
|
||||
}
|
||||
|
||||
if (m.hingeCups) {
|
||||
rows.push([
|
||||
'Cuvettes charnières',
|
||||
`Ø${m.hingeCups.diameter} · prof. ${m.hingeCups.cupDepth} mm · positions ${m.hingeCups.positionsFromTop?.map(v => `${Math.round(v)}mm`).join(', ') || '-'}`
|
||||
]);
|
||||
}
|
||||
|
||||
if (m.carcassFasteners?.positionsAlongJointMm?.length) {
|
||||
const cf = m.carcassFasteners;
|
||||
const axes = (cf.positionsAlongJointMm || []).map((v) => `${Math.round(v)}`).join(', ');
|
||||
const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset;
|
||||
const nF = cf.positionsAlongJointMm.length;
|
||||
const pat =
|
||||
nF >= 3
|
||||
? '3 ferrures/jonction (50 / milieu / 50)'
|
||||
: nF === 2
|
||||
? `2 ferrures/jonction (50/50, sans milieu si arête ≤ ${CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM} mm)`
|
||||
: `${nF} ferrure(s)/jonction`;
|
||||
rows.push([
|
||||
'Ferrures corps (jonctions côtés/séparateurs)',
|
||||
`${cf.jointCount || 1} jonctions · ${pat} · centres ${axes} mm · perçage ferrure ${cf.ferrureDepthAlongLengthMm ?? 46}/${cf.ferrureDepthInThicknessMm ?? 12} mm · +2 tourillons Ø${cf.dowelDiameter || 8}×${tourillonFaceDrillDepthMm(piece.thicknessMm)} (±${off} mm) par ferrure`
|
||||
]);
|
||||
}
|
||||
|
||||
if (m.connectorHoles) {
|
||||
const edge = CONNECTORS.jointPatternEdge || m.connectorHoles.jointPattern;
|
||||
const offMid = CONNECTORS.jointPatternMiddleTourillonOffsetFromIxMm ?? 30;
|
||||
let patternNote = '';
|
||||
const ixJ = m.connectorHoles.ixconnectJointsAlongDepth;
|
||||
if (ixJ?.length && ['dessus', 'fond'].includes(piece.elementType)) {
|
||||
patternNote = ` · Axes (bord avant profondeur): extrémités T@${edge?.tourillon1Axis}·IX@${edge?.ixconnectAxis}·T@${edge?.tourillon2Axis} · internes IX milieu + T ±${offMid} mm (${ixJ.length} jonction(s))`;
|
||||
} else if (m.connectorHoles.jointPattern && edge) {
|
||||
patternNote = ` · Pattern arête T@${edge.tourillon1Axis}·IX@${edge.ixconnectAxis}·T@${edge.tourillon2Axis} mm (depuis bord avant)`;
|
||||
}
|
||||
rows.push([
|
||||
'Connecteurs Ixconnect',
|
||||
`About Ø${m.connectorHoles.edgeHole?.diameter || 8} prof ${m.connectorHoles.edgeHole?.depth || 45} mm · Face Ø${m.connectorHoles.faceHole?.diameter || 8} prof ${m.connectorHoles.faceHole?.depth || 12} mm${patternNote}`
|
||||
]);
|
||||
}
|
||||
|
||||
if (m.groove) {
|
||||
const groovePos = m.groove.axisFromPanelEdge || m.groove.axisFromBackEdge || 16;
|
||||
rows.push([
|
||||
'Rainure',
|
||||
`${m.groove.width || 9} x ${m.groove.depth || 10.5} mm · position à ${groovePos} mm du bord`
|
||||
]);
|
||||
}
|
||||
|
||||
if (m.fullHeightHandleGroove) {
|
||||
const off = m.fullHeightHandleGroove.topBottomOffsetMm ?? 2;
|
||||
const gw = m.fullHeightHandleGroove.grooveWidthMm ?? 9;
|
||||
const gd = m.fullHeightHandleGroove.grooveDepthMm ?? 10.5;
|
||||
const useful = Math.max(0, (piece.heightMm || 0) - 2 * off);
|
||||
rows.push([
|
||||
'Rainure poignée TH',
|
||||
`${gw} × ${gd} mm (larg. × prof.) · retraits ${off} mm haut/bas · longueur utile rainure ${Math.round(useful)} mm`
|
||||
]);
|
||||
}
|
||||
|
||||
if (m.finishConnectors) {
|
||||
const fc = m.finishConnectors;
|
||||
rows.push([
|
||||
'SC 8/25 (finition)',
|
||||
`Face Ø${fc.faceHole?.diameter || 8} · prof. ${fc.faceHole?.depth || 12.5} mm · axes arrière ${fc.axes?.back ?? 30} / avant ${fc.axes?.front ?? 43} mm (depuis face intérieure)`
|
||||
]);
|
||||
}
|
||||
|
||||
if (m.reinforcementFixing) {
|
||||
const rf = m.reinforcementFixing;
|
||||
const ix = rf.ixconnectAxisFromTopMm != null ? ` · Ixconnect ${rf.connectorModel || 'SC 8/60'} axe ${rf.ixconnectAxisFromTopMm} mm (depuis haut)` : '';
|
||||
const axes = rf.tourillonAxesFromTopMm || rf.tourillonAxes || [];
|
||||
const sides = rf.sideCount || 2;
|
||||
rows.push([
|
||||
'Fixation renfort',
|
||||
`${sides} chants extrémité · par chant: ${rf.connectorModel || 'SC 8/60'}${ix} + ${rf.tourillonsPerSide || rf.tourillonCount || 2} tourillons Ø${rf.tourillonDiameter || 8} · axes ${axes.join(', ')} mm`
|
||||
]);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function openViewLightbox(piece, index = 0) {
|
||||
const views = Array.isArray(piece?.piecePlanViews) ? piece.piecePlanViews : [];
|
||||
if (!views.length) return;
|
||||
setLightbox({ views, index, title: piece.description });
|
||||
}
|
||||
|
||||
function shiftLightbox(step) {
|
||||
if (!lightbox) return;
|
||||
const total = lightbox.views.length;
|
||||
const next = (lightbox.index + step + total) % total;
|
||||
setLightbox({ ...lightbox, index: next });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!lightbox) return undefined;
|
||||
const onKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setLightbox(null);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
setLightbox((prev) => {
|
||||
if (!prev) return prev;
|
||||
const total = prev.views.length;
|
||||
return { ...prev, index: (prev.index + 1) % total };
|
||||
});
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
setLightbox((prev) => {
|
||||
if (!prev) return prev;
|
||||
const total = prev.views.length;
|
||||
return { ...prev, index: (prev.index - 1 + total) % total };
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [lightbox]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!previewPiece?.piecePlanSvg) return undefined;
|
||||
const host = piecePlanHostRef.current;
|
||||
if (!host) return undefined;
|
||||
const sync = () => {
|
||||
const svg = host.querySelector('svg');
|
||||
setPlanPanelLayout(getPiecePlanPanelPercents(svg));
|
||||
};
|
||||
sync();
|
||||
const ro = new ResizeObserver(sync);
|
||||
ro.observe(host);
|
||||
return () => ro.disconnect();
|
||||
}, [previewPiece]);
|
||||
|
||||
if (!cuttingList || !cuttingList.pieces) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
@@ -41,7 +365,7 @@ export default function CuttingListTable({ cuttingList, onExport }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tables by finish */}
|
||||
<div className={styles.tableScrollArea}>
|
||||
{byFinish.map((group, groupIndex) => (
|
||||
<div key={groupIndex} className={styles.finishGroup}>
|
||||
<h4 className={styles.finishTitle}>
|
||||
@@ -57,18 +381,37 @@ export default function CuttingListTable({ cuttingList, onExport }) {
|
||||
<th>Description</th>
|
||||
<th>Largeur</th>
|
||||
<th>Hauteur</th>
|
||||
<th title="Dimension dans la profondeur de l'armoire">Profondeur</th>
|
||||
<th>Ép.</th>
|
||||
<th>Qté</th>
|
||||
<th>Surface</th>
|
||||
<th>Chant</th>
|
||||
<th>Perçages</th>
|
||||
<th className={styles.cncHeader} title="Visualisation CNC 3D">
|
||||
3D
|
||||
</th>
|
||||
{onExportMprPiece && <th className={styles.mprHeader}>MPR</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.pieces.map((piece, pieceIndex) => (
|
||||
{group.pieces.map((piece, pieceIndex) => {
|
||||
const cncSegments = cncProgram ? getCncPartsForPiece(cuttingList, piece, cncProgram) : [];
|
||||
const depthMm = getPieceDepthMm(piece, cuttingList?.config?.depthMm);
|
||||
const widthMm = piece?.widthMm ?? null;
|
||||
const heightMm = piece?.heightMm ?? null;
|
||||
const depthNum = depthMm == null ? null : Number(depthMm);
|
||||
const widthNum = widthMm == null ? null : Number(widthMm);
|
||||
const heightNum = heightMm == null ? null : Number(heightMm);
|
||||
const hideWidth = depthNum != null && widthNum != null && depthNum === widthNum;
|
||||
const hideHeight = depthNum != null && heightNum != null && depthNum === heightNum;
|
||||
const mprInstanceCount = countMprInstancesForPiece(piece);
|
||||
const mprBusy = mprExportingPieceId === piece.id;
|
||||
return (
|
||||
<tr key={pieceIndex}>
|
||||
<td className={styles.descCell}>{piece.description}</td>
|
||||
<td className={styles.dimCell}>{piece.widthMm}</td>
|
||||
<td className={styles.dimCell}>{piece.heightMm}</td>
|
||||
<td className={styles.dimCell}>{hideWidth ? '—' : (widthMm ?? '—')}</td>
|
||||
<td className={styles.dimCell}>{hideHeight ? '—' : (heightMm ?? '—')}</td>
|
||||
<td className={styles.dimCell}>{depthMm ?? '—'}</td>
|
||||
<td className={styles.dimCell}>{piece.thicknessMm}</td>
|
||||
<td className={styles.qtyCell}>{piece.quantity}</td>
|
||||
<td className={styles.surfaceCell}>{piece.surfaceM2.toFixed(3)}</td>
|
||||
@@ -79,28 +422,232 @@ export default function CuttingListTable({ cuttingList, onExport }) {
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{piece.piecePlanSvg ? (
|
||||
<button type="button" className={styles.planBtn} onClick={() => openPiecePlan(piece)}>
|
||||
Voir plan
|
||||
</button>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className={styles.cncCell}>
|
||||
{cncSegments.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cncBtn}
|
||||
onClick={() => openCnc3d(piece)}
|
||||
title="Visualiser CNC 3D"
|
||||
aria-label={`CNC 3D — ${piece.description}`}
|
||||
>
|
||||
3D
|
||||
</button>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
{onExportMprPiece && (
|
||||
<td className={styles.mprCell}>
|
||||
<div className={styles.mprCellInner}>
|
||||
{mprInstanceCount > 1 && (
|
||||
<select
|
||||
className={styles.mprInstanceSelect}
|
||||
value={mprInstanceByPieceId[piece.id] ?? 'all'}
|
||||
disabled={mprBusy}
|
||||
onChange={(e) =>
|
||||
setMprInstanceByPieceId((prev) => ({
|
||||
...prev,
|
||||
[piece.id]: e.target.value
|
||||
}))
|
||||
}
|
||||
aria-label={`Exemplaire MPR pour ${piece.description}`}
|
||||
title="Exemplaire à exporter"
|
||||
>
|
||||
<option value="all">T.{mprInstanceCount}</option>
|
||||
{Array.from({ length: mprInstanceCount }, (_, i) => (
|
||||
<option key={i} value={String(i)}>
|
||||
{i + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.mprBtn}
|
||||
disabled={mprBusy}
|
||||
onClick={() => handleExportMprRow(piece)}
|
||||
title="Exporter MPR"
|
||||
aria-label={`Exporter MPR — ${piece.description}`}
|
||||
>
|
||||
{mprBusy ? '…' : 'MPR'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{cncModal && (
|
||||
<div className={styles.previewOverlay} onClick={closeCncModal}>
|
||||
<div className={styles.cncModal} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.previewHeader}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.previewClose}
|
||||
onClick={closeCncModal}
|
||||
aria-label="Fermer la prévisualisation CNC"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h4>{cncModal.piece.description}</h4>
|
||||
<div className={styles.previewMeta}>
|
||||
Parcours outil (G-code) · Qté {cncModal.piece.quantity}
|
||||
{cncModal.segments.length > 1 && (
|
||||
<span className={styles.cncInstanceWrap}>
|
||||
{' · '}
|
||||
<label htmlFor="cnc-instance" className={styles.cncInstanceLabel}>Exemplaire</label>
|
||||
<select
|
||||
id="cnc-instance"
|
||||
className={styles.cncInstanceSelect}
|
||||
value={cncModal.instanceIndex}
|
||||
onChange={(e) => {
|
||||
const instanceIndex = Number(e.target.value);
|
||||
setCncModal((prev) => (prev ? { ...prev, instanceIndex } : prev));
|
||||
}}
|
||||
>
|
||||
{cncModal.segments.map((seg, i) => (
|
||||
<option key={seg.id} value={i}>
|
||||
{i + 1} / {cncModal.segments.length}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.cncModalBody}>
|
||||
<div className={styles.cncViewerCol}>
|
||||
<Cnc3DViewer
|
||||
gcode={cncModal.segments[cncModal.instanceIndex]?.gcode || ''}
|
||||
piece={cncModal.piece}
|
||||
materialColor={getPieceMaterialColor(cncModal.piece)}
|
||||
orbitStorageKey={`${planRestoreKey}:${cncModal.piece.id}:${cncModal.instanceIndex}`}
|
||||
/>
|
||||
<p className={styles.cncModalHint}>
|
||||
Prévisualisation 3D du parcours pour cette pièce. Glisser pour tourner, molette pour zoomer, clic droit
|
||||
pour déplacer.
|
||||
</p>
|
||||
</div>
|
||||
<pre className={styles.cncCodeBlock}>
|
||||
{cncModal.segments[cncModal.instanceIndex]?.gcode || '; —'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewPiece && (
|
||||
<div className={styles.previewOverlay} onClick={() => setPreviewPiece(null)}>
|
||||
<div className={styles.previewModal} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.previewHeader}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.previewClose}
|
||||
onClick={() => setPreviewPiece(null)}
|
||||
aria-label="Fermer l'aperçu du plan"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h4>{previewPiece.description}</h4>
|
||||
<div className={styles.previewMeta}>
|
||||
Fini: {previewPiece.widthMm} x {previewPiece.heightMm} x {previewPiece.thicknessMm} mm
|
||||
{' · '}
|
||||
Brut CNC: {previewPiece.rawCutWidthMm} x {previewPiece.rawCutHeightMm} mm
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.previewCanvasWrap}>
|
||||
<div
|
||||
className={`${styles.previewContent} ${previewPiece?.piecePlanViews?.length ? styles.previewContentClickable : ''}`}
|
||||
>
|
||||
<div ref={piecePlanHostRef} className={styles.piecePlanHost}>
|
||||
<div dangerouslySetInnerHTML={{ __html: previewPiece.piecePlanSvg }} />
|
||||
{!!previewPiece?.piecePlanViews?.length &&
|
||||
previewPiece.piecePlanViews.map((view, idx) => {
|
||||
const p = planPanelLayout.panels[idx];
|
||||
if (!p) return null;
|
||||
return (
|
||||
<div
|
||||
key={view.id || idx}
|
||||
className={styles.viewOverlayPanel}
|
||||
style={{
|
||||
left: `${p.leftPct}%`,
|
||||
width: `${p.widthPct}%`,
|
||||
top: `${p.topPct}%`,
|
||||
height: `${p.heightPct}%`
|
||||
}}
|
||||
onClick={() => openViewLightbox(previewPiece, idx)}
|
||||
title={`Agrandir ${view.label || `Vue ${idx + 1}`}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.viewIconBtn}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openViewLightbox(previewPiece, idx);
|
||||
}}
|
||||
title={`Plein écran ${view.label || `Vue ${idx + 1}`}`}
|
||||
>
|
||||
<span className={styles.viewIconGlyph}>⛶</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.previewTech}>
|
||||
<h5>Informations fabrication</h5>
|
||||
<table className={styles.previewTable}>
|
||||
<tbody>
|
||||
{buildManufacturingRows(previewPiece).map(([label, value]) => (
|
||||
<tr key={label}>
|
||||
<th>{label}</th>
|
||||
<td>{value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Export buttons */}
|
||||
{onExport && (
|
||||
<div className={styles.exportActions}>
|
||||
<button
|
||||
className={styles.exportBtn}
|
||||
onClick={() => onExport('text')}
|
||||
>
|
||||
Exporter en texte
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.exportBtn} ${styles.primary}`}
|
||||
onClick={() => onExport('pdf')}
|
||||
>
|
||||
Exporter en PDF
|
||||
</button>
|
||||
{lightbox && (
|
||||
<div className={styles.lightboxOverlay} onClick={() => setLightbox(null)}>
|
||||
<div className={styles.lightboxModal} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.lightboxHeader}>
|
||||
<div>
|
||||
<strong>{lightbox.title}</strong>
|
||||
<div className={styles.previewMeta}>
|
||||
{lightbox.views[lightbox.index]?.label} - {lightbox.views[lightbox.index]?.title}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className={styles.previewClose} onClick={() => setLightbox(null)}>×</button>
|
||||
</div>
|
||||
<div
|
||||
className={styles.lightboxContent}
|
||||
dangerouslySetInnerHTML={{ __html: lightbox.views[lightbox.index]?.svg || '' }}
|
||||
/>
|
||||
<div className={styles.lightboxFooter}>
|
||||
<button type="button" className={styles.planBtn} onClick={() => shiftLightbox(-1)}>Précédent</button>
|
||||
<span className={styles.lightboxCounter}>{lightbox.index + 1} / {lightbox.views.length}</span>
|
||||
<button type="button" className={styles.planBtn} onClick={() => shiftLightbox(1)}>Suivant</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty {
|
||||
@@ -14,6 +19,15 @@
|
||||
background: #f0f7ff;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #cce0ff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tableScrollArea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.summaryTitle {
|
||||
@@ -51,7 +65,7 @@
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.finishGroup:last-of-type {
|
||||
.finishGroup:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@@ -80,10 +94,18 @@
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
min-width: 820px;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.table th {
|
||||
padding: 8px 10px;
|
||||
background: #f9fafb;
|
||||
@@ -127,6 +149,419 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.planBtn {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #1e3a8a;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.planBtn:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.cncCell {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
width: 2.75rem;
|
||||
max-width: 2.75rem;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.table th.cncHeader {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
width: 2.75rem;
|
||||
max-width: 2.75rem;
|
||||
white-space: nowrap;
|
||||
z-index: 3;
|
||||
padding: 6px 2px !important;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.cncBtn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 2.5rem;
|
||||
margin: 0 auto;
|
||||
padding: 2px 0;
|
||||
border: 1px solid #c4b5fd;
|
||||
border-radius: 3px;
|
||||
background: #f5f3ff;
|
||||
color: #5b21b6;
|
||||
cursor: pointer;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.cncBtn:hover {
|
||||
background: #ede9fe;
|
||||
}
|
||||
|
||||
.table th.mprHeader {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
width: 2.75rem;
|
||||
max-width: 2.75rem;
|
||||
white-space: nowrap;
|
||||
z-index: 3;
|
||||
padding: 6px 2px !important;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.mprCell {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
width: 2.75rem;
|
||||
max-width: 2.75rem;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.mprCellInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
max-width: 2.5rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.mprInstanceSelect {
|
||||
width: 100%;
|
||||
padding: 1px 0;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 3px;
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.2;
|
||||
color: #374151;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mprBtn {
|
||||
width: 100%;
|
||||
padding: 2px 0;
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 3px;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
cursor: pointer;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.mprBtn:hover:not(:disabled) {
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.mprBtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.table th.cncHeader,
|
||||
.table th.mprHeader,
|
||||
.cncCell,
|
||||
.mprCell {
|
||||
width: 3rem;
|
||||
max-width: 3rem;
|
||||
}
|
||||
|
||||
.cncBtn {
|
||||
max-width: 2.75rem;
|
||||
}
|
||||
|
||||
.mprCellInner {
|
||||
max-width: 2.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.cncModal {
|
||||
width: min(95vw, 960px);
|
||||
max-height: 90vh;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.cncModalBody {
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px 16px 16px;
|
||||
overflow: auto;
|
||||
max-height: min(72vh, 640px);
|
||||
}
|
||||
|
||||
.cncViewerCol {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cncModalHint {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.cncCodeBlock {
|
||||
margin: 0;
|
||||
min-height: 200px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.cncInstanceWrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cncInstanceLabel {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cncInstanceSelect {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #cbd5e1;
|
||||
font-size: 0.82rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cncModalBody {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.previewOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(17, 24, 39, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.previewModal {
|
||||
width: min(95vw, 1100px);
|
||||
max-height: 90vh;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.previewHeader {
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-right: 44px;
|
||||
}
|
||||
|
||||
.previewHeader h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.previewMeta {
|
||||
color: #6b7280;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.previewClose {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.previewClose:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.previewCanvasWrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.previewContent {
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.previewContentClickable {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.piecePlanHost {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.piecePlanHost :global(svg) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.viewOverlayPanel {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
cursor: zoom-in;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.viewIconBtn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 999px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #1e3a8a;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.viewIconBtn:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.viewIconGlyph {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.previewTech {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding: 12px 16px 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.previewTech h5 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.previewTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.previewTable th,
|
||||
.previewTable td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.previewTable th {
|
||||
width: 220px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lightboxOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(2, 6, 23, 0.72);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1300;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.lightboxModal {
|
||||
width: min(96vw, 1280px);
|
||||
max-height: 94vh;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lightboxHeader {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding: 10px 16px;
|
||||
position: relative;
|
||||
padding-right: 44px;
|
||||
}
|
||||
|
||||
.lightboxContent {
|
||||
overflow: auto;
|
||||
background: #f8fafc;
|
||||
padding: 10px;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.lightboxFooter {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lightboxCounter {
|
||||
font-size: 0.85rem;
|
||||
color: #475569;
|
||||
min-width: 54px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.edgeBadge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
@@ -145,46 +580,28 @@
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.exportActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.exportBtn {
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.exportBtn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.exportBtn.primary {
|
||||
background: #4f46e5;
|
||||
border-color: #4f46e5;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.exportBtn.primary:hover {
|
||||
background: #4338ca;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.table {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.table th.mprHeader,
|
||||
.mprCell {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
z-index: 3;
|
||||
box-shadow: -4px 0 8px -4px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.table th.mprHeader {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.mprCell {
|
||||
background: #fff;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.descCell {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import styles from './HardwareListTable.module.css';
|
||||
|
||||
function formatChf(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
return `CHF ${n.toLocaleString('fr-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function formatUnitPrice({ unitPriceChf, unitLabel, unit }) {
|
||||
const n = Number(unitPriceChf);
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
const money = `CHF ${n.toLocaleString('fr-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
const u = unitLabel || unit || null;
|
||||
return u ? `${money} / ${u}` : money;
|
||||
}
|
||||
|
||||
export default function HardwareListTable({ hardwareList, error = null }) {
|
||||
if (error) {
|
||||
return <div className={styles.empty}>{error}</div>;
|
||||
}
|
||||
|
||||
if (!hardwareList) {
|
||||
return <div className={styles.empty}>Aucune donnée de quincaillerie disponible</div>;
|
||||
}
|
||||
|
||||
const items = Array.isArray(hardwareList.items) ? hardwareList.items : [];
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.summary}>
|
||||
<h3 className={styles.summaryTitle}>Résumé</h3>
|
||||
<div className={styles.summaryGrid}>
|
||||
<div className={styles.summaryItem}>
|
||||
<span className={styles.summaryLabel}>Articles</span>
|
||||
<span className={styles.summaryValue}>{items.length}</span>
|
||||
</div>
|
||||
<div className={styles.summaryItem}>
|
||||
<span className={styles.summaryLabel}>Total</span>
|
||||
<span className={styles.summaryValue}>{formatChf(hardwareList.totalChf)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.thumbCol}></th>
|
||||
<th>Matériel</th>
|
||||
<th>Code</th>
|
||||
<th className={styles.num}>Qté</th>
|
||||
<th className={styles.num}>Prix unitaire</th>
|
||||
<th className={styles.num}>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((it) => (
|
||||
<tr key={it.key}>
|
||||
<td className={styles.thumbCell}>
|
||||
{it.imageUrl ? (
|
||||
<img className={styles.thumbImg} src={it.imageUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span className={styles.thumbEmpty}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.labelCell}>
|
||||
{it.productUrl ? (
|
||||
<a className={styles.productLink} href={it.productUrl} target="_blank" rel="noreferrer">
|
||||
{it.label}
|
||||
</a>
|
||||
) : (
|
||||
it.label
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.codeCell}>{it.code || '—'}</td>
|
||||
<td className={`${styles.num} ${styles.qty}`}>{it.quantity}</td>
|
||||
<td className={styles.num}>{formatUnitPrice(it)}</td>
|
||||
<td className={styles.num}>{formatChf(it.lineTotalChf)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th colSpan={5} className={styles.totalLabel}>Total</th>
|
||||
<th className={`${styles.num} ${styles.totalValue}`}>{formatChf(hardwareList.totalChf)}</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 18px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.summary {
|
||||
background: #f0f7ff;
|
||||
border-radius: 10px;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.summaryTitle {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.summaryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.summaryItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.summaryLabel {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summaryValue {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
background: var(--bg-subtle);
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.thumbCol {
|
||||
width: 52px;
|
||||
}
|
||||
|
||||
.thumbCell {
|
||||
width: 52px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.thumbImg {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.thumbEmpty {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.labelCell {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.codeCell {
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.productLink {
|
||||
color: var(--action);
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.productLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.qty {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.table tfoot th,
|
||||
.table tfoot td {
|
||||
background: #fff7ed;
|
||||
border-top: 1px solid rgba(245, 158, 11, 0.25);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.totalLabel {
|
||||
text-align: right;
|
||||
font-weight: 800;
|
||||
color: #7c2d12;
|
||||
}
|
||||
|
||||
.totalValue {
|
||||
font-weight: 900;
|
||||
color: #7c2d12;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.summaryGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.table {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export default function Layout({ children }) {
|
||||
<Link to="/configurateur">Configurateur</Link>
|
||||
<Link to="/materiaux">Matériaux</Link>
|
||||
<Link to="/admin">Admin</Link>
|
||||
<Link to="/parametres">Paramètres</Link>
|
||||
<button type="button" className={styles.logoutBtn} onClick={handleLogout}>Déconnexion</button>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -12,6 +12,13 @@ export default function MaterialModal({ material, collections, onClose, onSave }
|
||||
width_mm: '',
|
||||
height_mm: '',
|
||||
thickness_mm: '',
|
||||
diameter_mm: '',
|
||||
length_mm: '',
|
||||
bore_diameter_mm: '',
|
||||
bore_depth_base_mm: '',
|
||||
bore_depth_piece_mm: '',
|
||||
price_chf: '',
|
||||
price_unit: '',
|
||||
formex_url: ''
|
||||
});
|
||||
|
||||
@@ -27,6 +34,13 @@ export default function MaterialModal({ material, collections, onClose, onSave }
|
||||
width_mm: material.width_mm?.toString() || '',
|
||||
height_mm: material.height_mm?.toString() || '',
|
||||
thickness_mm: material.thickness_mm?.toString() || '',
|
||||
diameter_mm: material.diameter_mm?.toString() || '',
|
||||
length_mm: material.length_mm?.toString() || '',
|
||||
bore_diameter_mm: material.bore_diameter_mm?.toString() || '',
|
||||
bore_depth_base_mm: material.bore_depth_base_mm?.toString() || '',
|
||||
bore_depth_piece_mm: material.bore_depth_piece_mm?.toString() || '',
|
||||
price_chf: material.price_chf?.toString() || '',
|
||||
price_unit: material.price_unit || '',
|
||||
formex_url: material.formex_url || ''
|
||||
});
|
||||
}
|
||||
@@ -56,7 +70,14 @@ export default function MaterialModal({ material, collections, onClose, onSave }
|
||||
collection: formData.collection.trim(),
|
||||
width_mm: formData.width_mm ? parseInt(formData.width_mm, 10) : null,
|
||||
height_mm: formData.height_mm ? parseInt(formData.height_mm, 10) : null,
|
||||
thickness_mm: formData.thickness_mm ? parseInt(formData.thickness_mm, 10) : null,
|
||||
thickness_mm: formData.thickness_mm ? parseFloat(formData.thickness_mm) : null,
|
||||
diameter_mm: formData.diameter_mm ? parseFloat(formData.diameter_mm) : null,
|
||||
length_mm: formData.length_mm ? parseFloat(formData.length_mm) : null,
|
||||
bore_diameter_mm: formData.bore_diameter_mm ? parseFloat(formData.bore_diameter_mm) : null,
|
||||
bore_depth_base_mm: formData.bore_depth_base_mm ? parseFloat(formData.bore_depth_base_mm) : null,
|
||||
bore_depth_piece_mm: formData.bore_depth_piece_mm ? parseFloat(formData.bore_depth_piece_mm) : null,
|
||||
price_chf: formData.price_chf ? parseFloat(formData.price_chf) : null,
|
||||
price_unit: formData.price_unit.trim() || null,
|
||||
formex_url: formData.formex_url.trim() || null
|
||||
};
|
||||
|
||||
@@ -174,15 +195,110 @@ export default function MaterialModal({ material, collections, onClose, onSave }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.row}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="formex_url">URL Formex</label>
|
||||
<label htmlFor="diameter_mm">Ø Diamètre (mm)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="diameter_mm"
|
||||
name="diameter_mm"
|
||||
value={formData.diameter_mm}
|
||||
onChange={handleChange}
|
||||
placeholder="8"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="length_mm">Longueur (mm)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="length_mm"
|
||||
name="length_mm"
|
||||
value={formData.length_mm}
|
||||
onChange={handleChange}
|
||||
placeholder="55"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset className={styles.fieldset}>
|
||||
<legend>Perçage</legend>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="bore_diameter_mm">Ø Perçage (mm)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="bore_diameter_mm"
|
||||
name="bore_diameter_mm"
|
||||
value={formData.bore_diameter_mm}
|
||||
onChange={handleChange}
|
||||
placeholder="8"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="bore_depth_base_mm">Prof. fond (mm)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="bore_depth_base_mm"
|
||||
name="bore_depth_base_mm"
|
||||
value={formData.bore_depth_base_mm}
|
||||
onChange={handleChange}
|
||||
placeholder="11"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="bore_depth_piece_mm">Prof. pièce (mm)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="bore_depth_piece_mm"
|
||||
name="bore_depth_piece_mm"
|
||||
value={formData.bore_depth_piece_mm}
|
||||
onChange={handleChange}
|
||||
placeholder="8"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className={styles.row}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="price_chf">Prix CHF</label>
|
||||
<input
|
||||
type="number"
|
||||
id="price_chf"
|
||||
name="price_chf"
|
||||
value={formData.price_chf}
|
||||
onChange={handleChange}
|
||||
placeholder="0.60"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="price_unit">Unité de prix</label>
|
||||
<input
|
||||
type="text"
|
||||
id="price_unit"
|
||||
name="price_unit"
|
||||
value={formData.price_unit}
|
||||
onChange={handleChange}
|
||||
placeholder="par Pièce (PC)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="formex_url">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id="formex_url"
|
||||
name="formex_url"
|
||||
value={formData.formex_url}
|
||||
onChange={handleChange}
|
||||
placeholder="https://www.formex.ch/fr/shop/..."
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
max-width: 500px;
|
||||
max-width: 560px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
@@ -92,6 +92,24 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.row:has(> :nth-child(2):last-child) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.fieldset {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.fieldset legend {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
padding: 0 0.375rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -4,22 +4,74 @@
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import CuttingListTable from './CuttingListTable';
|
||||
import HardwareListTable from './HardwareListTable';
|
||||
import styles from './Plans2DView.module.css';
|
||||
import { saveOrderPlanExport, generateOrderPlanExports } from '../api/client';
|
||||
import Icon from './ui/Icon';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function Plans2DView({ config, modules = [], colors = {}, onClose }) {
|
||||
const [activeTab, setActiveTab] = useState('assembly');
|
||||
const PLANS_TAB_KEY = 'apex.plans.tab';
|
||||
const VALID_PLAN_TABS = ['assembly', 'side', 'top', 'cutting', 'hardware'];
|
||||
|
||||
function readSavedPlanTab() {
|
||||
try {
|
||||
let t = localStorage.getItem(PLANS_TAB_KEY);
|
||||
if (VALID_PLAN_TABS.includes(t)) return t;
|
||||
t = sessionStorage.getItem(PLANS_TAB_KEY);
|
||||
if (VALID_PLAN_TABS.includes(t)) {
|
||||
localStorage.setItem(PLANS_TAB_KEY, t);
|
||||
sessionStorage.removeItem(PLANS_TAB_KEY);
|
||||
return t;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 'assembly';
|
||||
}
|
||||
|
||||
export default function Plans2DView({ config, modules = [], colors = {}, orderId = null, onClose }) {
|
||||
const [activeTab, setActiveTab] = useState(readSavedPlanTab);
|
||||
const [plans, setPlans] = useState(null);
|
||||
const [cuttingList, setCuttingList] = useState(null);
|
||||
const [hardwareList, setHardwareList] = useState(null);
|
||||
const [hardwareError, setHardwareError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
/** Erreur API plans 2D seulement — n’empêche pas liste débitage / quincaillerie */
|
||||
const [planViewsError, setPlanViewsError] = useState(null);
|
||||
const [exportStatus, setExportStatus] = useState('');
|
||||
const [cncProgram, setCncProgram] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(PLANS_TAB_KEY, activeTab);
|
||||
sessionStorage.removeItem(PLANS_TAB_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const planRestoreKey = `${config.widthMm}|${config.heightMm}|${config.depthMm}`;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
try {
|
||||
localStorage.removeItem(PLANS_TAB_KEY);
|
||||
sessionStorage.removeItem(PLANS_TAB_KEY);
|
||||
sessionStorage.removeItem('apex.cnc3d.modal');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
// Fetch plans and cutting list
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPlanViewsError(null);
|
||||
setHardwareError(null);
|
||||
|
||||
try {
|
||||
// Fetch plans
|
||||
@@ -28,10 +80,24 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config, modules, colors })
|
||||
});
|
||||
const plansData = await plansRes.json();
|
||||
let plansData = {};
|
||||
try {
|
||||
plansData = await plansRes.json();
|
||||
} catch {
|
||||
plansData = {};
|
||||
}
|
||||
|
||||
if (plansData.success) {
|
||||
if (plansRes.ok && plansData.success && plansData.plans) {
|
||||
setPlans(plansData.plans);
|
||||
setPlanViewsError(null);
|
||||
} else {
|
||||
setPlans(null);
|
||||
const detail = Array.isArray(plansData.details) ? plansData.details[0]?.message : undefined;
|
||||
const msg =
|
||||
plansData.error ||
|
||||
detail ||
|
||||
(!plansRes.ok ? `Plans 2D indisponibles (HTTP ${plansRes.status}).` : 'Réponse serveur invalide pour les plans 2D.');
|
||||
setPlanViewsError(msg);
|
||||
}
|
||||
|
||||
// Fetch cutting list
|
||||
@@ -42,8 +108,34 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
|
||||
});
|
||||
const cutData = await cutRes.json();
|
||||
|
||||
if (cutData.success) {
|
||||
if (cutRes.ok && cutData.success) {
|
||||
setCuttingList(cutData.cuttingList);
|
||||
} else {
|
||||
setCuttingList(null);
|
||||
}
|
||||
|
||||
// Fetch hardware list
|
||||
const hwRes = await fetch(`${API_BASE}/api/plans/hardware-list`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config, modules, colors })
|
||||
});
|
||||
const hwData = await hwRes.json();
|
||||
if (hwData.success) {
|
||||
setHardwareList({ items: hwData.items || [], totalChf: hwData.totalChf ?? 0 });
|
||||
} else {
|
||||
setHardwareList(null);
|
||||
setHardwareError('Erreur lors de la génération de la liste de quincaillerie');
|
||||
}
|
||||
|
||||
const cncRes = await fetch(`${API_BASE}/api/plans/export-cnc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config, modules, colors })
|
||||
});
|
||||
if (cncRes.ok) {
|
||||
const cncText = await cncRes.text();
|
||||
setCncProgram(cncText);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
@@ -60,13 +152,16 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
|
||||
// Export handlers
|
||||
const handleExport = useCallback(async (format) => {
|
||||
try {
|
||||
setExportStatus('');
|
||||
if (format === 'text') {
|
||||
const res = await fetch(`${API_BASE}/api/plans/export-text`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config, modules, colors })
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const blob = await res.blob();
|
||||
const textContent = await blob.text();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -74,33 +169,141 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
if (orderId) {
|
||||
await saveOrderPlanExport(orderId, {
|
||||
exportType: 'cutting_list_text',
|
||||
content: textContent,
|
||||
mimeType: 'text/plain; charset=utf-8',
|
||||
fileExtension: 'txt'
|
||||
});
|
||||
setExportStatus(`Export enregistré pour la commande #${orderId}.`);
|
||||
} else {
|
||||
setExportStatus('Export téléchargé localement (non archivé: commande non envoyée).');
|
||||
}
|
||||
|
||||
} else if (format === 'pdf') {
|
||||
const res = await fetch(`${API_BASE}/api/plans/export-pdf`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config, modules, colors })
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const html = await res.text();
|
||||
|
||||
// Open in new window for printing
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (printWindow) {
|
||||
printWindow.document.write(html);
|
||||
printWindow.document.close();
|
||||
printWindow.onload = () => {
|
||||
printWindow.print();
|
||||
};
|
||||
}
|
||||
|
||||
if (orderId) {
|
||||
await generateOrderPlanExports(orderId);
|
||||
setExportStatus(`PDF dossier enregistré pour la commande #${orderId}.`);
|
||||
} else {
|
||||
setExportStatus('Version imprimable ouverte (non archivée: commande non envoyée).');
|
||||
}
|
||||
} else if (format === 'cnc') {
|
||||
const res = await fetch(`${API_BASE}/api/plans/export-cnc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config, modules, colors })
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const gcode = await res.text();
|
||||
setCncProgram(gcode);
|
||||
|
||||
const blob = new Blob([gcode], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'apex-program.nc';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setExportStatus('Programme CNC (.nc) téléchargé.');
|
||||
} else if (format === 'mpr') {
|
||||
const res = await fetch(`${API_BASE}/api/plans/export-mpr`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
config,
|
||||
modules,
|
||||
colors,
|
||||
orderReference: orderId ? `COMMANDE_${orderId}` : '',
|
||||
stackListKm: '01',
|
||||
mprGroupIndex: 1
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'apex-mpr-g1.zip';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setExportStatus('Archive Homag MPR (.zip) téléchargée.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Export error:', err);
|
||||
alert('Erreur lors de l\'export');
|
||||
}
|
||||
}, [config, modules, colors]);
|
||||
}, [config, modules, colors, orderId]);
|
||||
|
||||
const handleExportMprPiece = useCallback(async (piece, instanceIndex = null) => {
|
||||
try {
|
||||
setExportStatus('');
|
||||
if (!piece?.id) return;
|
||||
const res = await fetch(`${API_BASE}/api/plans/export-mpr-piece`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
config,
|
||||
modules,
|
||||
colors,
|
||||
pieceId: piece.id,
|
||||
...(instanceIndex != null && Number.isFinite(instanceIndex) ? { instanceIndex } : {}),
|
||||
orderReference: orderId ? `COMMANDE_${orderId}` : '',
|
||||
stackListKm: '01',
|
||||
mprGroupIndex: 1
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`;
|
||||
try {
|
||||
const err = await res.json();
|
||||
msg = err.error || msg;
|
||||
} catch {
|
||||
msg = (await res.text()) || msg;
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const disposition = res.headers.get('Content-Disposition') || '';
|
||||
const match = disposition.match(/filename="?([^";\n]+)"?/i);
|
||||
const downloadName = match?.[1] || 'piece.mpr';
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = downloadName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setExportStatus(`MPR téléchargé : ${piece.description}`);
|
||||
} catch (err) {
|
||||
console.error('Export MPR piece error:', err);
|
||||
alert(err.message || 'Erreur lors de l\'export MPR');
|
||||
}
|
||||
}, [config, modules, colors, orderId]);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'assembly', label: 'Vue de face' },
|
||||
{ id: 'side', label: 'Vue de côté' },
|
||||
{ id: 'top', label: 'Vue de dessus' },
|
||||
{ id: 'cutting', label: 'Liste de débitage' }
|
||||
{ id: 'cutting', label: 'Liste de débitage' },
|
||||
{ id: 'hardware', label: 'Quincaillerie' }
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -108,7 +311,10 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>Plans de fabrication</h2>
|
||||
<button className={styles.closeBtn} onClick={onClose}>×</button>
|
||||
<button className={styles.closeBtn} onClick={handleClose}>
|
||||
<Icon name="x" size={14} />
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.tabs}>
|
||||
@@ -123,38 +329,65 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={`${styles.content} ${activeTab === 'cutting' ? styles.contentCutting : ''}`}>
|
||||
{loading ? (
|
||||
<div className={styles.loading}>Génération des plans...</div>
|
||||
) : error ? (
|
||||
<div className={styles.error}>{error}</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'assembly' && plans?.assembly && (
|
||||
{activeTab === 'assembly' && (
|
||||
planViewsError ? (
|
||||
<div className={styles.empty}>{planViewsError}</div>
|
||||
) : plans?.assembly ? (
|
||||
<div
|
||||
className={styles.planContainer}
|
||||
dangerouslySetInnerHTML={{ __html: plans.assembly }}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.empty}>Aucun plan de face disponible.</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'side' && plans?.side && (
|
||||
{activeTab === 'side' && (
|
||||
planViewsError ? (
|
||||
<div className={styles.empty}>{planViewsError}</div>
|
||||
) : plans?.side ? (
|
||||
<div
|
||||
className={styles.planContainer}
|
||||
dangerouslySetInnerHTML={{ __html: plans.side }}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.empty}>Aucun plan de côté disponible.</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'top' && plans?.top && (
|
||||
{activeTab === 'top' && (
|
||||
planViewsError ? (
|
||||
<div className={styles.empty}>{planViewsError}</div>
|
||||
) : plans?.top ? (
|
||||
<div
|
||||
className={styles.planContainer}
|
||||
dangerouslySetInnerHTML={{ __html: plans.top }}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.empty}>Aucun plan de dessus disponible.</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'cutting' && (
|
||||
<CuttingListTable
|
||||
cuttingList={cuttingList}
|
||||
onExport={handleExport}
|
||||
onExportMprPiece={handleExportMprPiece}
|
||||
cncProgram={cncProgram}
|
||||
planRestoreKey={planRestoreKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'hardware' && (
|
||||
<HardwareListTable
|
||||
hardwareList={hardwareList}
|
||||
error={hardwareError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -162,20 +395,50 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.footerInfo}>
|
||||
<div className={styles.specs}>
|
||||
<span>{config.widthMm} × {config.heightMm} × {config.depthMm} mm</span>
|
||||
<span>•</span>
|
||||
<span className={styles.specsDims}>
|
||||
{config.widthMm} × {config.heightMm} × {config.depthMm} mm
|
||||
</span>
|
||||
<span className={styles.specsSep} aria-hidden="true">•</span>
|
||||
<span>{config.installationType === 'niche' ? 'Encastrée' : 'Libre'}</span>
|
||||
<span>•</span>
|
||||
<span className={styles.specsSep} aria-hidden="true">•</span>
|
||||
<span>{config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</span>
|
||||
</div>
|
||||
{exportStatus && <div className={styles.exportStatus}>{exportStatus}</div>}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
{activeTab === 'cutting' && (
|
||||
<button
|
||||
className={styles.exportBtnSecondary}
|
||||
onClick={() => handleExport('text')}
|
||||
>
|
||||
<Icon name="download" size={15} />
|
||||
Liste texte
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={styles.exportBtn}
|
||||
onClick={() => handleExport('pdf')}
|
||||
>
|
||||
<Icon name="download" size={15} />
|
||||
Imprimer / PDF
|
||||
</button>
|
||||
{activeTab === 'cutting' && (
|
||||
<>
|
||||
<button
|
||||
className={styles.exportBtn}
|
||||
onClick={() => handleExport('cnc')}
|
||||
>
|
||||
<Icon name="download" size={15} />
|
||||
Export CNC (.nc)
|
||||
</button>
|
||||
<button className={styles.exportBtn} onClick={() => handleExport('mpr')}>
|
||||
<Icon name="download" size={15} />
|
||||
Export MPR (ZIP)
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,10 +17,11 @@
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
max-height: 90vh;
|
||||
max-height: min(90vh, calc(100dvh - 40px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -39,17 +40,26 @@
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
line-height: 1;
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--action);
|
||||
color: var(--action);
|
||||
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
@@ -97,6 +107,15 @@
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.contentCutting {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
display: flex;
|
||||
@@ -111,6 +130,20 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 280px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.planContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -128,45 +161,107 @@
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
gap: 12px 16px;
|
||||
padding: 14px 16px 16px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
background: var(--bg-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footerInfo {
|
||||
flex: 1 1 12rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.specs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 8px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.specsDims {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.specsSep {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.exportStatus {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
flex: 1 1 16rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.exportBtn {
|
||||
padding: 10px 20px;
|
||||
background: var(--action);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 9px 14px;
|
||||
background: linear-gradient(135deg, var(--action), var(--action-hover));
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.exportBtnSecondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 9px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.exportBtnSecondary:hover {
|
||||
border-color: var(--action);
|
||||
color: var(--action);
|
||||
}
|
||||
|
||||
.exportBtn:hover {
|
||||
background: var(--action-hover);
|
||||
box-shadow: 0 10px 22px rgba(79, 70, 229, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.modal {
|
||||
max-height: 95vh;
|
||||
max-height: min(95vh, calc(100dvh - 24px));
|
||||
}
|
||||
|
||||
.tabs {
|
||||
@@ -182,11 +277,26 @@
|
||||
|
||||
.footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
padding: 12px 14px 14px;
|
||||
}
|
||||
|
||||
.footerInfo {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.specs {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.exportBtn,
|
||||
.exportBtnSecondary {
|
||||
flex: 1 1 calc(50% - 4px);
|
||||
min-width: 9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ const DEFAULT_COLORS = {
|
||||
panneauxInterieurs: '#d4c4a4',
|
||||
separateurs: '#6b5344',
|
||||
etageres: '#d4c4a4',
|
||||
rayonFixe: '#d4c4a4',
|
||||
renfortsTiroir: '#c4a574',
|
||||
tiroirs: '#c4a574',
|
||||
penderie: '#8b7355'
|
||||
};
|
||||
@@ -43,6 +45,8 @@ const ELEMENT_LABELS = {
|
||||
panneauxInterieurs: 'Panneaux intérieurs',
|
||||
separateurs: 'Séparateurs',
|
||||
etageres: 'Étagères',
|
||||
rayonFixe: 'Rayon fixe',
|
||||
renfortsTiroir: 'Renforts tiroir',
|
||||
tiroirs: 'Tiroirs',
|
||||
penderie: 'Tringle penderie'
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@
|
||||
min-height: 280px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.controls {
|
||||
@@ -17,6 +20,19 @@
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.stepBadge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(79, 70, 229, 0.92);
|
||||
color: #fff;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.controlBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@@ -44,6 +60,20 @@
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.controlBtnDisabled,
|
||||
.controlBtn:disabled {
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.controlBtnDisabled:hover,
|
||||
.controlBtn:disabled:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -121,3 +151,4 @@
|
||||
border-color: var(--action);
|
||||
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import WardrobePreview from '../WardrobePreview';
|
||||
import DesignTip from '../DesignTip';
|
||||
import { resolveShelfPositionsMm, minShelvesForStructuralHeight } from '../../constants/wardrobe';
|
||||
import styles from './PreviewArea.module.css';
|
||||
|
||||
export default function PreviewArea({ wc, ui }) {
|
||||
const colorPickEnabled = ui.activeStep === 4 || ui.activeStep === 6;
|
||||
const infoByStep = {
|
||||
1: 'Survolez les zones colorees du cadre puis glissez pour regler L/H/P.',
|
||||
2: 'Tirez les separateurs pour redimensionner et glissez les rayonnages par la tranche avant.',
|
||||
3: 'Activez ou masquez les portes pour verifier l ouverture.',
|
||||
4: 'Choisissez un element puis appliquez sa couleur.',
|
||||
5: 'Verifiez les volumes interieurs pour valider la quincaillerie.',
|
||||
6: 'Controle final: zoom, rotation et verification globale.'
|
||||
};
|
||||
const structuralWarnings = (() => {
|
||||
if (ui.activeStep !== 2) return [];
|
||||
if (minShelvesForStructuralHeight(wc.config.heightMm) <= 0) return [];
|
||||
const heightMm = Number(wc.config.heightMm) || 0;
|
||||
const plinthMm = Number(wc.config.plinthHeightMm) || 0;
|
||||
const midMm = (heightMm - plinthMm) / 2;
|
||||
const mods = Array.isArray(wc.config.modules) ? wc.config.modules : [];
|
||||
const out = [];
|
||||
for (let i = 0; i < mods.length; i++) {
|
||||
const mod = mods[i];
|
||||
const shelfCount = Math.max(0, Number(mod?.shelves) || 0);
|
||||
const positions = resolveShelfPositionsMm({
|
||||
shelfCount,
|
||||
heightMm,
|
||||
plinthHeightMm: plinthMm,
|
||||
shelfPositionsMm: mod?.shelfPositionsMm
|
||||
});
|
||||
const hasStructural = positions.some((p) => p >= midMm - 200 && p <= midMm + 200);
|
||||
if (!hasStructural) out.push(`Colonne ${i + 1}: placer un rayon dans la zone structurelle (±20 cm).`);
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
const handleSetDimension = useCallback((key, valueMm) => {
|
||||
wc.setDimension(key, valueMm);
|
||||
}, [wc.setDimension]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e) => {
|
||||
if (!e.ctrlKey && !e.metaKey) return;
|
||||
const el = e.target;
|
||||
if (el && (el.closest?.('input, textarea, select') || el.isContentEditable)) return;
|
||||
|
||||
if (e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
wc.undo();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'z' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
wc.redo();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'y' && e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
wc.redo();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [wc.undo, wc.redo]);
|
||||
|
||||
return (
|
||||
<div className={styles.previewArea}>
|
||||
<div className={styles.roomControls}>
|
||||
{ui.roomViewActive && ui.roomConfig ? (
|
||||
<>
|
||||
<span className={styles.roomLabel}>
|
||||
Vue : Ma pièce ({ui.roomAnalysis?.roomDimensions?.widthM}m × {ui.roomAnalysis?.roomDimensions?.lengthM}m)
|
||||
</span>
|
||||
<div className={styles.roomBtnGroup}>
|
||||
<button type="button" className={styles.roomBtnSecondary} onClick={() => ui.setShowRoomUpload(true)}>
|
||||
Nouvelle photo
|
||||
</button>
|
||||
<button type="button" className={styles.roomBtn} onClick={() => ui.setRoomViewActive(false)}>
|
||||
Vue simple
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={styles.roomLabel}>Vue : Simple</span>
|
||||
<div className={styles.roomBtnGroup}>
|
||||
{ui.roomConfig && (
|
||||
<button type="button" className={styles.roomBtn} onClick={() => ui.setRoomViewActive(true)}>
|
||||
Afficher ma pièce
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={ui.roomConfig ? styles.roomBtnSecondary : styles.roomBtn}
|
||||
onClick={() => ui.setShowRoomUpload(true)}
|
||||
>
|
||||
{ui.roomConfig ? 'Nouvelle photo' : 'Visualiser dans ma pi\u00e8ce'}
|
||||
</button>
|
||||
<button type="button" className={styles.aiRenderBtn} onClick={() => ui.setShowAIRender(true)}>
|
||||
<span className={styles.aiBadgeSmall}>IA</span>
|
||||
Rendu réaliste
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.preview}>
|
||||
<WardrobePreview
|
||||
config={wc.config}
|
||||
activeStep={ui.activeStep}
|
||||
roomConfig={ui.roomViewActive ? ui.roomConfig : null}
|
||||
colors={wc.config.colors}
|
||||
modules={wc.config.modules}
|
||||
onElementClick={colorPickEnabled ? (elementType, label) => ui.openColorPicker(elementType, label) : undefined}
|
||||
onModuleClick={ui.handleModuleClick}
|
||||
onUpdateModule={wc.handleColumnUpdate}
|
||||
onResizeModules={wc.handleResizeModules}
|
||||
selectedModuleIndex={ui.expandedColumn}
|
||||
onSetDimension={handleSetDimension}
|
||||
onBeginUndoableGesture={wc.pushUndoSnapshot}
|
||||
canUndo3d={wc.canUndo}
|
||||
canRedo3d={wc.canRedo}
|
||||
onUndo3d={wc.undo}
|
||||
onRedo3d={wc.redo}
|
||||
suppressHtmlOverlays={ui.showPlansView || ui.showAIRender || ui.showRoomUpload}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.infoZone}>
|
||||
<div className={styles.infoLine}>{infoByStep[ui.activeStep] || 'Manipulez la vue pour controler la configuration.'}</div>
|
||||
{structuralWarnings.map((msg) => (
|
||||
<div key={msg} className={styles.warnLine}>{msg}</div>
|
||||
))}
|
||||
</div>
|
||||
<DesignTip config={wc.config} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
.previewArea {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.roomControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.roomLabel {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.roomBtnGroup {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.roomBtn, .roomBtnSecondary, .aiRenderBtn {
|
||||
padding: 0.3rem 0.65rem;
|
||||
border-radius: 5px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.roomBtn:hover, .roomBtnSecondary:hover, .aiRenderBtn:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.aiRenderBtn {
|
||||
background: var(--action);
|
||||
color: #fff;
|
||||
border-color: var(--action);
|
||||
}
|
||||
.aiRenderBtn:hover { opacity: 0.9; }
|
||||
|
||||
.aiBadgeSmall {
|
||||
display: inline-block;
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
margin-right: 0.25rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
max-height: 50vh;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.infoZone {
|
||||
margin-top: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.infoLine {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.warnLine {
|
||||
font-size: 0.78rem;
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.45rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.preview {
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 40vh;
|
||||
}
|
||||
.roomControls { padding: 0.35rem 0.5rem; }
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
COLUMN_TYPES,
|
||||
maxShelfGapMm,
|
||||
SHELF_GAP_MM,
|
||||
minShelvesForStructuralHeight,
|
||||
resolveShelfPositionsMm,
|
||||
system32ShelfPositionsMm,
|
||||
SYSTEM32_PITCH_MM
|
||||
} from '../../constants/wardrobe';
|
||||
import s from './steps.module.css';
|
||||
|
||||
/** After moving array item from -> to, map a selected index to the new position. */
|
||||
function mapExpandedIndexAfterReorder(expanded, from, to) {
|
||||
if (expanded === null || expanded === undefined) return expanded;
|
||||
if (expanded === from) return to;
|
||||
if (from < to) {
|
||||
if (expanded > from && expanded <= to) return expanded - 1;
|
||||
} else if (from > to) {
|
||||
if (expanded >= to && expanded < from) return expanded + 1;
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
export default function StepColumns({ wc, ui }) {
|
||||
const {
|
||||
config, numColumns,
|
||||
handleNumColumnsChange, handleAIOptimize, handleColumnTypeChange,
|
||||
handleColumnLock, handleColumnUpdate,
|
||||
aiOptimizing, aiOptimizeMsg, setAiOptimizeMsg, totalModuleWidth,
|
||||
reorderModules, pushUndoSnapshot,
|
||||
} = wc;
|
||||
const { expandedColumn, setExpandedColumn } = ui;
|
||||
|
||||
const [draggingIndex, setDraggingIndex] = useState(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState(null);
|
||||
const dragSourceRef = useRef(null);
|
||||
const suppressCardClickRef = useRef(false);
|
||||
const canReorderColumns = config.modules.length > 1;
|
||||
|
||||
const handleColumnDragStart = useCallback((index, e) => {
|
||||
if (!canReorderColumns) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
dragSourceRef.current = index;
|
||||
setDraggingIndex(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', String(index));
|
||||
}, [canReorderColumns]);
|
||||
|
||||
const handleColumnDragOver = useCallback((index, e) => {
|
||||
if (!canReorderColumns || dragSourceRef.current === null) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOverIndex(index);
|
||||
}, [canReorderColumns]);
|
||||
|
||||
const handleColumnDrop = useCallback((toIndex, e) => {
|
||||
e.preventDefault();
|
||||
const raw = e.dataTransfer.getData('text/plain');
|
||||
const fromIndex = Number.parseInt(raw, 10);
|
||||
dragSourceRef.current = null;
|
||||
setDragOverIndex(null);
|
||||
setDraggingIndex(null);
|
||||
if (!Number.isFinite(fromIndex) || fromIndex === toIndex) return;
|
||||
suppressCardClickRef.current = true;
|
||||
pushUndoSnapshot();
|
||||
reorderModules(fromIndex, toIndex);
|
||||
setExpandedColumn((prev) => mapExpandedIndexAfterReorder(prev, fromIndex, toIndex));
|
||||
}, [pushUndoSnapshot, reorderModules, setExpandedColumn]);
|
||||
|
||||
const handleColumnDragEnd = useCallback(() => {
|
||||
dragSourceRef.current = null;
|
||||
setDraggingIndex(null);
|
||||
setDragOverIndex(null);
|
||||
}, []);
|
||||
|
||||
const handleColCardClick = useCallback((i, isActive) => {
|
||||
if (suppressCardClickRef.current) {
|
||||
suppressCardClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
setExpandedColumn(isActive ? null : i);
|
||||
}, [setExpandedColumn]);
|
||||
|
||||
const minShelvesStructural = minShelvesForStructuralHeight(config.heightMm);
|
||||
const hasTallCabinet = minShelvesStructural > 0;
|
||||
const hw = config.hardware || {};
|
||||
const drawerDepth = config.effectiveDrawerDepthMm || Math.max(400, Math.min(650, config.depthMm - 10));
|
||||
|
||||
return (
|
||||
<div className={s.stepSection}>
|
||||
<h2 className={s.stepTitle}>Configuration intérieure</h2>
|
||||
<p className={s.stepDesc}>
|
||||
Choisissez le nombre de colonnes et personnalisez chacune. Cliquez sur un module dans la vue 3D pour le sélectionner.
|
||||
{canReorderColumns && (
|
||||
<span className={s.stepDescExtra}> Glissez-déposez les cartes ci-dessous pour changer l'ordre des colonnes (gauche → droite = avant → arrière dans la vue 3D).</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className={s.hardwareGroup}>
|
||||
<label className={s.hardwareLabel}>Choix colonne: séparations ou block</label>
|
||||
<div className={s.colCountBtns}>
|
||||
{[
|
||||
{ key: 'separations', label: 'Séparations' },
|
||||
{ key: 'block', label: 'Block (x caisses)' }
|
||||
].map((m) => (
|
||||
<button
|
||||
key={m.key}
|
||||
type="button"
|
||||
className={`${s.colCountBtn} ${config.columnMode === m.key ? s.colCountActive : ''}`}
|
||||
onClick={() => wc.set('columnMode', m.key)}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{config.columnMode === 'block' && (
|
||||
<p className={s.hint}>Assemblage block: vis de jonction + perçages 3 milieux et +100/-100 mm (30 mm face/dos).</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column count */}
|
||||
<div className={s.colCountRow}>
|
||||
<span className={s.colCountLabel}>Nombre de colonnes</span>
|
||||
<div className={s.colCountBtns}>
|
||||
{[1, 2, 3, 4, 5, 6].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`${s.colCountBtn} ${numColumns === n ? s.colCountActive : ''}`}
|
||||
onClick={() => handleNumColumnsChange(n)}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={s.optimizeBtn}
|
||||
onClick={handleAIOptimize}
|
||||
disabled={aiOptimizing}
|
||||
>
|
||||
{aiOptimizing ? '...' : '\u2728 Optimiser'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{aiOptimizeMsg && (
|
||||
<div className={s.aiMsg}>
|
||||
<span className={s.aiBadge}>IA</span>
|
||||
<span>{aiOptimizeMsg}</span>
|
||||
<button className={s.aiDismiss} onClick={() => setAiOptimizeMsg('')}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Horizontal column strip */}
|
||||
{config.modules.length > 0 && (
|
||||
<>
|
||||
<p className={s.columnStripLegend}>
|
||||
Gauche → droite : colonne 1, 2, 3… (identique à la vue 3D).
|
||||
</p>
|
||||
<div className={s.columnStrip}>
|
||||
{config.modules.map((mod, i) => {
|
||||
const typeDef = COLUMN_TYPES.find(t => t.key === mod.type) || COLUMN_TYPES[0];
|
||||
const isActive = expandedColumn === i;
|
||||
const isDragging = draggingIndex === i;
|
||||
const isOver = dragOverIndex === i && draggingIndex !== null && draggingIndex !== i;
|
||||
return (
|
||||
<button
|
||||
key={mod.id}
|
||||
type="button"
|
||||
draggable={canReorderColumns}
|
||||
onDragStart={(e) => handleColumnDragStart(i, e)}
|
||||
onDragOver={(e) => handleColumnDragOver(i, e)}
|
||||
onDrop={(e) => handleColumnDrop(i, e)}
|
||||
onDragEnd={handleColumnDragEnd}
|
||||
className={`${s.colCard} ${isActive ? s.colCardActive : ''} ${mod.locked ? s.colCardLocked : ''} ${isDragging ? s.colCardDragging : ''} ${isOver ? s.colCardDropTarget : ''}`}
|
||||
onClick={() => handleColCardClick(i, isActive)}
|
||||
title={canReorderColumns ? `Colonne ${i + 1} — cliquer pour d\u00e9tails, glisser pour r\u00e9organiser` : `Colonne ${i + 1}`}
|
||||
>
|
||||
{mod.locked && <span className={s.colLockIcon}>🔒</span>}
|
||||
<span className={s.colCardIndex}>Colonne {i + 1}</span>
|
||||
{canReorderColumns && (
|
||||
<span className={s.colDragHint} aria-hidden="true">::</span>
|
||||
)}
|
||||
<span className={s.colCardIcon}>{typeDef.icon}</span>
|
||||
<span className={s.colCardType}>{typeDef.label}</span>
|
||||
<span className={s.colCardWidth}>{mod.widthMm} mm</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Detail tray for expanded column */}
|
||||
{expandedColumn !== null && config.modules[expandedColumn] && (() => {
|
||||
const i = expandedColumn;
|
||||
const mod = config.modules[i];
|
||||
const typeDef = COLUMN_TYPES.find(t => t.key === mod.type) || COLUMN_TYPES[0];
|
||||
const shelfN = mod.shelves || 0;
|
||||
const shelfPositions = resolveShelfPositionsMm({
|
||||
shelfCount: shelfN,
|
||||
heightMm: config.heightMm,
|
||||
plinthHeightMm: config.plinthHeightMm ?? 100,
|
||||
shelfPositionsMm: mod.shelfPositionsMm
|
||||
});
|
||||
const minShelves = minShelvesStructural;
|
||||
const bodyHmm = (Number(config.heightMm) || 0) - (Number(config.plinthHeightMm) || 0);
|
||||
const bodyMidMm = bodyHmm / 2;
|
||||
const shelfSlots = system32ShelfPositionsMm(config.heightMm, config.plinthHeightMm ?? 100);
|
||||
const structuralIdx =
|
||||
minShelves > 0 && shelfPositions.length > 0
|
||||
? shelfPositions.reduce((bestIdx, posMm, idx, arr) => {
|
||||
const d = Math.abs(posMm - bodyMidMm);
|
||||
const best = Math.abs(arr[bestIdx] - bodyMidMm);
|
||||
return d < best ? idx : bestIdx;
|
||||
}, 0)
|
||||
: null;
|
||||
const hasShelfInStructuralWindow =
|
||||
minShelves <= 0 ||
|
||||
shelfPositions.some((p) => p >= bodyMidMm - 200 && p <= bodyMidMm + 200);
|
||||
const gapMax = Math.min(
|
||||
SHELF_GAP_MM.max,
|
||||
maxShelfGapMm(config.heightMm, config.plinthHeightMm ?? 100, shelfN)
|
||||
);
|
||||
const gapMin = SHELF_GAP_MM.min;
|
||||
const canCustomGap = shelfN >= 2 && gapMax >= gapMin;
|
||||
const handleShelfPosChange = (shelfIndex, rawValue) => {
|
||||
if (!shelfPositions.length) return;
|
||||
const next = [...shelfPositions];
|
||||
next[shelfIndex] = Number(rawValue);
|
||||
handleColumnUpdate(i, { shelfPositionsMm: next });
|
||||
};
|
||||
return (
|
||||
<div className={s.colDetail}>
|
||||
<div className={s.colDetailHeader}>
|
||||
<span className={s.colDetailTitle}>Colonne {i + 1} — {typeDef.icon} {typeDef.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`${s.lockBtn} ${mod.locked ? s.isLocked : ''}`}
|
||||
onClick={() => handleColumnLock(i)}
|
||||
>
|
||||
{mod.locked ? '\u{1F512} Verrouill\u00e9e' : '\u{1F513} Verrouiller'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Type tiles */}
|
||||
<div className={s.typeTiles}>
|
||||
{COLUMN_TYPES.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
className={`${s.typeTile} ${mod.type === t.key ? s.typeTileActive : ''}`}
|
||||
onClick={() => handleColumnTypeChange(i, t.key)}
|
||||
>
|
||||
{t.icon} {t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Shelves */}
|
||||
<div className={s.detailRow}>
|
||||
<span className={s.detailLabel}>Étagères</span>
|
||||
<div className={s.counter}>
|
||||
<button
|
||||
type="button"
|
||||
className={s.counterBtn}
|
||||
onClick={() => handleColumnUpdate(i, { shelves: Math.max(minShelves, (mod.shelves || 0) - 1) })}
|
||||
disabled={(mod.shelves || 0) <= minShelves}
|
||||
>−</button>
|
||||
<span className={s.counterValue}>{mod.shelves || 0}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={s.counterBtn}
|
||||
onClick={() => handleColumnUpdate(i, { shelves: Math.min(10, (mod.shelves || 0) + 1) })}
|
||||
disabled={(mod.shelves || 0) >= 10}
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
{minShelves >= 1 && (mod.shelves || 0) <= minShelves && (
|
||||
<p className={s.hint}>
|
||||
Hauteur > 2 m : au moins 1 étagère par colonne (rigidité, assemblage RAZENTZAB). Le compteur ne peut pas descendre en dessous.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{shelfN >= 2 && (
|
||||
<div className={s.detailRow}>
|
||||
<span className={s.detailLabel}>Entre rayons (clair)</span>
|
||||
{mod.shelfGapMm == null ? (
|
||||
<div className={s.gapModeRow}>
|
||||
<span className={s.gapAutoLabel}>Automatique</span>
|
||||
<button
|
||||
type="button"
|
||||
className={s.gapCustomizeBtn}
|
||||
disabled={!canCustomGap}
|
||||
onClick={() => handleColumnUpdate(i, {
|
||||
shelfGapMm: Math.round(Math.min(280, gapMax) / SHELF_GAP_MM.step) * SHELF_GAP_MM.step,
|
||||
})}
|
||||
>
|
||||
Personnaliser
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.gapModeRow}>
|
||||
<span className={s.counterValue}>{mod.shelfGapMm} mm</span>
|
||||
<button
|
||||
type="button"
|
||||
className={s.gapCustomizeBtn}
|
||||
onClick={() => handleColumnUpdate(i, { shelfGapMm: null })}
|
||||
>
|
||||
Auto
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{shelfN >= 2 && mod.shelfGapMm != null && canCustomGap && (
|
||||
<div className={s.gapRangeRow}>
|
||||
<input
|
||||
type="range"
|
||||
className={s.gapRange}
|
||||
min={gapMin}
|
||||
max={gapMax}
|
||||
step={SHELF_GAP_MM.step}
|
||||
value={Math.min(gapMax, Math.max(gapMin, mod.shelfGapMm))}
|
||||
onChange={(e) => handleColumnUpdate(i, { shelfGapMm: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{shelfN >= 2 && !canCustomGap && (
|
||||
<p className={s.hint}>Hauteur insuffisante pour fixer l'espacement entre rayons (répartition auto).</p>
|
||||
)}
|
||||
{shelfN > 0 && shelfPositions.length > 0 && (
|
||||
<div className={s.detailRow} style={{ alignItems: 'flex-start' }}>
|
||||
<span className={s.detailLabel}>Positions rayons</span>
|
||||
<div style={{ display: 'grid', gap: 8, width: '100%' }}>
|
||||
{shelfPositions.map((posMm, shelfIdx) => {
|
||||
let minMm = shelfSlots[shelfIdx] ?? shelfSlots[0];
|
||||
let maxMm = shelfSlots[shelfSlots.length - (shelfPositions.length - shelfIdx)] ?? shelfSlots[shelfSlots.length - 1];
|
||||
return (
|
||||
<div key={`shelf-pos-${shelfIdx}`} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className={s.counterValue} style={{ minWidth: 56 }}>
|
||||
R{String(shelfIdx + 1)}{structuralIdx === shelfIdx ? '*' : ''}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={minMm}
|
||||
max={maxMm}
|
||||
step={SYSTEM32_PITCH_MM}
|
||||
value={posMm}
|
||||
onChange={(e) => handleShelfPosChange(shelfIdx, e.target.value)}
|
||||
className={s.gapRange}
|
||||
/>
|
||||
<span className={s.hint}>mm</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{structuralIdx !== null && (
|
||||
<p className={s.hint}>* Rayon structurel recommandé autour du milieu (zone ±200 mm).</p>
|
||||
)}
|
||||
{minShelves > 0 && !hasShelfInStructuralWindow && (
|
||||
<p className={s.warnText}>Aucun rayon dans la zone structurelle ±200 mm autour du milieu. Placez-en un pour la rigidité.</p>
|
||||
)}
|
||||
|
||||
{/* Drawers */}
|
||||
<div className={s.detailRow}>
|
||||
<span className={s.detailLabel}>Tiroirs</span>
|
||||
<div className={s.counter}>
|
||||
<button
|
||||
type="button"
|
||||
className={s.counterBtn}
|
||||
onClick={() => handleColumnUpdate(i, { drawers: Math.max(0, (mod.drawers || 0) - 1) })}
|
||||
disabled={(mod.drawers || 0) <= 0}
|
||||
>−</button>
|
||||
<span className={s.counterValue}>{mod.drawers || 0}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={s.counterBtn}
|
||||
onClick={() => handleColumnUpdate(i, { drawers: Math.min(6, (mod.drawers || 0) + 1) })}
|
||||
disabled={(mod.drawers || 0) >= 6}
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
{(mod.drawers || 0) > 0 && (
|
||||
<>
|
||||
<div className={s.detailRow}>
|
||||
<span className={s.detailLabel}>Type tiroirs</span>
|
||||
<div className={s.colCountBtns} style={{ flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ key: 'visible', label: 'Visible' },
|
||||
{ key: 'hidden', label: 'Caché' }
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={`${s.colCountBtn} ${hw.drawerType === item.key ? s.colCountActive : ''}`}
|
||||
onClick={() => wc.setHardware('drawerType', item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className={s.hint} style={{ marginTop: 0 }}>
|
||||
Manufacture depuis le bas. Profondeur utile: {drawerDepth} mm (réglage global).
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Rod */}
|
||||
<div className={s.detailRow}>
|
||||
<span className={s.detailLabel}>Tringle</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`${s.toggleBtn} ${mod.hasRod ? s.toggleOn : ''}`}
|
||||
onClick={() => handleColumnUpdate(i, { hasRod: !mod.hasRod })}
|
||||
>
|
||||
{mod.hasRod ? 'Oui' : 'Non'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<p className={s.summaryLine}>
|
||||
{config.modules.length} colonne{config.modules.length > 1 ? 's' : ''} — Largeur totale : {totalModuleWidth} mm
|
||||
</p>
|
||||
{hasTallCabinet && <p className={s.warnText}>Armoire > 2m: rayon au milieu obligatoire avec assemblage RAZENTZAB.</p>}
|
||||
{config.modules.some((m) => (m.drawers || 0) > 0 && (m.widthMm || 0) > 1000) && (
|
||||
<p className={s.warnText}>Une colonne avec tiroirs dépasse 1000 mm. Limite max appliquée.</p>
|
||||
)}
|
||||
{config.modules.some((m) => m.hasRod) && config.heightMm < 1200 && (
|
||||
<p className={s.warnText}>Rayon avec tringle: hauteur minimale 1200 mm.</p>
|
||||
)}
|
||||
<p className={s.hint}>Tirez les séparateurs dans la vue 3D pour redimensionner</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import s from './steps.module.css';
|
||||
|
||||
const DIMS = [
|
||||
{ key: 'widthMm', label: 'Largeur totale', min: 400, max: 5000, step: 50, unit: 'mm' },
|
||||
{ key: 'heightMm', label: 'Hauteur', min: 1800, max: 2800, step: 50, unit: 'mm' },
|
||||
{ key: 'depthMm', label: 'Profondeur', min: 400, max: 800, step: 10, unit: 'mm' },
|
||||
{ key: 'plinthHeightMm', label: 'Plinthe', min: 60, max: 160, step: 10, unit: 'mm' },
|
||||
];
|
||||
|
||||
export default function StepDimensions({ wc }) {
|
||||
const { config, set } = wc;
|
||||
const niche = config.nicheMeasurements || {};
|
||||
const sideDefaultsEnabled = config.sideFinishesEnabled !== false;
|
||||
const sideLeftEnabled = config.sideFinishLeftEnabled ?? sideDefaultsEnabled;
|
||||
const sideRightEnabled = config.sideFinishRightEnabled ?? sideDefaultsEnabled;
|
||||
const topFinishEnabled = config.topFinishEnabled !== false;
|
||||
|
||||
const minNicheWidth = Math.min(niche.widthTopMm || config.widthMm, niche.widthMidMm || config.widthMm, niche.widthBottomMm || config.widthMm);
|
||||
const minNicheDepth = Math.min(niche.depthTopMm || config.depthMm, niche.depthMidMm || config.depthMm, niche.depthBottomMm || config.depthMm);
|
||||
const minNicheHeight = Math.min(niche.heightLeftMm || config.heightMm, niche.heightRightMm || config.heightMm);
|
||||
const gap = Number(config.sanitaryGapMm || 30);
|
||||
const usableWidth = Math.max(400, minNicheWidth - gap * 2);
|
||||
const usableDepth = Math.max(400, minNicheDepth - gap * 2);
|
||||
const usableHeight = Math.max(1800, minNicheHeight - gap * 2);
|
||||
|
||||
return (
|
||||
<div className={s.stepSection}>
|
||||
<h2 className={s.stepTitle}>Espace & Dimensions</h2>
|
||||
|
||||
{/* Installation type */}
|
||||
<div className={s.cardToggle}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${s.cardToggleItem} ${config.installationType === 'free' ? s.cardToggleActive : ''}`}
|
||||
onClick={() => set('installationType', 'free')}
|
||||
>
|
||||
<span className={s.cardToggleIcon} aria-hidden="true">
|
||||
<svg className={s.cardToggleIconSvg} viewBox="0 0 24 24" fill="none">
|
||||
<rect x="4" y="6" width="10" height="12" rx="1.5" />
|
||||
<path d="M17 4v16" />
|
||||
<path d="M7.5 10h3M7.5 14h3" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className={s.cardToggleLabel}>Libre</span>
|
||||
<span className={s.cardToggleHint}>Posée contre un mur</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${s.cardToggleItem} ${config.installationType === 'niche' ? s.cardToggleActive : ''}`}
|
||||
onClick={() => set('installationType', 'niche')}
|
||||
>
|
||||
<span className={s.cardToggleIcon} aria-hidden="true">
|
||||
<svg className={s.cardToggleIconSvg} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M4 4v16M20 4v16" />
|
||||
<rect x="8" y="6" width="8" height="12" rx="1.5" />
|
||||
<path d="M10.5 10h3M10.5 14h3" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className={s.cardToggleLabel}>Niche</span>
|
||||
<span className={s.cardToggleHint}>Encastrée entre deux murs</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={s.hardwareGroup}>
|
||||
<label className={s.hardwareLabel}>Type de pièce</label>
|
||||
<div className={s.typeTiles}>
|
||||
{[
|
||||
{ key: 'other', label: 'Autre' },
|
||||
{ key: 'bathroom', label: 'Salle de bain' },
|
||||
{ key: 'laundry', label: 'Buanderie' },
|
||||
{ key: 'technical', label: 'Local technique' }
|
||||
].map((r) => (
|
||||
<button
|
||||
key={r.key}
|
||||
type="button"
|
||||
className={`${s.typeTile} ${config.roomType === r.key ? s.typeTileActive : ''}`}
|
||||
onClick={() => set('roomType', r.key)}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dimension sliders */}
|
||||
<div className={s.dimGrid}>
|
||||
{DIMS.map(({ key, label, min, max, step, unit }) => (
|
||||
<div key={key} className={s.dimField}>
|
||||
<span className={s.dimLabel}>{label}</span>
|
||||
<div className={s.dimSliderRow}>
|
||||
<input
|
||||
type="range"
|
||||
className={s.dimSlider}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={config[key]}
|
||||
onChange={(e) => set(key, Number(e.target.value))}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className={s.dimValue}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={config[key]}
|
||||
onChange={(e) => set(key, Number(e.target.value) || min)}
|
||||
/>
|
||||
<span className={s.dimUnit}>{unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{config.installationType === 'free' && (
|
||||
<div className={`${s.hardwareGroup} ${s.finishHabillageGroup}`}>
|
||||
<label className={s.hardwareLabel}>Habillages de finition</label>
|
||||
<div className={s.finishSelector}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${s.finishOption} ${sideLeftEnabled ? s.finishOptionActive : ''}`}
|
||||
onClick={() => set('sideFinishLeftEnabled', !sideLeftEnabled)}
|
||||
>
|
||||
<span className={s.finishOptionLabel}>Côté gauche</span>
|
||||
<span className={s.finishOptionState}>{sideLeftEnabled ? 'Actif' : 'Retiré'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${s.finishOption} ${sideRightEnabled ? s.finishOptionActive : ''}`}
|
||||
onClick={() => set('sideFinishRightEnabled', !sideRightEnabled)}
|
||||
>
|
||||
<span className={s.finishOptionLabel}>Côté droit</span>
|
||||
<span className={s.finishOptionState}>{sideRightEnabled ? 'Actif' : 'Retiré'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${s.finishOption} ${topFinishEnabled ? s.finishOptionActive : ''}`}
|
||||
onClick={() => set('topFinishEnabled', !topFinishEnabled)}
|
||||
>
|
||||
<span className={s.finishOptionLabel}>Dessus</span>
|
||||
<span className={s.finishOptionState}>{topFinishEnabled ? 'Actif' : 'Retiré'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.installationType === 'niche' && (
|
||||
<div className={s.panelInfo}>
|
||||
<div className={s.panelInfoTitle}>Mesures niche complètes</div>
|
||||
<div className={s.ruleGrid3}>
|
||||
<label className={s.dimField}>
|
||||
<span className={s.dimLabel}>Largeur haut</span>
|
||||
<input className={s.dimValue} type="number" value={niche.widthTopMm || config.widthMm} onChange={(e) => set('nicheMeasurements', { ...niche, widthTopMm: Number(e.target.value) || config.widthMm })} />
|
||||
</label>
|
||||
<label className={s.dimField}>
|
||||
<span className={s.dimLabel}>Largeur milieu</span>
|
||||
<input className={s.dimValue} type="number" value={niche.widthMidMm || config.widthMm} onChange={(e) => set('nicheMeasurements', { ...niche, widthMidMm: Number(e.target.value) || config.widthMm })} />
|
||||
</label>
|
||||
<label className={s.dimField}>
|
||||
<span className={s.dimLabel}>Largeur bas</span>
|
||||
<input className={s.dimValue} type="number" value={niche.widthBottomMm || config.widthMm} onChange={(e) => set('nicheMeasurements', { ...niche, widthBottomMm: Number(e.target.value) || config.widthMm })} />
|
||||
</label>
|
||||
<label className={s.dimField}>
|
||||
<span className={s.dimLabel}>Profondeur haut</span>
|
||||
<input className={s.dimValue} type="number" value={niche.depthTopMm || config.depthMm} onChange={(e) => set('nicheMeasurements', { ...niche, depthTopMm: Number(e.target.value) || config.depthMm })} />
|
||||
</label>
|
||||
<label className={s.dimField}>
|
||||
<span className={s.dimLabel}>Profondeur milieu</span>
|
||||
<input className={s.dimValue} type="number" value={niche.depthMidMm || config.depthMm} onChange={(e) => set('nicheMeasurements', { ...niche, depthMidMm: Number(e.target.value) || config.depthMm })} />
|
||||
</label>
|
||||
<label className={s.dimField}>
|
||||
<span className={s.dimLabel}>Profondeur bas</span>
|
||||
<input className={s.dimValue} type="number" value={niche.depthBottomMm || config.depthMm} onChange={(e) => set('nicheMeasurements', { ...niche, depthBottomMm: Number(e.target.value) || config.depthMm })} />
|
||||
</label>
|
||||
<label className={s.dimField}>
|
||||
<span className={s.dimLabel}>Hauteur gauche</span>
|
||||
<input className={s.dimValue} type="number" value={niche.heightLeftMm || config.heightMm} onChange={(e) => set('nicheMeasurements', { ...niche, heightLeftMm: Number(e.target.value) || config.heightMm })} />
|
||||
</label>
|
||||
<label className={s.dimField}>
|
||||
<span className={s.dimLabel}>Hauteur droite</span>
|
||||
<input className={s.dimValue} type="number" value={niche.heightRightMm || config.heightMm} onChange={(e) => set('nicheMeasurements', { ...niche, heightRightMm: Number(e.target.value) || config.heightMm })} />
|
||||
</label>
|
||||
<label className={s.dimField}>
|
||||
<span className={s.dimLabel}>Vide sanitaire (mm)</span>
|
||||
<input className={s.dimValue} type="number" min={10} max={50} step={1} value={config.sanitaryGapMm || 30} onChange={(e) => set('sanitaryGapMm', Number(e.target.value) || 30)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className={s.panelRow}><span>Plus petite largeur:</span><strong>{minNicheWidth} mm</strong></div>
|
||||
<div className={s.panelRow}><span>Plus petite profondeur:</span><strong>{minNicheDepth} mm</strong></div>
|
||||
<div className={s.panelRow}><span>Plus petite hauteur:</span><strong>{minNicheHeight} mm</strong></div>
|
||||
<div className={s.panelRow}><span>Cote utile retenue:</span><strong>{usableWidth} × {usableHeight} × {usableDepth} mm</strong></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import s from './steps.module.css';
|
||||
import { getDoorHingeSide } from '@shared/manufacturing.js';
|
||||
|
||||
export default function StepDoors({ wc }) {
|
||||
const { config, set } = wc;
|
||||
const hw = config.hardware || {};
|
||||
const handleHeight = Number(hw.handleBoreHeightMm || 1000);
|
||||
const isNonStandardHandleHeight = handleHeight < 800 || handleHeight > 1200;
|
||||
const doorCount = Math.max(1, (config.modules?.length || 0) || (config.blocks?.length || 0) || 2);
|
||||
const hingeSides = Array.isArray(hw.doorHingeSides) ? hw.doorHingeSides : null;
|
||||
|
||||
const getHingeSideForDoor = (i) => {
|
||||
if (hingeSides && hingeSides.length === doorCount) {
|
||||
return hingeSides[i] === 'right' ? 'right' : 'left';
|
||||
}
|
||||
return getDoorHingeSide(i, doorCount, hw);
|
||||
};
|
||||
|
||||
const setDoorHingeSideAt = (i, side) => {
|
||||
const next = Array.from({ length: doorCount }, (_, idx) => getHingeSideForDoor(idx));
|
||||
next[i] = side === 'right' ? 'right' : 'left';
|
||||
wc.setHardware('doorHingeSides', next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={s.stepSection}>
|
||||
<h2 className={s.stepTitle}>Type de portes</h2>
|
||||
<p className={s.stepDesc}>Portes battantes uniquement pour le moment. Configurez charnières et poignées.</p>
|
||||
|
||||
<div className={s.cardToggle}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${s.cardToggleItem} ${config.doorType === 'hinged' ? s.cardToggleActive : ''}`}
|
||||
onClick={() => set('doorType', 'hinged')}
|
||||
>
|
||||
<span className={s.cardToggleIcon}>🚪</span>
|
||||
<span className={s.cardToggleLabel}>Battantes</span>
|
||||
<span className={s.cardToggleHint}>Portes classiques à charnières</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={s.hardwareGroup}>
|
||||
<label className={s.hardwareLabel}>Côté charnières (par porte)</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{Array.from({ length: doorCount }).map((_, i) => {
|
||||
const active = getHingeSideForDoor(i);
|
||||
return (
|
||||
<div key={`door-hinge-${i}`} style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<span style={{ minWidth: 88, fontSize: 13, color: '#334155', fontWeight: 600 }}>
|
||||
Porte {i + 1}
|
||||
</span>
|
||||
<div className={s.colCountBtns}>
|
||||
{[
|
||||
{ key: 'left', label: 'Gauche' },
|
||||
{ key: 'right', label: 'Droite' }
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={`${s.colCountBtn} ${active === item.key ? s.colCountActive : ''}`}
|
||||
onClick={() => setDoorHingeSideAt(i, item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.hardwareGroup}>
|
||||
<label className={s.hardwareLabel}>Valeurs par défaut (si non défini par porte)</label>
|
||||
<div className={s.detailRow} style={{ marginBottom: 8 }}>
|
||||
<span className={s.hint}>
|
||||
Si vous définissez « par porte » ci-dessus, ces réglages ne servent que de fallback.
|
||||
</span>
|
||||
</div>
|
||||
<label className={s.hardwareLabel}>Portes uniques: charnières gauche/droite</label>
|
||||
<div className={s.colCountBtns}>
|
||||
{[
|
||||
{ key: 'left', label: 'Gauche' },
|
||||
{ key: 'right', label: 'Droite' }
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={`${s.colCountBtn} ${hw.doorHingeSide === item.key ? s.colCountActive : ''}`}
|
||||
onClick={() => wc.setHardware('doorHingeSide', item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className={s.hardwareLabel} style={{ marginTop: 10 }}>Portes centrales: fiche gauche/droite</label>
|
||||
<div className={s.colCountBtns}>
|
||||
{[
|
||||
{ key: 'left', label: 'Fiche gauche' },
|
||||
{ key: 'right', label: 'Fiche droite' }
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={`${s.colCountBtn} ${hw.centerDoorHingeSide === item.key ? s.colCountActive : ''}`}
|
||||
onClick={() => wc.setHardware('centerDoorHingeSide', item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.hardwareGroup}>
|
||||
<label className={s.hardwareLabel}>Perçage poignée (axe depuis le sol)</label>
|
||||
<div className={s.dimSliderRow}>
|
||||
<input
|
||||
type="range"
|
||||
className={s.dimSlider}
|
||||
min={600}
|
||||
max={1600}
|
||||
step={10}
|
||||
value={handleHeight}
|
||||
onChange={(e) => wc.setHardware('handleBoreHeightMm', Number(e.target.value))}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className={s.dimValue}
|
||||
min={600}
|
||||
max={1600}
|
||||
step={10}
|
||||
value={handleHeight}
|
||||
onChange={(e) => wc.setHardware('handleBoreHeightMm', Number(e.target.value) || 1000)}
|
||||
/>
|
||||
<span className={s.dimUnit}>mm</span>
|
||||
</div>
|
||||
<p className={s.hint}>Plage standard visible sur plan: 1000 mm ± 200 mm.</p>
|
||||
{isNonStandardHandleHeight && (
|
||||
<p className={s.warnText}>Configuration non standard: hauteur hors plage standard.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.hardwareGroup}>
|
||||
<label className={s.hardwareLabel}>Type de poignée</label>
|
||||
<div className={s.colCountBtns}>
|
||||
{[
|
||||
{ key: 'standard', label: 'Standard' },
|
||||
{ key: 'full_height', label: 'Toute hauteur' }
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={`${s.colCountBtn} ${hw.handleType === item.key ? s.colCountActive : ''}`}
|
||||
onClick={() => wc.setHardware('handleType', item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.detailRow}>
|
||||
<span className={s.detailLabel}>Portes coulissantes</span>
|
||||
<span className={s.hint}>Indisponible pour le moment (support backend conservé).</span>
|
||||
</div>
|
||||
|
||||
{config.doorType === 'sliding' && (
|
||||
<div className={s.detailRow}>
|
||||
<span className={s.detailLabel}>Nombre de vantaux (legacy)</span>
|
||||
<div className={s.colCountBtns}>
|
||||
{[2, 3].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`${s.colCountBtn} ${config.slidingPanelsCount === n ? s.colCountActive : ''}`}
|
||||
onClick={() => set('slidingPanelsCount', n)}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { COLUMN_TYPES } from '../../constants/wardrobe';
|
||||
import { submitOrder } from '../../api/client';
|
||||
import Icon from '../ui/Icon';
|
||||
import s from './steps.module.css';
|
||||
|
||||
export default function StepFinalize({ wc, ui }) {
|
||||
const { config, priceResult, serviceOptions, selectedFinish, totalModuleWidth } = wc;
|
||||
const { orderForm, setOrderForm, orderSent, setOrderSent, setActiveStep } = ui;
|
||||
|
||||
const errors = priceResult?.errors || [];
|
||||
const totalChf = priceResult?.totalChf ?? 0;
|
||||
|
||||
const handleSubmitOrder = (e) => {
|
||||
e.preventDefault();
|
||||
submitOrder({ config, totalChf: priceResult?.totalChf, ...orderForm })
|
||||
.then((order) => {
|
||||
setOrderSent(true);
|
||||
ui.setLastOrderId(order?.id || null);
|
||||
})
|
||||
.catch(alert);
|
||||
};
|
||||
|
||||
// Build summary lines
|
||||
const installLabel = config.installationType === 'free' ? 'Libre' : 'Niche';
|
||||
const doorLabel = config.doorType === 'hinged' ? 'Battantes' : `Coulissantes (${config.slidingPanelsCount} vantaux) [legacy]`;
|
||||
const roomLabel = {
|
||||
other: 'Autre',
|
||||
bathroom: 'Salle de bain',
|
||||
laundry: 'Buanderie',
|
||||
technical: 'Local technique'
|
||||
}[config.roomType || 'other'];
|
||||
const columnsDesc = config.modules.map((m, i) => {
|
||||
const t = COLUMN_TYPES.find(ct => ct.key === m.type) || COLUMN_TYPES[0];
|
||||
return `${t.icon} ${t.label} (${m.widthMm}mm)`;
|
||||
}).join(', ');
|
||||
|
||||
return (
|
||||
<div className={s.stepSection}>
|
||||
<h2 className={s.stepTitle}>Récapitulatif & Commande</h2>
|
||||
|
||||
{errors.length > 0 && (
|
||||
<ul className={s.errors}>
|
||||
{errors.map((err, i) => <li key={i}>{err.message}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Configuration summary */}
|
||||
<div className={s.summaryCard}>
|
||||
<div className={s.summaryRow}>
|
||||
<span className={s.summaryLabel}>Installation</span>
|
||||
<span className={s.summaryValue}>
|
||||
{installLabel} · {roomLabel}
|
||||
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(1)}>modifier</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.summaryRow}>
|
||||
<span className={s.summaryLabel}>Dimensions</span>
|
||||
<span className={s.summaryValue}>
|
||||
{config.widthMm} × {config.heightMm} × {config.depthMm} mm
|
||||
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(1)}>modifier</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.summaryRow}>
|
||||
<span className={s.summaryLabel}>Colonnes ({config.modules.length})</span>
|
||||
<span className={s.summaryValue}>
|
||||
{totalModuleWidth} mm
|
||||
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(2)}>modifier</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.summaryRow}>
|
||||
<span className={s.summaryLabel}>Portes</span>
|
||||
<span className={s.summaryValue}>
|
||||
{doorLabel}
|
||||
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(3)}>modifier</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.summaryRow}>
|
||||
<span className={s.summaryLabel}>Matériau</span>
|
||||
<span className={s.summaryValue}>
|
||||
{selectedFinish?.label || 'Non s\u00e9lectionn\u00e9'}
|
||||
<button type="button" className={s.summaryEdit} onClick={() => setActiveStep(4)}>modifier</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Panel optimization */}
|
||||
{priceResult?.details?.panelOptimization && (
|
||||
<div className={s.panelInfo}>
|
||||
<div className={s.panelInfoTitle}>Optimisation panneaux</div>
|
||||
<div className={s.panelRow}>
|
||||
<span>Format optimal :</span>
|
||||
<strong>
|
||||
{priceResult.details.panelOptimization.selectedDimension.width_mm} ×{' '}
|
||||
{priceResult.details.panelOptimization.selectedDimension.height_mm} mm
|
||||
</strong>
|
||||
</div>
|
||||
<div className={s.panelRow}>
|
||||
<span>Panneaux nécessaires :</span>
|
||||
<strong>{priceResult.details.panelOptimization.panelsNeeded}</strong>
|
||||
</div>
|
||||
<div className={s.panelRow}>
|
||||
<span>Surface requise :</span>
|
||||
<span>{priceResult.surfaceM2?.toFixed(2)} m²</span>
|
||||
</div>
|
||||
<div className={s.panelRow}>
|
||||
<span>Chute estimée :</span>
|
||||
<span>{priceResult.details.panelOptimization.wasteM2?.toFixed(2)} m² ({priceResult.details.panelOptimization.wastePercent}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manufacturing specs (APEX 4.0) */}
|
||||
<div className={s.panelInfo}>
|
||||
<div className={s.panelInfoTitle}>Fabrication APEX 4.0</div>
|
||||
<div className={s.panelRow}>
|
||||
<span>Structure / Renforts / Dos :</span>
|
||||
<strong>19 mm / 16 mm / 8 mm</strong>
|
||||
</div>
|
||||
<div className={s.panelRow}>
|
||||
<span>System 32 :</span>
|
||||
<strong>Ø5 mm, prof. 12 mm, pas 32 mm</strong>
|
||||
</div>
|
||||
<div className={s.panelRow}>
|
||||
<span>Rainure dos :</span>
|
||||
<strong>9×10.5 mm, à 16 mm du bord</strong>
|
||||
</div>
|
||||
<div className={s.panelRow}>
|
||||
<span>Connecteurs structure :</span>
|
||||
<strong>SC 8/60 (extrémités T@20 → IX@50 → T@80 · milieu IX + T ±30 mm)</strong>
|
||||
</div>
|
||||
<div className={s.panelRow}>
|
||||
<span>Connecteurs finition :</span>
|
||||
<strong>SC 8/25 (axes 30/43 mm)</strong>
|
||||
</div>
|
||||
<div className={s.panelRow}>
|
||||
<span>Plinthe :</span>
|
||||
<strong>AXILO™ 78</strong>
|
||||
</div>
|
||||
{priceResult?.details?.reinforcementCount > 0 && (
|
||||
<div className={s.panelRow}>
|
||||
<span>Renforts Forteresse :</span>
|
||||
<strong>{priceResult.details.reinforcementCount} pcs (1× SC 8/60 + 2× tourillons)</strong>
|
||||
</div>
|
||||
)}
|
||||
{priceResult?.details?.reinforcementChf > 0 && (
|
||||
<div className={s.panelRow}>
|
||||
<span>Coût renforts :</span>
|
||||
<strong>CHF {priceResult.details.reinforcementChf.toFixed(2)}</strong>
|
||||
</div>
|
||||
)}
|
||||
{priceResult?.details?.blockExtraStructureChf > 0 && (
|
||||
<div className={s.panelRow}>
|
||||
<span>Surplus structure (assemblage block, montants suppl.) :</span>
|
||||
<strong>CHF {priceResult.details.blockExtraStructureChf.toFixed(2)}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className={s.actionBtns}>
|
||||
<button type="button" className={s.actionBtn} onClick={() => ui.setShowPlansView(true)}>
|
||||
<Icon name="blueprint" size={14} />
|
||||
Plans de fabrication
|
||||
</button>
|
||||
<button type="button" className={s.actionBtn} onClick={() => ui.setShowAIRender(true)}>
|
||||
<Icon name="sparkles" size={14} />
|
||||
Rendu IA réaliste
|
||||
</button>
|
||||
<button type="button" className={s.actionBtn} onClick={() => ui.setShowRoomUpload(true)}>
|
||||
<Icon name="eye" size={14} />
|
||||
Visualiser dans ma pièce
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Service options */}
|
||||
<div className={s.hardwareGroup}>
|
||||
<label className={s.hardwareLabel}>Options</label>
|
||||
{serviceOptions.map((opt) => (
|
||||
<label key={opt.id} className={s.checkLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(config.serviceOptionIds || []).includes(opt.id)}
|
||||
onChange={(e) => {
|
||||
const ids = config.serviceOptionIds || [];
|
||||
wc.set('serviceOptionIds', e.target.checked ? [...ids, opt.id] : ids.filter((id) => id !== opt.id));
|
||||
}}
|
||||
/>
|
||||
{opt.label} — CHF {opt.price_chf}/h
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className={s.priceBreakdown}>
|
||||
<div className={s.priceTotal}>
|
||||
<span>Total</span>
|
||||
<span>CHF {totalChf.toLocaleString('fr-CH', { minimumFractionDigits: 2 })}.–</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order form */}
|
||||
{!orderSent ? (
|
||||
<form onSubmit={handleSubmitOrder} className={s.orderForm}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email *"
|
||||
value={orderForm.email}
|
||||
onChange={(e) => setOrderForm((f) => ({ ...f, email: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nom"
|
||||
value={orderForm.name}
|
||||
onChange={(e) => setOrderForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="Téléphone"
|
||||
value={orderForm.phone}
|
||||
onChange={(e) => setOrderForm((f) => ({ ...f, phone: e.target.value }))}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Remarques"
|
||||
value={orderForm.notes}
|
||||
onChange={(e) => setOrderForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
rows={2}
|
||||
/>
|
||||
<button type="submit" className={s.submitBtn}>
|
||||
<Icon name="file" size={15} />
|
||||
Commander / Envoyer ma demande
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className={s.success}>Votre demande a bien été envoyée. Nous vous recontacterons.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import s from './steps.module.css';
|
||||
|
||||
export default function StepHardware({ wc }) {
|
||||
const { config, handleVariants, hingeVariants, blumHardware, setHardware } = wc;
|
||||
|
||||
const hasDrawers = config.modules.some(m => m.drawers > 0);
|
||||
const drawerType = config.hardware?.drawerType || 'visible';
|
||||
const standardHeights = [60, 90, 128, 177, 240];
|
||||
const currentHeights = config.hardware?.drawerHeightsMm || [90, 128, 177, 240];
|
||||
|
||||
return (
|
||||
<div className={s.stepSection}>
|
||||
<h2 className={s.stepTitle}>Quincaillerie</h2>
|
||||
<p className={s.stepDesc}>Sélectionnez les poignées, charnières et systèmes de tiroirs.</p>
|
||||
|
||||
{/* Handles */}
|
||||
<div className={s.hardwareGroup}>
|
||||
<label className={s.hardwareLabel}>Poignées</label>
|
||||
<div className={s.hardwareGrid}>
|
||||
{handleVariants.map(h => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
className={`${s.hardwareBtn} ${config.hardware.handleVariantId === h.id ? s.hardwareBtnSelected : ''}`}
|
||||
onClick={() => setHardware('handleVariantId', h.id)}
|
||||
title={`${h.name} - ${h.price_chf} CHF`}
|
||||
disabled={drawerType === 'visible' && hasDrawers}
|
||||
>
|
||||
{h.image_url ? (
|
||||
<img src={h.image_url} alt={h.name} />
|
||||
) : (
|
||||
<span className={s.hardwareIcon}>🚪</span>
|
||||
)}
|
||||
<span className={s.hardwareName}>{h.name}</span>
|
||||
<span className={s.hardwarePrice}>{h.price_chf} CHF</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{drawerType === 'visible' && hasDrawers && (
|
||||
<p className={s.hint}>Tiroir visible avec coulisse Blum Motion: poignée non requise.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hinges */}
|
||||
<div className={s.hardwareGroup}>
|
||||
<label className={s.hardwareLabel}>Charnières</label>
|
||||
<div className={s.hardwareGrid}>
|
||||
{hingeVariants.map(h => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
className={`${s.hardwareBtn} ${config.hardware.hingeVariantId === h.id ? s.hardwareBtnSelected : ''}`}
|
||||
onClick={() => setHardware('hingeVariantId', h.id)}
|
||||
>
|
||||
<span className={s.hardwareIcon}>{h.type === 'vissee' ? '\u{1F529}' : '\u{1F4CC}'}</span>
|
||||
<span className={s.hardwareName}>{h.name}</span>
|
||||
<span className={s.hardwareType}>{h.type === 'vissee' ? 'Viss\u00e9e' : 'Plant\u00e9e'}</span>
|
||||
<span className={s.hardwarePrice}>{h.price_chf} CHF</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drawer systems */}
|
||||
{hasDrawers && (
|
||||
<div className={s.hardwareGroup}>
|
||||
<label className={s.hardwareLabel}>Système de tiroirs (Blum)</label>
|
||||
<div className={s.hardwareGrid}>
|
||||
{blumHardware.filter(h => h.type === 'drawer_system').map(h => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
className={`${s.hardwareBtn} ${config.hardware.drawerSystemId === h.id ? s.hardwareBtnSelected : ''}`}
|
||||
onClick={() => setHardware('drawerSystemId', h.id)}
|
||||
>
|
||||
<span className={s.hardwareIcon}>🗄️</span>
|
||||
<span className={s.hardwareName}>{h.name}</span>
|
||||
<span className={s.hardwarePrice}>{h.price_chf} CHF</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={s.panelInfo}>
|
||||
<div className={s.panelInfoTitle}>Hauteurs standard tiroirs</div>
|
||||
<div className={s.typeTiles}>
|
||||
{standardHeights.map((mm) => {
|
||||
const active = currentHeights.includes(mm);
|
||||
return (
|
||||
<button
|
||||
key={mm}
|
||||
type="button"
|
||||
className={`${s.typeTile} ${active ? s.typeTileActive : ''}`}
|
||||
onClick={() => {
|
||||
const next = active
|
||||
? currentHeights.filter((h) => h !== mm)
|
||||
: [...currentHeights, mm].sort((a, b) => a - b);
|
||||
setHardware('drawerHeightsMm', next.slice(0, 4));
|
||||
}}
|
||||
>
|
||||
{mm} mm
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className={s.hint}>4 hauteurs standard max (hypothèse codée en dur pour cette version).</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import s from './steps.module.css';
|
||||
|
||||
const ELEMENT_LABELS = {
|
||||
portes: 'Portes',
|
||||
cotes: 'C\u00f4t\u00e9s',
|
||||
dessus: 'Dessus',
|
||||
fond: 'Fond',
|
||||
dos: 'Dos',
|
||||
etageres: '\u00c9tag\u00e8res',
|
||||
tiroirs: 'Tiroirs',
|
||||
separateurs: 'S\u00e9parateurs',
|
||||
plintheBas: 'Plinthe',
|
||||
bandeauHaut: 'Bandeau haut',
|
||||
};
|
||||
|
||||
export default function StepMaterials({ wc, ui }) {
|
||||
const { config, finishes, selectedFinish } = wc;
|
||||
const roomType = config.roomType || 'other';
|
||||
const needsHydro = roomType === 'bathroom' || roomType === 'laundry';
|
||||
const needsFire = roomType === 'technical';
|
||||
const hasOverride = !!config.allowMaterialOverride;
|
||||
const normalize = (v) => String(v || '').toLowerCase();
|
||||
const isHydro = (m) =>
|
||||
[normalize(m.category), normalize(m.collection), normalize(m.label), normalize(m.name), normalize(m.code)]
|
||||
.join(' ')
|
||||
.match(/hydro|hydrofuge|hpl|marine/);
|
||||
const isFire = (m) =>
|
||||
[normalize(m.category), normalize(m.collection), normalize(m.label), normalize(m.name), normalize(m.code)]
|
||||
.join(' ')
|
||||
.match(/igni|fire|m1|a2|incomb/);
|
||||
const allowedMaterial = (m) => {
|
||||
if (hasOverride) return true;
|
||||
if (needsHydro) return !!isHydro(m);
|
||||
if (needsFire) return !!isFire(m);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Quick pick: first 10 finishes with images
|
||||
const quickPicks = finishes.filter((f) => f.image_url && allowedMaterial(f)).slice(0, 10);
|
||||
|
||||
return (
|
||||
<div className={s.stepSection}>
|
||||
<h2 className={s.stepTitle}>Matériaux & Couleurs</h2>
|
||||
<p className={s.stepDesc}>Choisissez le matériau principal et personnalisez les couleurs par élément.</p>
|
||||
|
||||
{(needsHydro || needsFire) && !hasOverride && (
|
||||
<p className={s.warnText}>
|
||||
{needsHydro
|
||||
? 'Pièce humide: seuls les panneaux hydrofuges sont autorisés.'
|
||||
: 'Local technique: seuls les panneaux ignifuges sont autorisés.'}
|
||||
</p>
|
||||
)}
|
||||
{(needsHydro || needsFire) && hasOverride && (
|
||||
<p className={s.warnText}>Dérogation active: tous les matériaux sont affichés (configuration non standard).</p>
|
||||
)}
|
||||
|
||||
{(needsHydro || needsFire) && (
|
||||
<label className={s.overrideToggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasOverride}
|
||||
onChange={(e) => wc.set('allowMaterialOverride', e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>Dérogation matériaux</strong>
|
||||
<small>Débloquer tous les matériaux avec avertissement.</small>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Current material */}
|
||||
{selectedFinish && (
|
||||
<div className={s.currentMaterial}>
|
||||
{selectedFinish.image_url ? (
|
||||
<img src={selectedFinish.image_url} alt={selectedFinish.label} className={s.currentMaterialImg} />
|
||||
) : (
|
||||
<div className={s.quickSwatchNoImg}>?</div>
|
||||
)}
|
||||
<div className={s.currentMaterialInfo}>
|
||||
<strong>{selectedFinish.label}</strong>
|
||||
<span>{selectedFinish.code} — {selectedFinish.collection}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick pick row */}
|
||||
<div className={s.quickPick}>
|
||||
{quickPicks.map(mat => (
|
||||
<button
|
||||
key={mat.id}
|
||||
type="button"
|
||||
className={`${s.quickSwatch} ${mat.id === config.finishId ? s.quickSwatchActive : ''}`}
|
||||
onClick={() => {
|
||||
ui.openMaterialDetail(mat);
|
||||
}}
|
||||
title={mat.label}
|
||||
>
|
||||
<img src={mat.image_url} alt={mat.label} className={s.quickSwatchImg} />
|
||||
<span className={s.quickSwatchLabel}>{mat.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={s.browseBtn}
|
||||
onClick={() => ui.setShowMaterialPicker(true)}
|
||||
>
|
||||
{(needsHydro || needsFire) && !hasOverride
|
||||
? 'Parcourir les matériaux autorisés'
|
||||
: 'Parcourir tous les matériaux'}
|
||||
</button>
|
||||
|
||||
<div className={s.panelInfo}>
|
||||
<div className={s.panelInfoTitle}>Contraintes chants et finitions</div>
|
||||
<div className={s.panelRow}>
|
||||
<span>Bande de chant:</span>
|
||||
<strong>même couleur que la face extérieure</strong>
|
||||
</div>
|
||||
<div className={s.panelRow}>
|
||||
<span>Mode libre:</span>
|
||||
<strong>couleur doublage au choix</strong>
|
||||
</div>
|
||||
<div className={s.panelRow}>
|
||||
<span>Mode encastré:</span>
|
||||
<strong>fileurs + bandeau couleur face extérieure</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-element color dots */}
|
||||
<div className={s.colorDotsTitle}>Couleur par élément</div>
|
||||
<div className={s.colorDots}>
|
||||
{Object.entries(ELEMENT_LABELS).map(([key, label]) => {
|
||||
const c = config.colors[key];
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={s.colorDot}
|
||||
onClick={() => ui.openColorPicker(key, label)}
|
||||
title={`${label}: ${c?.label || 'Non d\u00e9fini'}`}
|
||||
>
|
||||
<span
|
||||
className={s.colorDotSwatch}
|
||||
style={{ background: c?.hexColor || '#ccc' }}
|
||||
/>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { STEPS } from '../../constants/wardrobe';
|
||||
import styles from './StepperBar.module.css';
|
||||
|
||||
export default function StepperBar({ activeStep, onStepChange, totalChf, touched }) {
|
||||
return (
|
||||
<div className={styles.stepperBar}>
|
||||
{STEPS.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`${styles.step} ${activeStep === id ? styles.stepActive : ''}`}
|
||||
onClick={() => onStepChange(id)}
|
||||
>
|
||||
<span className={styles.stepNum}>
|
||||
{touched?.has(id) && activeStep !== id ? (
|
||||
<span className={styles.stepCheck}>✓</span>
|
||||
) : id}
|
||||
</span>
|
||||
<span className={styles.stepLabel}>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
<span className={styles.spacer} />
|
||||
<div className={styles.priceBadge}>
|
||||
<span className={styles.priceLabel}>CHF</span>
|
||||
{(totalChf || 0).toLocaleString('fr-CH', { minimumFractionDigits: 0 })}.–
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
.stepperBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 60;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.stepperBar::-webkit-scrollbar { display: none; }
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.step:hover { background: var(--bg); color: var(--text); }
|
||||
|
||||
.stepActive {
|
||||
background: var(--action);
|
||||
color: #fff;
|
||||
}
|
||||
.stepActive:hover {
|
||||
background: var(--action);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stepNum {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
background: var(--border);
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stepActive .stepNum {
|
||||
background: rgba(255,255,255,0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stepCheck {
|
||||
color: var(--success, #22c55e);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.spacer { flex: 1; min-width: 0.5rem; }
|
||||
|
||||
.priceBadge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.priceLabel {
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.stepperBar { padding: 0.4rem 0.5rem; gap: 0.15rem; }
|
||||
.step { padding: 0.4rem 0.6rem; font-size: 0.75rem; }
|
||||
.stepLabel { display: none; }
|
||||
.priceBadge { font-size: 0.8125rem; padding: 0.35rem 0.65rem; }
|
||||
}
|
||||
@@ -0,0 +1,987 @@
|
||||
/* Shared step styles */
|
||||
.stepSection {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.stepTitle {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.stepDesc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin: -0.5rem 0 1rem;
|
||||
}
|
||||
.stepDescExtra {
|
||||
display: block;
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* Card toggle (installation type, door type) */
|
||||
.cardToggle {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.cardToggleItem {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.15rem;
|
||||
min-height: 5.85rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.cardToggleItem:hover { border-color: var(--text-muted); }
|
||||
.cardToggleActive {
|
||||
border-color: var(--action);
|
||||
background: color-mix(in srgb, var(--action) 6%, var(--bg-card));
|
||||
}
|
||||
.cardToggleIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
.cardToggleIconSvg {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.cardToggleLabel {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.cardToggleHint {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.12rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.cardToggleActive .cardToggleIcon {
|
||||
color: var(--action);
|
||||
}
|
||||
|
||||
/* Dimension sliders */
|
||||
.dimGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.dimField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.dimLabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dimSliderRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.dimSlider {
|
||||
flex: 1;
|
||||
accent-color: var(--action);
|
||||
height: 6px;
|
||||
}
|
||||
.dimValue {
|
||||
min-width: 4.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
font-size: 0.8125rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dimUnit {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Column count row */
|
||||
.colCountRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.colCountLabel {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
.colCountBtns {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
.colCountBtn {
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.55rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.colCountBtn:hover { border-color: var(--action); }
|
||||
.colCountActive {
|
||||
background: var(--action);
|
||||
border-color: var(--action);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.optimizeBtn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--action);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--action);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
margin-left: 0.35rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.optimizeBtn:hover { background: var(--action); color: #fff; }
|
||||
.optimizeBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.aiMsg {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: color-mix(in srgb, var(--action) 8%, var(--bg));
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.aiBadge {
|
||||
display: inline-block;
|
||||
background: var(--action);
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.aiDismiss {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Column strip (horizontal cards mirroring 3D) */
|
||||
.columnStripLegend {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 0.45rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.columnStrip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(4.85rem, 1fr));
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.75rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Many columns: allow horizontal scroll instead of squashing */
|
||||
@media (max-width: 520px) {
|
||||
.columnStrip {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.35rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.colCard {
|
||||
flex: 0 0 auto;
|
||||
min-width: 5.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.colCard {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.45rem 0.5rem;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
cursor: grab;
|
||||
text-align: center;
|
||||
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s, opacity 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
.colCard:active { cursor: grabbing; }
|
||||
.colCard:hover { border-color: var(--text-muted); }
|
||||
.colCardActive { border-color: var(--action); background: color-mix(in srgb, var(--action) 6%, var(--bg-card)); }
|
||||
.colCardLocked { opacity: 0.7; }
|
||||
.colCardDragging {
|
||||
opacity: 0.55;
|
||||
cursor: grabbing;
|
||||
}
|
||||
.colCardDropTarget {
|
||||
border-color: var(--action);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--action) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--action) 10%, var(--bg-card));
|
||||
}
|
||||
.colCardIndex {
|
||||
display: block;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--action);
|
||||
line-height: 1.15;
|
||||
margin-bottom: 0.2rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.colDragHint {
|
||||
display: block;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
margin-bottom: 0.15rem;
|
||||
opacity: 0.75;
|
||||
user-select: none;
|
||||
}
|
||||
.colCardIcon { font-size: 1.2rem; display: block; }
|
||||
.colCardType { font-size: 0.6875rem; font-weight: 600; color: var(--text); margin-top: 0.15rem; }
|
||||
.colCardWidth { font-size: 0.625rem; color: var(--text-muted); }
|
||||
.colLockIcon {
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
right: 0.25rem;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
/* Column detail tray (expanded below strip) */
|
||||
.colDetail {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
margin-bottom: 0.75rem;
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
.colDetailHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.colDetailTitle {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.lockBtn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.lockBtn:hover { border-color: var(--text); }
|
||||
.isLocked { background: var(--bg); }
|
||||
|
||||
/* Type selector tiles */
|
||||
.typeTiles {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.typeTile {
|
||||
padding: 0.4rem 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.typeTile:hover { border-color: var(--action); }
|
||||
.typeTileActive {
|
||||
border-color: var(--action);
|
||||
background: color-mix(in srgb, var(--action) 10%, var(--bg));
|
||||
color: var(--action);
|
||||
}
|
||||
|
||||
/* Counter rows */
|
||||
.detailRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
.detailLabel {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.counter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.counterBtn {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-card);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.counterBtn:hover:not(:disabled) { border-color: var(--action); }
|
||||
.counterBtn:disabled { opacity: 0.3; cursor: default; }
|
||||
.counterValue {
|
||||
min-width: 1.5rem;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.gapModeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.gapAutoLabel {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.gapCustomizeBtn {
|
||||
padding: 0.25rem 0.55rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-card);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
color: var(--action);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.gapCustomizeBtn:hover:not(:disabled) {
|
||||
border-color: var(--action);
|
||||
background: color-mix(in srgb, var(--action) 8%, var(--bg-card));
|
||||
}
|
||||
.gapCustomizeBtn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.gapRangeRow {
|
||||
padding: 0 0 0.35rem;
|
||||
margin-top: -0.15rem;
|
||||
}
|
||||
.gapRange {
|
||||
width: 100%;
|
||||
accent-color: var(--action);
|
||||
}
|
||||
|
||||
/* Toggle button */
|
||||
.toggleBtn {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.toggleOn {
|
||||
background: var(--action);
|
||||
border-color: var(--action);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Summary line */
|
||||
.summaryLine {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Hardware grid */
|
||||
.hardwareGroup {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/** Extra air above habillage row (after dimension sliders). */
|
||||
.finishHabillageGroup {
|
||||
padding-top: 0.85rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.hardwareLabel {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.hardwareGrid {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hardwareBtn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
cursor: pointer;
|
||||
min-width: 5.5rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.hardwareBtn:hover { border-color: var(--text-muted); }
|
||||
.hardwareBtnSelected {
|
||||
border-color: var(--action);
|
||||
background: color-mix(in srgb, var(--action) 6%, var(--bg-card));
|
||||
}
|
||||
.hardwareBtn img {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.hardwareIcon { font-size: 1.5rem; }
|
||||
.hardwareName { font-size: 0.6875rem; font-weight: 600; color: var(--text); text-align: center; }
|
||||
.hardwareType { font-size: 0.625rem; color: var(--text-muted); }
|
||||
.hardwarePrice { font-size: 0.6875rem; color: var(--action); font-weight: 500; }
|
||||
.skipBtn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.skipBtn:hover { color: var(--text); border-color: var(--text-muted); }
|
||||
|
||||
/* Material quick pick */
|
||||
.quickPick {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.35rem;
|
||||
margin-bottom: 1rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.quickSwatch {
|
||||
flex-shrink: 0;
|
||||
width: 4.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.quickSwatch:hover { border-color: var(--text-muted); }
|
||||
.quickSwatchActive { border-color: var(--action); }
|
||||
.quickSwatchImg {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
background: var(--bg);
|
||||
}
|
||||
.quickSwatchNoImg {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 6px;
|
||||
background: var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.quickSwatchLabel {
|
||||
font-size: 0.5625rem;
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.browseBtn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.browseBtn:hover { border-color: var(--action); color: var(--action); }
|
||||
|
||||
.currentMaterial {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.currentMaterialImg {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.currentMaterialInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.currentMaterialInfo strong { font-size: 0.875rem; }
|
||||
.currentMaterialInfo span { font-size: 0.75rem; color: var(--text-muted); }
|
||||
|
||||
/* Element color dots */
|
||||
.colorDotsTitle {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.colorDots {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.colorDot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
cursor: pointer;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.colorDot:hover { border-color: var(--action); }
|
||||
.colorDotSwatch {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Finalize: summary card */
|
||||
.summaryCard {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.summaryRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.35rem 0;
|
||||
font-size: 0.8125rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
|
||||
}
|
||||
.summaryRow:last-child { border-bottom: none; }
|
||||
.summaryLabel { color: var(--text-muted); }
|
||||
.summaryValue { color: var(--text); font-weight: 500; }
|
||||
.summaryEdit {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--action);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.summaryEdit:hover { text-decoration: underline; }
|
||||
|
||||
/* Price breakdown */
|
||||
.priceBreakdown {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.priceRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.priceTotal {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
border-top: 2px solid var(--primary);
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.actionBtns {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.actionBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
border: 1px solid var(--action);
|
||||
border-radius: 9px;
|
||||
background: var(--bg-card);
|
||||
color: var(--action);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.actionBtn:hover {
|
||||
box-shadow: 0 8px 18px rgba(79, 70, 229, 0.18);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Order form */
|
||||
.orderForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.orderForm input,
|
||||
.orderForm textarea {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.orderForm textarea { resize: vertical; }
|
||||
.submitBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.7rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--action), var(--action-hover));
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s ease;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.submitBtn:hover {
|
||||
box-shadow: 0 10px 22px rgba(79, 70, 229, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 1rem;
|
||||
background: color-mix(in srgb, var(--success, #22c55e) 10%, var(--bg-card));
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.errors {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.errors li {
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: color-mix(in srgb, #ef4444 10%, var(--bg-card));
|
||||
border-radius: 5px;
|
||||
font-size: 0.8125rem;
|
||||
color: #ef4444;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Checkboxes for service options */
|
||||
.checkLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Panel optimization info */
|
||||
.panelInfo {
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.panelInfoTitle {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.panelRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
|
||||
.finishSelector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.45rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.finishOption {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.finishOption:hover {
|
||||
border-color: var(--action);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.finishOptionActive {
|
||||
border-color: var(--action);
|
||||
background: color-mix(in srgb, var(--action) 10%, var(--bg-card));
|
||||
box-shadow: 0 8px 18px color-mix(in srgb, var(--action) 20%, transparent);
|
||||
}
|
||||
|
||||
.finishOptionLabel {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.finishOptionState {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ruleGrid3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.warnText {
|
||||
font-size: 0.8125rem;
|
||||
color: #b45309;
|
||||
background: color-mix(in srgb, #f59e0b 10%, var(--bg-card));
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 30%, var(--border));
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
|
||||
.overrideToggle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.55rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
margin: 0.4rem 0 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overrideToggle input {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.overrideToggle span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.12rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.overrideToggle small {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.dimGrid { grid-template-columns: 1fr; }
|
||||
.cardToggle { flex-direction: column; }
|
||||
.columnStrip { gap: 0.25rem; }
|
||||
.ruleGrid3 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Compact mode for desktop split-panel (keeps everything visible) */
|
||||
@media (min-width: 1000px) {
|
||||
.stepTitle {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.stepDesc {
|
||||
margin: -0.35rem 0 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.cardToggle {
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.cardToggleItem {
|
||||
min-height: 4.6rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.cardToggleLabel {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.cardToggleHint {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.dimGrid {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Prevent right-column slider rows from being clipped in narrow 1/3 panel */
|
||||
.dimSliderRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 4.1rem auto;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dimSlider {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dimLabel {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.dimValue {
|
||||
min-width: 4.1rem;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.colCountRow {
|
||||
margin-bottom: 0.6rem;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.aiMsg {
|
||||
padding: 0.5rem 0.65rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export default function Icon({ name, size = 16, className = '' }) {
|
||||
const props = {
|
||||
width: size,
|
||||
height: size,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
'aria-hidden': 'true',
|
||||
className
|
||||
};
|
||||
|
||||
switch (name) {
|
||||
case 'logout':
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="m16 17 5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
</svg>
|
||||
);
|
||||
case 'sync':
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M3 12a9 9 0 0 1 15.5-6.4L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-15.5 6.4L3 16" />
|
||||
<path d="M3 21v-5h5" />
|
||||
</svg>
|
||||
);
|
||||
case 'eye':
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
case 'download':
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
<path d="M12 15V3" />
|
||||
</svg>
|
||||
);
|
||||
case 'trash':
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
);
|
||||
case 'file':
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
</svg>
|
||||
);
|
||||
case 'blueprint':
|
||||
return (
|
||||
<svg {...props}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
|
||||
</svg>
|
||||
);
|
||||
case 'sparkles':
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8Z" />
|
||||
<path d="M5 3v3M3.5 4.5h3M19 16v5M16.5 18.5h5" />
|
||||
</svg>
|
||||
);
|
||||
case 'x':
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { PANEL } from '@shared/manufacturing.js';
|
||||
|
||||
/** Inner carcass height (mm): floor to ceiling inside side panels. */
|
||||
export function innerCarcassHeightMm(heightMm, plinthHeightMm) {
|
||||
return heightMm - plinthHeightMm - 2 * PANEL.structure;
|
||||
}
|
||||
|
||||
/** Max clear vertical gap (mm) between shelf faces for n shelves; n >= 2. */
|
||||
export function maxShelfGapMm(heightMm, plinthHeightMm, shelfCount) {
|
||||
const H = innerCarcassHeightMm(heightMm, plinthHeightMm);
|
||||
const n = shelfCount;
|
||||
if (n < 2) return 0;
|
||||
const t = PANEL.structure;
|
||||
if (H <= n * t) return 0;
|
||||
return Math.floor((H - n * t) / (n - 1));
|
||||
}
|
||||
|
||||
export const SHELF_GAP_MM = { min: 80, max: 520, step: 10 };
|
||||
export const SYSTEM32_PITCH_MM = 32;
|
||||
export const SYSTEM32_FIRST_HOLE_MM = 51;
|
||||
|
||||
/** Above this carcass height (mm), at least one shelf per column is required (rigidité / RAZENTZAB). */
|
||||
export const STRUCTURAL_MIN_SHELF_HEIGHT_MM = 2000;
|
||||
|
||||
/** Minimum shelf count for a column at this height (0 or 1). */
|
||||
export function minShelvesForStructuralHeight(heightMm) {
|
||||
return (Number(heightMm) || 0) > STRUCTURAL_MIN_SHELF_HEIGHT_MM ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shelf center Y (meters), wardrobe group space — matches WardrobePreview formulas.
|
||||
* @param {number|null|undefined} shelfGapMm — clear opening between shelf faces; null = equal division (default).
|
||||
*/
|
||||
export function shelfCenterYsMeters({
|
||||
shelfCount,
|
||||
heightM,
|
||||
plinthHM,
|
||||
shelfGapMm,
|
||||
shelfPositionsMm,
|
||||
}) {
|
||||
const T = PANEL.structure / 1000;
|
||||
const bodyH = heightM - plinthHM;
|
||||
const H_inner = bodyH - 2 * T;
|
||||
const yBase = -heightM / 2 + plinthHM + T;
|
||||
|
||||
if (shelfCount <= 0) return [];
|
||||
|
||||
if (Array.isArray(shelfPositionsMm) && shelfPositionsMm.length === shelfCount) {
|
||||
return shelfPositionsMm.map((posMm) => -heightM / 2 + plinthHM + Number(posMm) / 1000);
|
||||
}
|
||||
|
||||
const equal = () =>
|
||||
Array.from({ length: shelfCount }, (_, i) =>
|
||||
yBase + (H_inner / (shelfCount + 1)) * (i + 1)
|
||||
);
|
||||
|
||||
if (shelfCount === 1 || shelfGapMm == null || !Number.isFinite(Number(shelfGapMm))) {
|
||||
return equal();
|
||||
}
|
||||
|
||||
const n = shelfCount;
|
||||
const gReq = Number(shelfGapMm) / 1000;
|
||||
const maxG = n > 1 ? (H_inner - n * T) / (n - 1) : 0;
|
||||
if (n > 1 && !(maxG > 0)) {
|
||||
return equal();
|
||||
}
|
||||
const gUse = n > 1 ? Math.max(0, Math.min(gReq, maxG)) : 0;
|
||||
const needed = n * T + (n > 1 ? (n - 1) * gUse : 0);
|
||||
const m = (H_inner - needed) / 2;
|
||||
return Array.from({ length: n }, (_, i) =>
|
||||
yBase + m + T / 2 + i * (T + (n > 1 ? gUse : 0))
|
||||
);
|
||||
}
|
||||
|
||||
export function system32ShelfPositionsMm(heightMm, plinthHeightMm) {
|
||||
const bodyH = (Number(heightMm) || 0) - (Number(plinthHeightMm) || 0);
|
||||
if (!(bodyH > 0)) return [];
|
||||
const minPos = SYSTEM32_FIRST_HOLE_MM;
|
||||
const maxPos = bodyH - SYSTEM32_FIRST_HOLE_MM;
|
||||
if (maxPos < minPos) return [];
|
||||
const count = Math.floor((maxPos - minPos) / SYSTEM32_PITCH_MM) + 1;
|
||||
return Array.from({ length: Math.max(0, count) }, (_, i) => minPos + i * SYSTEM32_PITCH_MM);
|
||||
}
|
||||
|
||||
export function resolveShelfPositionsMm({
|
||||
shelfCount,
|
||||
heightMm,
|
||||
plinthHeightMm,
|
||||
shelfPositionsMm
|
||||
}) {
|
||||
const n = Math.max(0, Number(shelfCount) || 0);
|
||||
if (n <= 0) return [];
|
||||
const slots = system32ShelfPositionsMm(heightMm, plinthHeightMm);
|
||||
if (slots.length <= 0) return [];
|
||||
if (n >= slots.length) return slots.slice(0, n);
|
||||
|
||||
const toNearestIndex = (valueMm) => {
|
||||
const v = Number(valueMm);
|
||||
if (!Number.isFinite(v)) return null;
|
||||
let bestIdx = 0;
|
||||
let bestDist = Infinity;
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
const d = Math.abs(slots[i] - v);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
return bestIdx;
|
||||
};
|
||||
|
||||
let indexes = [];
|
||||
if (Array.isArray(shelfPositionsMm) && shelfPositionsMm.length === n) {
|
||||
indexes = shelfPositionsMm.map(toNearestIndex);
|
||||
} else {
|
||||
indexes = Array.from({ length: n }, (_, i) => {
|
||||
const t = ((i + 1) * (slots.length + 1)) / (n + 1);
|
||||
return Math.max(0, Math.min(slots.length - 1, Math.round(t) - 1));
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const minIdx = i;
|
||||
const idx = Number.isFinite(indexes[i]) ? indexes[i] : minIdx;
|
||||
indexes[i] = Math.max(minIdx, Math.min(slots.length - (n - i), idx));
|
||||
}
|
||||
for (let i = n - 2; i >= 0; i--) {
|
||||
indexes[i] = Math.min(indexes[i], indexes[i + 1] - 1);
|
||||
}
|
||||
|
||||
return indexes.map((idx) => slots[idx]);
|
||||
}
|
||||
|
||||
// Column element types -- single source of truth
|
||||
export const COLUMN_TYPES = [
|
||||
{ key: 'penderie', label: 'Penderie', icon: '\u{1F454}', shelves: 1, drawers: 0, hasRod: true, defaultWidth: 600, minWidth: 400, maxWidth: 1200 },
|
||||
{ key: 'tiroirs', label: 'Tiroirs', icon: '\u{1F5C4}\uFE0F', shelves: 0, drawers: 3, hasRod: false, defaultWidth: 500, minWidth: 400, maxWidth: 800 },
|
||||
{ key: 'rayonnage', label: 'Rayonnage', icon: '\u{1F4DA}', shelves: 4, drawers: 0, hasRod: false, defaultWidth: 500, minWidth: 400, maxWidth: 900 },
|
||||
{ key: 'bloc_carre', label: 'Bloc carr\u00e9', icon: '\u2B1C', shelves: 0, drawers: 0, hasRod: false, defaultWidth: 400, minWidth: 300, maxWidth: 600 },
|
||||
{ key: 'mixte', label: 'Mixte', icon: '\u{1F4E6}', shelves: 2, drawers: 2, hasRod: true, defaultWidth: 600, minWidth: 400, maxWidth: 1000 },
|
||||
];
|
||||
|
||||
// Default cycling types when creating new columns
|
||||
export const DEFAULT_CYCLE = ['penderie', 'rayonnage', 'tiroirs'];
|
||||
|
||||
// Default colors for each element type — must include ALL element types rendered in 3D
|
||||
export const DEFAULT_COLORS = {
|
||||
plintheBas: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#8b7355' },
|
||||
fileur: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#8b7355' },
|
||||
bandeauHaut: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#8b7355' },
|
||||
champs: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#6b5344' },
|
||||
caisse: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
|
||||
cotesDoublage: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
|
||||
habillageFinitionGauche: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
|
||||
habillageFinitionDroite: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
|
||||
habillageFinitionDessus: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
|
||||
cotes: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#b09060' },
|
||||
dessus: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
|
||||
fond: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#d4c4a4' },
|
||||
dos: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#d4c4a4' },
|
||||
portes: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#a08060' },
|
||||
panneauxInterieurs: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#d4c4a4' },
|
||||
separateurs: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#6b5344' },
|
||||
etageres: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#d4c4a4' },
|
||||
rayonFixe: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#d4c4a4' },
|
||||
renfortsTiroir: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
|
||||
tiroirs: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#c4a574' },
|
||||
penderie: { finishId: null, label: 'Non d\u00e9fini', hexColor: '#8b7355' }
|
||||
};
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
roomType: 'other', // other | bathroom | laundry | technical
|
||||
allowMaterialOverride: false,
|
||||
installationType: 'free',
|
||||
widthMm: 2400,
|
||||
heightMm: 2400,
|
||||
depthMm: 600,
|
||||
plinthHeightMm: 100,
|
||||
exteriorFinishesEnabled: true,
|
||||
sideFinishesEnabled: true,
|
||||
sideFinishLeftEnabled: true,
|
||||
sideFinishRightEnabled: true,
|
||||
topFinishEnabled: true,
|
||||
nicheMeasurements: {
|
||||
widthTopMm: 2400,
|
||||
widthMidMm: 2400,
|
||||
widthBottomMm: 2400,
|
||||
depthTopMm: 600,
|
||||
depthMidMm: 600,
|
||||
depthBottomMm: 600,
|
||||
heightLeftMm: 2400,
|
||||
heightRightMm: 2400
|
||||
},
|
||||
sanitaryGapMm: 30,
|
||||
modules: [],
|
||||
columnMode: 'separations', // separations | block
|
||||
colors: { ...DEFAULT_COLORS },
|
||||
hardware: {
|
||||
handleVariantId: null,
|
||||
hingeVariantId: null,
|
||||
drawerSystemId: null,
|
||||
/**
|
||||
* Optional per-door hinge side override.
|
||||
* When present and length matches door count, each entry is 'left'|'right' for door i.
|
||||
*/
|
||||
doorHingeSides: null,
|
||||
doorHingeSide: 'left', // left | right
|
||||
centerDoorHingeSide: 'left', // left | right
|
||||
handleBoreHeightMm: 1000,
|
||||
handleType: 'standard', // standard | full_height
|
||||
drawerType: 'visible', // visible | hidden
|
||||
drawerHeightsMm: [90, 128, 177, 240]
|
||||
},
|
||||
doorType: 'hinged',
|
||||
slidingPanelsCount: 2,
|
||||
finishId: null,
|
||||
dimensionId: null,
|
||||
serviceOptionIds: []
|
||||
};
|
||||
|
||||
export const STEPS = [
|
||||
{ id: 1, key: 'espace', label: 'Espace' },
|
||||
{ id: 2, key: 'colonnes', label: 'Colonnes' },
|
||||
{ id: 3, key: 'portes', label: 'Portes' },
|
||||
{ id: 4, key: 'materiaux', label: 'Mat\u00e9riaux' },
|
||||
{ id: 5, key: 'quincaillerie', label: 'Quincaillerie' },
|
||||
{ id: 6, key: 'finaliser', label: 'Finaliser' },
|
||||
];
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const SESSION_STEP_KEY = 'apex_wardrobe_step';
|
||||
const PLANS_OPEN_KEY = 'apex.plans.open';
|
||||
|
||||
function readPlansOpenFlag() {
|
||||
try {
|
||||
if (localStorage.getItem(PLANS_OPEN_KEY) === '1') return true;
|
||||
if (sessionStorage.getItem(PLANS_OPEN_KEY) === '1') {
|
||||
localStorage.setItem(PLANS_OPEN_KEY, '1');
|
||||
sessionStorage.removeItem(PLANS_OPEN_KEY);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function useConfiguratorUI() {
|
||||
const [activeStep, setActiveStep] = useState(() => {
|
||||
try { return Number(sessionStorage.getItem(SESSION_STEP_KEY)) || 1; } catch { return 1; }
|
||||
});
|
||||
const [expandedColumn, setExpandedColumn] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
try { sessionStorage.setItem(SESSION_STEP_KEY, String(activeStep)); } catch {}
|
||||
}, [activeStep]);
|
||||
|
||||
// Modal visibility
|
||||
const [showMaterialPicker, setShowMaterialPicker] = useState(false);
|
||||
const [showMaterialModal, setShowMaterialModal] = useState(false);
|
||||
const [showColorPicker, setShowColorPicker] = useState(false);
|
||||
const [showRoomUpload, setShowRoomUpload] = useState(false);
|
||||
const [showPlansView, setShowPlansView] = useState(readPlansOpenFlag);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (showPlansView) {
|
||||
localStorage.setItem(PLANS_OPEN_KEY, '1');
|
||||
sessionStorage.removeItem(PLANS_OPEN_KEY);
|
||||
} else {
|
||||
localStorage.removeItem(PLANS_OPEN_KEY);
|
||||
sessionStorage.removeItem(PLANS_OPEN_KEY);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [showPlansView]);
|
||||
const [showAIRender, setShowAIRender] = useState(false);
|
||||
|
||||
// Material picker state
|
||||
const [selectedMaterial, setSelectedMaterial] = useState(null);
|
||||
const [materialFilter, setMaterialFilter] = useState('');
|
||||
const [selectedCollection, setSelectedCollection] = useState('all');
|
||||
|
||||
// Color picker state
|
||||
const [colorPickerElement, setColorPickerElement] = useState(null);
|
||||
|
||||
// Room visualization
|
||||
const [roomConfig, setRoomConfig] = useState(null);
|
||||
const [roomAnalysis, setRoomAnalysis] = useState(null);
|
||||
const [roomViewActive, setRoomViewActive] = useState(false);
|
||||
|
||||
// Order form
|
||||
const [orderForm, setOrderForm] = useState({ email: '', name: '', phone: '', notes: '' });
|
||||
const [orderSent, setOrderSent] = useState(false);
|
||||
const [lastOrderId, setLastOrderId] = useState(null);
|
||||
|
||||
// Module click from 3D view
|
||||
const handleModuleClick = useCallback((moduleIndex) => {
|
||||
setExpandedColumn(prev => prev === moduleIndex ? null : moduleIndex);
|
||||
setActiveStep(2); // jump to columns step when clicking a module
|
||||
}, []);
|
||||
|
||||
// Element click from 3D view
|
||||
const openColorPicker = useCallback((elementType, label) => {
|
||||
setColorPickerElement({ type: elementType, label });
|
||||
setShowColorPicker(true);
|
||||
setActiveStep(4); // jump to materials step
|
||||
}, []);
|
||||
|
||||
const closeColorPicker = useCallback(() => {
|
||||
setShowColorPicker(false);
|
||||
setColorPickerElement(null);
|
||||
}, []);
|
||||
|
||||
// Material handlers
|
||||
const openMaterialDetail = useCallback((material) => {
|
||||
setSelectedMaterial(material);
|
||||
setShowMaterialModal(true);
|
||||
}, []);
|
||||
|
||||
const handleVariantSwitch = useCallback((variant, finishes) => {
|
||||
const variantMaterial = finishes.find(f => f.code === variant.variant_code);
|
||||
if (variantMaterial) {
|
||||
setSelectedMaterial(variantMaterial);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeStep, setActiveStep,
|
||||
expandedColumn, setExpandedColumn,
|
||||
showMaterialPicker, setShowMaterialPicker,
|
||||
showMaterialModal, setShowMaterialModal,
|
||||
showColorPicker, setShowColorPicker,
|
||||
showRoomUpload, setShowRoomUpload,
|
||||
showPlansView, setShowPlansView,
|
||||
showAIRender, setShowAIRender,
|
||||
selectedMaterial, setSelectedMaterial,
|
||||
materialFilter, setMaterialFilter,
|
||||
selectedCollection, setSelectedCollection,
|
||||
colorPickerElement, setColorPickerElement,
|
||||
roomConfig, setRoomConfig,
|
||||
roomAnalysis, setRoomAnalysis,
|
||||
roomViewActive, setRoomViewActive,
|
||||
orderForm, setOrderForm,
|
||||
orderSent, setOrderSent,
|
||||
lastOrderId, setLastOrderId,
|
||||
handleModuleClick,
|
||||
openColorPicker,
|
||||
closeColorPicker,
|
||||
openMaterialDetail,
|
||||
handleVariantSwitch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { fetchFinishes, fetchServiceOptions, calculatePrice } from '../api/client';
|
||||
import {
|
||||
COLUMN_TYPES, DEFAULT_CYCLE, DEFAULT_COLORS, DEFAULT_CONFIG, maxShelfGapMm, SHELF_GAP_MM,
|
||||
minShelvesForStructuralHeight, resolveShelfPositionsMm
|
||||
} from '../constants/wardrobe';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
const SESSION_KEY = 'apex_wardrobe_config';
|
||||
const SESSION_COLS_KEY = 'apex_wardrobe_numcols';
|
||||
const MAX_CONFIG_UNDO = 50;
|
||||
|
||||
function loadSavedConfig() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function useWardrobeConfig() {
|
||||
const saved = loadSavedConfig();
|
||||
const [config, setConfig] = useState(saved || DEFAULT_CONFIG);
|
||||
const configRef = useRef(config);
|
||||
useEffect(() => {
|
||||
configRef.current = config;
|
||||
}, [config]);
|
||||
|
||||
const undoPastRef = useRef([]);
|
||||
const undoFutureRef = useRef([]);
|
||||
const [undoRevision, setUndoRevision] = useState(0);
|
||||
|
||||
const pushUndoSnapshot = useCallback(() => {
|
||||
const snap = JSON.parse(JSON.stringify(configRef.current));
|
||||
undoPastRef.current.push(snap);
|
||||
if (undoPastRef.current.length > MAX_CONFIG_UNDO) undoPastRef.current.shift();
|
||||
undoFutureRef.current = [];
|
||||
setUndoRevision((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (undoPastRef.current.length === 0) return;
|
||||
const prev = undoPastRef.current.pop();
|
||||
undoFutureRef.current.push(JSON.parse(JSON.stringify(configRef.current)));
|
||||
setConfig(prev);
|
||||
setUndoRevision((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (undoFutureRef.current.length === 0) return;
|
||||
const next = undoFutureRef.current.pop();
|
||||
undoPastRef.current.push(JSON.parse(JSON.stringify(configRef.current)));
|
||||
setConfig(next);
|
||||
setUndoRevision((n) => n + 1);
|
||||
}, []);
|
||||
const [finishes, setFinishes] = useState([]);
|
||||
const [serviceOptions, setServiceOptions] = useState([]);
|
||||
const [priceResult, setPriceResult] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [numColumns, setNumColumns] = useState(() => {
|
||||
try { return Number(sessionStorage.getItem(SESSION_COLS_KEY)) || 3; } catch { return 3; }
|
||||
});
|
||||
|
||||
// Hardware catalogs
|
||||
const [handleVariants, setHandleVariants] = useState([]);
|
||||
const [hingeVariants, setHingeVariants] = useState([]);
|
||||
const [blumHardware, setBlumHardware] = useState([]);
|
||||
|
||||
// AI optimize state
|
||||
const [aiOptimizing, setAiOptimizing] = useState(false);
|
||||
const [aiOptimizeMsg, setAiOptimizeMsg] = useState('');
|
||||
|
||||
// Persist config to sessionStorage on every change
|
||||
useEffect(() => {
|
||||
try { sessionStorage.setItem(SESSION_KEY, JSON.stringify(config)); } catch {}
|
||||
}, [config]);
|
||||
|
||||
// Keep extended configuration schema compatible with older saved sessions.
|
||||
useEffect(() => {
|
||||
setConfig((c) => ({
|
||||
...DEFAULT_CONFIG,
|
||||
...c,
|
||||
nicheMeasurements: {
|
||||
...DEFAULT_CONFIG.nicheMeasurements,
|
||||
...(c.nicheMeasurements || {})
|
||||
},
|
||||
hardware: {
|
||||
...DEFAULT_CONFIG.hardware,
|
||||
...(c.hardware || {})
|
||||
}
|
||||
}));
|
||||
// Run once at startup for legacy session payloads.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try { sessionStorage.setItem(SESSION_COLS_KEY, String(numColumns)); } catch {}
|
||||
}, [numColumns]);
|
||||
|
||||
// Clamp per-module shelf gap (mm) when height or shelf count changes.
|
||||
// Enforce minimum shelf count when height requires a structural middle shelf.
|
||||
useEffect(() => {
|
||||
setConfig((c) => {
|
||||
const h = c.heightMm;
|
||||
const pl = c.plinthHeightMm ?? 100;
|
||||
const minS = minShelvesForStructuralHeight(h);
|
||||
const list = c.modules || [];
|
||||
let touched = false;
|
||||
const modules = list.map((mod) => {
|
||||
const nRaw = mod.shelves || 0;
|
||||
const n = Math.max(minS, nRaw);
|
||||
let nextMod = mod;
|
||||
if (n !== nRaw) {
|
||||
touched = true;
|
||||
nextMod = { ...nextMod, shelves: n };
|
||||
}
|
||||
const resolvedShelfPositions = resolveShelfPositionsMm({
|
||||
shelfCount: n,
|
||||
heightMm: h,
|
||||
plinthHeightMm: pl,
|
||||
shelfPositionsMm: nextMod.shelfPositionsMm
|
||||
});
|
||||
const nextShelfPositions = n > 0 ? resolvedShelfPositions : [];
|
||||
const prevShelfPositions = Array.isArray(nextMod.shelfPositionsMm) ? nextMod.shelfPositionsMm : [];
|
||||
const shelfPosChanged =
|
||||
nextShelfPositions.length !== prevShelfPositions.length ||
|
||||
nextShelfPositions.some((v, idx) => v !== prevShelfPositions[idx]);
|
||||
if (shelfPosChanged) {
|
||||
touched = true;
|
||||
nextMod = { ...nextMod, shelfPositionsMm: nextShelfPositions };
|
||||
}
|
||||
if (n < 2) {
|
||||
if (nextMod.shelfGapMm != null) {
|
||||
touched = true;
|
||||
return { ...nextMod, shelfGapMm: null };
|
||||
}
|
||||
return nextMod;
|
||||
}
|
||||
const capMax = maxShelfGapMm(h, pl, n);
|
||||
if (nextMod.shelfGapMm != null && capMax < SHELF_GAP_MM.min) {
|
||||
touched = true;
|
||||
return { ...nextMod, shelfGapMm: null };
|
||||
}
|
||||
if (nextMod.shelfGapMm == null || !Number.isFinite(Number(nextMod.shelfGapMm))) return nextMod;
|
||||
const minG = SHELF_GAP_MM.min;
|
||||
const raw = Math.round(Number(nextMod.shelfGapMm) / SHELF_GAP_MM.step) * SHELF_GAP_MM.step;
|
||||
const g = Math.min(capMax, Math.max(minG, raw));
|
||||
if (g !== Number(nextMod.shelfGapMm)) {
|
||||
touched = true;
|
||||
return { ...nextMod, shelfGapMm: g };
|
||||
}
|
||||
return nextMod;
|
||||
});
|
||||
if (!touched) return c;
|
||||
return { ...c, modules };
|
||||
});
|
||||
}, [config.modules, config.heightMm, config.plinthHeightMm]);
|
||||
|
||||
// Enforce UI constraints directly in state so downstream services always receive valid values.
|
||||
useEffect(() => {
|
||||
setConfig((c) => {
|
||||
let next = c;
|
||||
|
||||
const sanitizeStep = (value, min, max, step) => {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return min;
|
||||
const clamped = Math.max(min, Math.min(max, n));
|
||||
return Math.round(clamped / step) * step;
|
||||
};
|
||||
|
||||
// Sliding doors are currently disabled in UI; keep old configs stable.
|
||||
if (next.doorType === 'sliding') {
|
||||
next = { ...next, doorType: 'hinged', slidingPanelsCount: 2 };
|
||||
}
|
||||
|
||||
const modules = Array.isArray(next.modules) ? [...next.modules] : [];
|
||||
const rawDrawerType = next.hardware?.drawerType || 'visible';
|
||||
const drawerType =
|
||||
rawDrawerType === 'hidden' || rawDrawerType === 'hidden_with_handle' ? 'hidden' : 'visible';
|
||||
const handleType = next.hardware?.handleType || 'standard';
|
||||
|
||||
// Drawers constraints: from bottom, depth 400..650 by 50, and drawer columns max 1000 mm.
|
||||
const constrainedModules = modules.map((mod) => {
|
||||
const hasDrawers = (mod.drawers || 0) > 0 || mod.type === 'tiroirs';
|
||||
if (!hasDrawers) return mod;
|
||||
const nextMaxWidth = Math.min(1000, mod.maxWidth || 1200);
|
||||
// Keep same reference when nothing changes to avoid render loops/flicker.
|
||||
if (mod.maxWidth === nextMaxWidth) return mod;
|
||||
return {
|
||||
...mod,
|
||||
maxWidth: nextMaxWidth
|
||||
};
|
||||
});
|
||||
const modulesChanged = constrainedModules.some((m, i) => m !== modules[i]);
|
||||
|
||||
const sanitizedHandleBore = sanitizeStep(next.hardware?.handleBoreHeightMm, 600, 1600, 10);
|
||||
const sanitizedDrawerHeights = (next.hardware?.drawerHeightsMm || [90, 128, 177, 240]).slice(0, 4);
|
||||
const allowedHeights = [60, 90, 128, 177, 240];
|
||||
const fixedDrawerHeights = sanitizedDrawerHeights.map((h) => {
|
||||
return allowedHeights.includes(Number(h)) ? Number(h) : 128;
|
||||
});
|
||||
|
||||
const drawerDepthStepMm = sanitizeStep(next.depthMm - 10, 400, 650, 50);
|
||||
// Hidden drawers: depth reserve until poignée is modelled separately.
|
||||
const depthReductionMm = drawerType === 'hidden' ? 40 : 0;
|
||||
const effectiveDrawerDepthMm = Math.max(350, drawerDepthStepMm - depthReductionMm);
|
||||
|
||||
const niche = next.nicheMeasurements || {};
|
||||
const minNicheWidth = Math.min(niche.widthTopMm || next.widthMm, niche.widthMidMm || next.widthMm, niche.widthBottomMm || next.widthMm);
|
||||
const minNicheDepth = Math.min(niche.depthTopMm || next.depthMm, niche.depthMidMm || next.depthMm, niche.depthBottomMm || next.depthMm);
|
||||
const minNicheHeight = Math.min(niche.heightLeftMm || next.heightMm, niche.heightRightMm || next.heightMm);
|
||||
|
||||
let widthMm = next.widthMm;
|
||||
let depthMm = next.depthMm;
|
||||
let heightMm = next.heightMm;
|
||||
if (next.installationType === 'niche') {
|
||||
const gap = Number(next.sanitaryGapMm || 30);
|
||||
widthMm = Math.max(400, minNicheWidth - gap * 2);
|
||||
depthMm = Math.max(400, minNicheDepth - gap * 2);
|
||||
heightMm = Math.max(1800, minNicheHeight - gap * 2);
|
||||
}
|
||||
|
||||
const hardware = {
|
||||
...next.hardware,
|
||||
handleType,
|
||||
drawerType,
|
||||
handleBoreHeightMm: sanitizedHandleBore,
|
||||
drawerHeightsMm: fixedDrawerHeights
|
||||
};
|
||||
|
||||
const changed =
|
||||
modulesChanged ||
|
||||
widthMm !== next.widthMm ||
|
||||
depthMm !== next.depthMm ||
|
||||
heightMm !== next.heightMm ||
|
||||
sanitizedHandleBore !== (next.hardware?.handleBoreHeightMm || 1000) ||
|
||||
effectiveDrawerDepthMm !== next.effectiveDrawerDepthMm ||
|
||||
handleType !== (next.hardware?.handleType || 'standard') ||
|
||||
(next.hardware?.drawerType != null && drawerType !== next.hardware.drawerType) ||
|
||||
next.hardware?.drawerType === 'hidden_with_handle';
|
||||
|
||||
if (!changed) return c;
|
||||
|
||||
return {
|
||||
...next,
|
||||
modules: constrainedModules,
|
||||
widthMm,
|
||||
depthMm,
|
||||
heightMm,
|
||||
hardware,
|
||||
effectiveDrawerDepthMm
|
||||
};
|
||||
});
|
||||
}, [config.installationType, config.nicheMeasurements, config.sanitaryGapMm, config.doorType, config.depthMm, config.heightMm, config.hardware]);
|
||||
|
||||
// Fetch initial data
|
||||
useEffect(() => {
|
||||
Promise.all([fetchFinishes(), fetchServiceOptions()])
|
||||
.then(([fin, opt]) => {
|
||||
setFinishes(fin);
|
||||
setServiceOptions(opt);
|
||||
// Only apply default finish if none was saved
|
||||
if (fin.length) {
|
||||
setConfig((c) => {
|
||||
if (c.finishId) return c; // already has a saved finish
|
||||
const firstMaterial = fin[0];
|
||||
const defaultDim = firstMaterial.dimensions?.find(d => d.is_default) || firstMaterial.dimensions?.[0];
|
||||
return { ...c, finishId: firstMaterial.id, dimensionId: defaultDim?.id || null };
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
fetch(`${API_BASE}/api/handles`).then(r => r.json()).then(setHandleVariants).catch(console.error);
|
||||
fetch(`${API_BASE}/api/hinges`).then(r => r.json()).then(setHingeVariants).catch(console.error);
|
||||
fetch(`${API_BASE}/api/hardware`).then(r => r.json()).then(setBlumHardware).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Price calculation
|
||||
const updatePrice = useCallback(() => {
|
||||
const totalW = config.modules.reduce((s, m) => s + (m.widthMm || 0), 0) || config.widthMm;
|
||||
const payload = {
|
||||
...config,
|
||||
widthMm: totalW,
|
||||
blocks: config.modules.map((m) => ({ blockTypeId: 1, widthMm: m.widthMm }))
|
||||
};
|
||||
calculatePrice(payload)
|
||||
.then(setPriceResult)
|
||||
.catch((err) => setPriceResult({ totalChf: 0, errors: [{ message: err.message }] }));
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) updatePrice();
|
||||
}, [config, loading, updatePrice]);
|
||||
|
||||
// Simple setter helper
|
||||
const set = useCallback((key, value) => setConfig((c) => ({ ...c, [key]: value })), []);
|
||||
|
||||
const setDimension = useCallback((key, valueMm) => {
|
||||
const ranges = {
|
||||
widthMm: { min: 400, max: 5000, step: 50 },
|
||||
heightMm: { min: 1800, max: 2800, step: 50 },
|
||||
depthMm: { min: 400, max: 800, step: 10 },
|
||||
};
|
||||
const rules = ranges[key];
|
||||
if (!rules) return;
|
||||
|
||||
const nextRaw = Number(valueMm);
|
||||
if (!Number.isFinite(nextRaw)) return;
|
||||
const nextStepped = Math.round(nextRaw / rules.step) * rules.step;
|
||||
const next = Math.min(rules.max, Math.max(rules.min, nextStepped));
|
||||
|
||||
setConfig((c) => {
|
||||
const patch = { [key]: next };
|
||||
|
||||
if (c.installationType === 'niche') {
|
||||
const gap = Number(c.sanitaryGapMm || 30);
|
||||
const nicheOuter = next + gap * 2;
|
||||
const niche = { ...(c.nicheMeasurements || {}) };
|
||||
|
||||
if (key === 'widthMm') {
|
||||
niche.widthTopMm = nicheOuter;
|
||||
niche.widthMidMm = nicheOuter;
|
||||
niche.widthBottomMm = nicheOuter;
|
||||
} else if (key === 'depthMm') {
|
||||
niche.depthTopMm = nicheOuter;
|
||||
niche.depthMidMm = nicheOuter;
|
||||
niche.depthBottomMm = nicheOuter;
|
||||
} else if (key === 'heightMm') {
|
||||
niche.heightLeftMm = nicheOuter;
|
||||
niche.heightRightMm = nicheOuter;
|
||||
}
|
||||
patch.nicheMeasurements = niche;
|
||||
}
|
||||
|
||||
if (next === c[key] && !patch.nicheMeasurements) return c;
|
||||
return { ...c, ...patch };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Column generation
|
||||
const generateColumns = useCallback((nCols, totalW, existingModules = []) => {
|
||||
const lockedWidth = existingModules
|
||||
.filter((m, i) => i < nCols && m?.locked)
|
||||
.reduce((s, m) => s + (m.widthMm || 0), 0);
|
||||
const lockedCount = existingModules
|
||||
.filter((m, i) => i < nCols && m?.locked).length;
|
||||
const unlockedCount = nCols - lockedCount;
|
||||
const remainingW = totalW - lockedWidth;
|
||||
const colWidth = unlockedCount > 0 ? Math.round(remainingW / unlockedCount) : Math.round(totalW / nCols);
|
||||
|
||||
return Array.from({ length: nCols }, (_, i) => {
|
||||
const existing = existingModules[i];
|
||||
if (existing?.locked) {
|
||||
return { ...existing, position: i };
|
||||
}
|
||||
const typeKey = existing?.type || DEFAULT_CYCLE[i % DEFAULT_CYCLE.length];
|
||||
const typeDef = COLUMN_TYPES.find(t => t.key === typeKey) || COLUMN_TYPES[0];
|
||||
return {
|
||||
id: existing?.id || `col-${Date.now()}-${i}`,
|
||||
type: typeDef.key,
|
||||
moduleCode: typeDef.key,
|
||||
widthMm: colWidth,
|
||||
minWidth: 300,
|
||||
maxWidth: 1200,
|
||||
position: i,
|
||||
locked: false,
|
||||
shelves: typeDef.shelves,
|
||||
drawers: typeDef.drawers,
|
||||
hasRod: typeDef.hasRod
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleNumColumnsChange = useCallback((newNum) => {
|
||||
setNumColumns(newNum);
|
||||
const modules = generateColumns(newNum, config.widthMm, config.modules);
|
||||
setConfig(c => ({ ...c, modules }));
|
||||
}, [config.widthMm, config.modules, generateColumns]);
|
||||
|
||||
// AI layout optimization
|
||||
const handleAIOptimize = useCallback(async () => {
|
||||
setAiOptimizing(true);
|
||||
setAiOptimizeMsg('');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/layout/optimize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
totalWidth: config.totalWidth || config.widthMm,
|
||||
height: config.height,
|
||||
depth: config.depth,
|
||||
installationType: config.installationType,
|
||||
modules: config.modules.map(m => ({
|
||||
type: m.type || m.moduleCode,
|
||||
width: m.width,
|
||||
locked: m.locked || false,
|
||||
shelves: m.shelves,
|
||||
drawers: m.drawers,
|
||||
hasRod: m.hasRod
|
||||
}))
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error('API error');
|
||||
const data = await res.json();
|
||||
if (data.modules && Array.isArray(data.modules)) {
|
||||
setConfig(c => {
|
||||
const newModules = data.modules.map((m, i) => ({
|
||||
...c.modules[i],
|
||||
...m,
|
||||
id: c.modules[i]?.id || `col-${i}`
|
||||
}));
|
||||
return { ...c, modules: newModules };
|
||||
});
|
||||
setNumColumns(data.modules.length);
|
||||
}
|
||||
if (data.reasoning) {
|
||||
setAiOptimizeMsg(data.reasoning);
|
||||
}
|
||||
} catch (err) {
|
||||
setAiOptimizeMsg('Impossible d\'optimiser. V\u00e9rifiez la configuration IA.');
|
||||
} finally {
|
||||
setAiOptimizing(false);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const handleColumnTypeChange = useCallback((index, typeKey) => {
|
||||
const typeDef = COLUMN_TYPES.find(t => t.key === typeKey) || COLUMN_TYPES[0];
|
||||
setConfig(c => {
|
||||
const modules = [...c.modules];
|
||||
const minS = minShelvesForStructuralHeight(c.heightMm);
|
||||
const shelves = Math.max(minS, typeDef.shelves);
|
||||
modules[index] = {
|
||||
...modules[index],
|
||||
type: typeDef.key,
|
||||
moduleCode: typeDef.key,
|
||||
shelves,
|
||||
drawers: typeDef.drawers,
|
||||
hasRod: typeDef.hasRod
|
||||
};
|
||||
return { ...c, modules };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleColumnLock = useCallback((index) => {
|
||||
setConfig(c => {
|
||||
const modules = [...c.modules];
|
||||
modules[index] = { ...modules[index], locked: !modules[index].locked };
|
||||
return { ...c, modules };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const redistributeWidths = useCallback(() => {
|
||||
setConfig(c => {
|
||||
const modules = [...c.modules];
|
||||
const lockedWidth = modules.filter(m => m.locked).reduce((s, m) => s + m.widthMm, 0);
|
||||
const unlockedCount = modules.filter(m => !m.locked).length;
|
||||
const remainingW = c.widthMm - lockedWidth;
|
||||
const colWidth = unlockedCount > 0 ? Math.round(remainingW / unlockedCount) : 0;
|
||||
return {
|
||||
...c,
|
||||
modules: modules.map(m => m.locked ? m : { ...m, widthMm: colWidth })
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Re-distribute when width changes
|
||||
useEffect(() => {
|
||||
if (config.modules.length > 0) {
|
||||
redistributeWidths();
|
||||
}
|
||||
}, [config.widthMm]);
|
||||
|
||||
// Initialize columns on first load
|
||||
useEffect(() => {
|
||||
if (config.modules.length === 0) {
|
||||
const modules = generateColumns(numColumns, config.widthMm, []);
|
||||
setConfig(c => ({ ...c, modules }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleColumnUpdate = useCallback((index, updates) => {
|
||||
setConfig(c => {
|
||||
const modules = [...c.modules];
|
||||
const prev = modules[index];
|
||||
const merged = { ...prev, ...updates };
|
||||
const minS = minShelvesForStructuralHeight(c.heightMm);
|
||||
const shelves = Math.max(minS, merged.shelves ?? 0);
|
||||
const drawers = merged.drawers ?? 0;
|
||||
const withShelves = {
|
||||
...merged,
|
||||
shelves,
|
||||
shelfPositionsMm: resolveShelfPositionsMm({
|
||||
shelfCount: shelves,
|
||||
heightMm: c.heightMm,
|
||||
plinthHeightMm: c.plinthHeightMm ?? 100,
|
||||
shelfPositionsMm: merged.shelfPositionsMm
|
||||
})
|
||||
};
|
||||
const needsMixte = shelves > 0 && drawers > 0 && withShelves.type !== 'mixte';
|
||||
modules[index] = needsMixte
|
||||
? { ...withShelves, type: 'mixte', moduleCode: 'mixte' }
|
||||
: withShelves;
|
||||
return { ...c, modules };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const reorderModules = useCallback((fromIndex, toIndex) => {
|
||||
if (fromIndex === toIndex) return;
|
||||
setConfig((c) => {
|
||||
const list = [...c.modules];
|
||||
const n = list.length;
|
||||
if (fromIndex < 0 || fromIndex >= n || toIndex < 0 || toIndex >= n) return c;
|
||||
const [item] = list.splice(fromIndex, 1);
|
||||
list.splice(toIndex, 0, item);
|
||||
return {
|
||||
...c,
|
||||
modules: list.map((m, idx) => ({ ...m, position: idx })),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleResizeModules = useCallback((separatorIndex, deltaXmm) => {
|
||||
setConfig((c) => {
|
||||
const modules = [...c.modules];
|
||||
const left = { ...modules[separatorIndex] };
|
||||
const right = { ...modules[separatorIndex + 1] };
|
||||
|
||||
const leftMin = left.minWidth || 300;
|
||||
const leftMax = left.maxWidth || 1200;
|
||||
const rightMin = right.minWidth || 300;
|
||||
const rightMax = right.maxWidth || 1200;
|
||||
|
||||
let newLeftW = left.widthMm + deltaXmm;
|
||||
let newRightW = right.widthMm - deltaXmm;
|
||||
|
||||
if (newLeftW < leftMin) { newRightW += (newLeftW - leftMin); newLeftW = leftMin; }
|
||||
if (newLeftW > leftMax) { newRightW += (newLeftW - leftMax); newLeftW = leftMax; }
|
||||
if (newRightW < rightMin) { newLeftW += (newRightW - rightMin); newRightW = rightMin; }
|
||||
if (newRightW > rightMax) { newLeftW += (newRightW - rightMax); newRightW = rightMax; }
|
||||
|
||||
left.widthMm = Math.round(newLeftW);
|
||||
right.widthMm = Math.round(newRightW);
|
||||
modules[separatorIndex] = left;
|
||||
modules[separatorIndex + 1] = right;
|
||||
return { ...c, modules };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Color management
|
||||
const handleElementClick = useCallback((elementType, label) => {
|
||||
return { type: elementType, label };
|
||||
}, []);
|
||||
|
||||
const handleColorSelect = useCallback((elementType, finish) => {
|
||||
setConfig((c) => ({
|
||||
...c,
|
||||
colors: {
|
||||
...c.colors,
|
||||
[elementType]: {
|
||||
finishId: finish.id,
|
||||
label: finish.label || finish.name,
|
||||
hexColor: finish.hexColor || '#c4a574',
|
||||
imageUrl: finish.image_url || null
|
||||
}
|
||||
}
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleColorApplyToAll = useCallback((finish) => {
|
||||
const colorEntry = {
|
||||
finishId: finish.id,
|
||||
label: finish.label || finish.name,
|
||||
hexColor: finish.hexColor || '#c4a574',
|
||||
imageUrl: finish.image_url || null
|
||||
};
|
||||
setConfig((c) => {
|
||||
const newColors = {};
|
||||
Object.keys(DEFAULT_COLORS).forEach(key => {
|
||||
newColors[key] = { ...colorEntry };
|
||||
});
|
||||
Object.keys(c.colors).forEach(key => {
|
||||
if (!newColors[key]) newColors[key] = { ...colorEntry };
|
||||
});
|
||||
return { ...c, colors: newColors };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Hardware
|
||||
const setHardware = useCallback((key, value) => {
|
||||
setConfig((c) => ({
|
||||
...c,
|
||||
hardware: { ...c.hardware, [key]: value }
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Material management
|
||||
const handleMaterialConfirm = useCallback((materialId, dimensionId) => {
|
||||
setConfig((c) => {
|
||||
const material = finishes.find(f => f.id === materialId);
|
||||
const updated = { ...c, finishId: materialId, dimensionId };
|
||||
if (material) {
|
||||
const colorEntry = {
|
||||
finishId: material.id,
|
||||
label: material.label || material.name,
|
||||
hexColor: material.hexColor || '#c4a574',
|
||||
imageUrl: material.image_url || null
|
||||
};
|
||||
const newColors = {};
|
||||
Object.keys(DEFAULT_COLORS).forEach(key => {
|
||||
newColors[key] = { ...colorEntry };
|
||||
});
|
||||
Object.keys(c.colors).forEach(key => {
|
||||
if (!newColors[key]) newColors[key] = { ...colorEntry };
|
||||
});
|
||||
updated.colors = newColors;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}, [finishes]);
|
||||
|
||||
// Derived values
|
||||
const totalModuleWidth = useMemo(() =>
|
||||
config.modules.reduce((sum, m) => sum + (m.widthMm || 0), 0),
|
||||
[config.modules]
|
||||
);
|
||||
|
||||
const selectedFinish = useMemo(() =>
|
||||
finishes.find(f => f.id === config.finishId),
|
||||
[finishes, config.finishId]
|
||||
);
|
||||
|
||||
const materialsByCollection = useMemo(() =>
|
||||
finishes.reduce((acc, mat) => {
|
||||
const coll = mat.collection || 'Autres';
|
||||
if (!acc[coll]) acc[coll] = [];
|
||||
acc[coll].push(mat);
|
||||
return acc;
|
||||
}, {}),
|
||||
[finishes]
|
||||
);
|
||||
|
||||
return {
|
||||
config, setConfig, set, setDimension,
|
||||
finishes, serviceOptions,
|
||||
priceResult,
|
||||
loading,
|
||||
numColumns, setNumColumns,
|
||||
handleVariants, hingeVariants, blumHardware,
|
||||
aiOptimizing, aiOptimizeMsg, setAiOptimizeMsg,
|
||||
generateColumns,
|
||||
handleNumColumnsChange,
|
||||
handleAIOptimize,
|
||||
handleColumnTypeChange,
|
||||
handleColumnLock,
|
||||
redistributeWidths,
|
||||
handleColumnUpdate,
|
||||
reorderModules,
|
||||
handleResizeModules,
|
||||
handleElementClick,
|
||||
handleColorSelect,
|
||||
handleColorApplyToAll,
|
||||
setHardware,
|
||||
handleMaterialConfirm,
|
||||
totalModuleWidth,
|
||||
selectedFinish,
|
||||
materialsByCollection,
|
||||
canUndo: undoPastRef.current.length > 0,
|
||||
canRedo: undoFutureRef.current.length > 0,
|
||||
pushUndoSnapshot,
|
||||
undo,
|
||||
redo,
|
||||
};
|
||||
}
|
||||
+707
-21
@@ -1,7 +1,21 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { fetchOrders, fetchSyncLogs, getFormexSyncStreamUrl } from '../api/client';
|
||||
import {
|
||||
fetchOrders,
|
||||
fetchSyncLogs,
|
||||
getFormexSyncStreamUrl,
|
||||
getHaefeleSyncStreamUrl,
|
||||
fetchHaefeleMaterials,
|
||||
fetchOrderSupplierVouchers,
|
||||
fetchOrderPlanExports,
|
||||
generateOrderPlanExports,
|
||||
fetchOrderProductionDetails,
|
||||
getOrderPlanExportDownloadUrl,
|
||||
getOrderPlanExportViewUrl,
|
||||
deleteOrderPlanExport
|
||||
} from '../api/client';
|
||||
import Icon from '../components/ui/Icon';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
export default function Admin() {
|
||||
@@ -10,6 +24,19 @@ export default function Admin() {
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [expandedOrderId, setExpandedOrderId] = useState(null);
|
||||
const [supplierVouchersByOrder, setSupplierVouchersByOrder] = useState({});
|
||||
const [voucherLoadingByOrder, setVoucherLoadingByOrder] = useState({});
|
||||
const [voucherErrorByOrder, setVoucherErrorByOrder] = useState({});
|
||||
const [planExportsByOrder, setPlanExportsByOrder] = useState({});
|
||||
const [planExportsLoadingByOrder, setPlanExportsLoadingByOrder] = useState({});
|
||||
const [planExportsErrorByOrder, setPlanExportsErrorByOrder] = useState({});
|
||||
const [planExportsGeneratingByOrder, setPlanExportsGeneratingByOrder] = useState({});
|
||||
const [planExportDeletingById, setPlanExportDeletingById] = useState({});
|
||||
const [productionDetailsByOrder, setProductionDetailsByOrder] = useState({});
|
||||
const [productionLoadingByOrder, setProductionLoadingByOrder] = useState({});
|
||||
const [productionErrorByOrder, setProductionErrorByOrder] = useState({});
|
||||
const [productionVisibleByOrder, setProductionVisibleByOrder] = useState({});
|
||||
|
||||
// Formex sync state
|
||||
const [syncLogs, setSyncLogs] = useState([]);
|
||||
@@ -19,18 +46,32 @@ export default function Admin() {
|
||||
// SSE live progress state
|
||||
const [liveMessages, setLiveMessages] = useState([]);
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0, collection: '' });
|
||||
const [forceOverride, setForceOverride] = useState(false);
|
||||
const logsEndRef = useRef(null);
|
||||
|
||||
// Häfele sync state
|
||||
const [haefeleUrlsInput, setHaefeleUrlsInput] = useState('');
|
||||
const [haefeleSyncing, setHaefeleSyncing] = useState(false);
|
||||
const [haefeleSyncResult, setHaefeleSyncResult] = useState(null);
|
||||
const [haefeleLiveMessages, setHaefeleLiveMessages] = useState([]);
|
||||
const [haefeleProgress, setHaefeleProgress] = useState({ current: 0, total: 0, collection: '' });
|
||||
const [haefeleSearch, setHaefeleSearch] = useState('');
|
||||
const [haefeleMaterials, setHaefeleMaterials] = useState([]);
|
||||
const [haefeleLoading, setHaefeleLoading] = useState(false);
|
||||
const [haefeleError, setHaefeleError] = useState('');
|
||||
const haefeleLogsEndRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
navigate('/login?next=/admin', { replace: true });
|
||||
return;
|
||||
}
|
||||
// Fetch orders and sync logs in parallel
|
||||
Promise.all([fetchOrders(), fetchSyncLogs(10)])
|
||||
.then(([ordersData, logsData]) => {
|
||||
Promise.all([fetchOrders(), fetchSyncLogs(10), fetchHaefeleMaterials({ limit: 50 })])
|
||||
.then(([ordersData, logsData, haefeleData]) => {
|
||||
setOrders(ordersData);
|
||||
setSyncLogs(logsData);
|
||||
setHaefeleMaterials(haefeleData?.materials || []);
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -43,6 +84,17 @@ export default function Admin() {
|
||||
}
|
||||
}, [liveMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (haefeleLogsEndRef.current) {
|
||||
haefeleLogsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [haefeleLiveMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = window.localStorage.getItem('admin.haefele.urls');
|
||||
if (saved) setHaefeleUrlsInput(saved);
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/', { replace: true });
|
||||
@@ -54,14 +106,23 @@ export default function Admin() {
|
||||
setLiveMessages([]);
|
||||
setProgress({ current: 0, total: 0, collection: '' });
|
||||
|
||||
const eventSource = new EventSource(getFormexSyncStreamUrl());
|
||||
const eventSource = new EventSource(getFormexSyncStreamUrl(forceOverride));
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'log') {
|
||||
setLiveMessages(prev => [...prev, data.message]);
|
||||
const msg = data.message;
|
||||
const lower = msg.toLowerCase();
|
||||
const kind = lower.includes('erreur') || lower.includes('error') || lower.includes('failed') || lower.includes('échou')
|
||||
? 'error'
|
||||
: lower.includes('warning') || lower.includes('non connecté') || lower.includes('pas de prix')
|
||||
? 'warn'
|
||||
: lower.includes('succès') || lower.includes('connecté') || lower.includes('synchronis')
|
||||
? 'ok'
|
||||
: 'info';
|
||||
setLiveMessages(prev => [...prev, { msg, kind }]);
|
||||
} else if (data.type === 'progress') {
|
||||
setProgress({
|
||||
current: data.current,
|
||||
@@ -72,9 +133,9 @@ export default function Admin() {
|
||||
setSyncResult(data.result);
|
||||
setSyncing(false);
|
||||
eventSource.close();
|
||||
// Refresh sync logs
|
||||
fetchSyncLogs(10).then(setSyncLogs).catch(() => {});
|
||||
} else if (data.type === 'error') {
|
||||
setLiveMessages(prev => [...prev, { msg: `ERREUR: ${data.error}`, kind: 'error' }]);
|
||||
setSyncResult({ success: false, error: data.error });
|
||||
setSyncing(false);
|
||||
eventSource.close();
|
||||
@@ -85,17 +146,211 @@ export default function Admin() {
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
if (syncing) {
|
||||
setLiveMessages(prev => [...prev, { msg: 'Connexion SSE perdue', kind: 'error' }]);
|
||||
setSyncResult({ success: false, error: 'Connexion perdue' });
|
||||
setSyncing(false);
|
||||
}
|
||||
eventSource.close();
|
||||
};
|
||||
};
|
||||
|
||||
const handleClearLogs = () => {
|
||||
setLiveMessages([]);
|
||||
setSyncResult(null);
|
||||
setProgress({ current: 0, total: 0, collection: '' });
|
||||
};
|
||||
|
||||
const parseHaefeleUrls = () =>
|
||||
[...new Set(
|
||||
haefeleUrlsInput
|
||||
.split(/[\n,;]+/g)
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v && v.includes('haefele.ch'))
|
||||
)];
|
||||
|
||||
const persistHaefeleUrls = (value) => {
|
||||
setHaefeleUrlsInput(value);
|
||||
window.localStorage.setItem('admin.haefele.urls', value);
|
||||
};
|
||||
|
||||
const refreshHaefeleMaterials = async (search = haefeleSearch) => {
|
||||
setHaefeleLoading(true);
|
||||
setHaefeleError('');
|
||||
try {
|
||||
const data = await fetchHaefeleMaterials({ search, limit: 100 });
|
||||
setHaefeleMaterials(data?.materials || []);
|
||||
} catch (err) {
|
||||
setHaefeleError(err.message || 'Erreur de chargement Häfele');
|
||||
} finally {
|
||||
setHaefeleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHaefeleSync = () => {
|
||||
const urls = parseHaefeleUrls();
|
||||
if (urls.length === 0) {
|
||||
setHaefeleSyncResult({ success: false, error: 'Ajoutez au moins une URL Häfele valide.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setHaefeleSyncing(true);
|
||||
setHaefeleSyncResult(null);
|
||||
setHaefeleLiveMessages([]);
|
||||
setHaefeleProgress({ current: 0, total: urls.length, collection: 'Hafele' });
|
||||
persistHaefeleUrls(urls.join('\n'));
|
||||
|
||||
const eventSource = new EventSource(getHaefeleSyncStreamUrl(urls));
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'log') {
|
||||
const msg = data.message;
|
||||
const lower = msg.toLowerCase();
|
||||
const kind = lower.includes('erreur') || lower.includes('error') || lower.includes('failed') || lower.includes('✗')
|
||||
? 'error'
|
||||
: lower.includes('warning') || lower.includes('non connecté') || lower.includes('pas de prix')
|
||||
? 'warn'
|
||||
: lower.includes('✓') || lower.includes('succès') || lower.includes('connecté') || lower.includes('synchronis')
|
||||
? 'ok'
|
||||
: 'info';
|
||||
setHaefeleLiveMessages((prev) => [...prev, { msg, kind }]);
|
||||
} else if (data.type === 'progress') {
|
||||
setHaefeleProgress({
|
||||
current: data.current,
|
||||
total: data.total,
|
||||
collection: data.collection || 'Hafele'
|
||||
});
|
||||
} else if (data.type === 'complete') {
|
||||
setHaefeleSyncResult(data.result);
|
||||
setHaefeleSyncing(false);
|
||||
eventSource.close();
|
||||
fetchSyncLogs(10).then(setSyncLogs).catch(() => {});
|
||||
refreshHaefeleMaterials().catch(() => {});
|
||||
} else if (data.type === 'error') {
|
||||
setHaefeleLiveMessages((prev) => [...prev, { msg: `ERREUR: ${data.error}`, kind: 'error' }]);
|
||||
setHaefeleSyncResult({ success: false, error: data.error });
|
||||
setHaefeleSyncing(false);
|
||||
eventSource.close();
|
||||
fetchSyncLogs(10).then(setSyncLogs).catch(() => {});
|
||||
refreshHaefeleMaterials().catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Hafele SSE parse error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setHaefeleLiveMessages((prev) => [...prev, { msg: 'Connexion SSE Häfele perdue', kind: 'error' }]);
|
||||
setHaefeleSyncResult({ success: false, error: 'Connexion perdue' });
|
||||
setHaefeleSyncing(false);
|
||||
eventSource.close();
|
||||
fetchSyncLogs(10).then(setSyncLogs).catch(() => {});
|
||||
refreshHaefeleMaterials().catch(() => {});
|
||||
};
|
||||
};
|
||||
|
||||
const handleClearHaefeleLogs = () => {
|
||||
setHaefeleLiveMessages([]);
|
||||
setHaefeleSyncResult(null);
|
||||
setHaefeleProgress({ current: 0, total: 0, collection: '' });
|
||||
};
|
||||
|
||||
const progressPercent = progress.total > 0
|
||||
? Math.round((progress.current / progress.total) * 100)
|
||||
: 0;
|
||||
const haefeleProgressPercent = haefeleProgress.total > 0
|
||||
? Math.round((haefeleProgress.current / haefeleProgress.total) * 100)
|
||||
: 0;
|
||||
|
||||
const handleToggleSupplierDetails = async (orderId) => {
|
||||
if (expandedOrderId === orderId) {
|
||||
setExpandedOrderId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedOrderId(orderId);
|
||||
|
||||
const shouldLoadVouchers = !supplierVouchersByOrder[orderId] && !voucherLoadingByOrder[orderId];
|
||||
const shouldLoadPlanExports = !planExportsByOrder[orderId] && !planExportsLoadingByOrder[orderId];
|
||||
if (!shouldLoadVouchers && !shouldLoadPlanExports) return;
|
||||
|
||||
if (shouldLoadVouchers) {
|
||||
setVoucherErrorByOrder(prev => ({ ...prev, [orderId]: '' }));
|
||||
setVoucherLoadingByOrder(prev => ({ ...prev, [orderId]: true }));
|
||||
}
|
||||
if (shouldLoadPlanExports) {
|
||||
setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: '' }));
|
||||
setPlanExportsLoadingByOrder(prev => ({ ...prev, [orderId]: true }));
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
shouldLoadVouchers
|
||||
? fetchOrderSupplierVouchers(orderId)
|
||||
.then((data) => setSupplierVouchersByOrder(prev => ({ ...prev, [orderId]: data })))
|
||||
.catch((err) => setVoucherErrorByOrder(prev => ({ ...prev, [orderId]: err.message || 'Erreur de chargement' })))
|
||||
.finally(() => setVoucherLoadingByOrder(prev => ({ ...prev, [orderId]: false })))
|
||||
: Promise.resolve(),
|
||||
shouldLoadPlanExports
|
||||
? fetchOrderPlanExports(orderId)
|
||||
.then((data) => setPlanExportsByOrder(prev => ({ ...prev, [orderId]: data })))
|
||||
.catch((err) => setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: err.message || 'Erreur de chargement' })))
|
||||
.finally(() => setPlanExportsLoadingByOrder(prev => ({ ...prev, [orderId]: false })))
|
||||
: Promise.resolve()
|
||||
]);
|
||||
};
|
||||
|
||||
const handleGeneratePlanExports = async (orderId) => {
|
||||
setPlanExportsGeneratingByOrder(prev => ({ ...prev, [orderId]: true }));
|
||||
setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: '' }));
|
||||
try {
|
||||
await generateOrderPlanExports(orderId);
|
||||
const data = await fetchOrderPlanExports(orderId);
|
||||
setPlanExportsByOrder(prev => ({ ...prev, [orderId]: data }));
|
||||
} catch (err) {
|
||||
setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: err.message || 'Erreur de génération' }));
|
||||
} finally {
|
||||
setPlanExportsGeneratingByOrder(prev => ({ ...prev, [orderId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleProductionDetails = async (orderId) => {
|
||||
const isVisible = !!productionVisibleByOrder[orderId];
|
||||
if (isVisible) {
|
||||
setProductionVisibleByOrder(prev => ({ ...prev, [orderId]: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
setProductionVisibleByOrder(prev => ({ ...prev, [orderId]: true }));
|
||||
if (productionDetailsByOrder[orderId] || productionLoadingByOrder[orderId]) return;
|
||||
|
||||
setProductionErrorByOrder(prev => ({ ...prev, [orderId]: '' }));
|
||||
setProductionLoadingByOrder(prev => ({ ...prev, [orderId]: true }));
|
||||
try {
|
||||
const data = await fetchOrderProductionDetails(orderId);
|
||||
setProductionDetailsByOrder(prev => ({ ...prev, [orderId]: data }));
|
||||
} catch (err) {
|
||||
setProductionErrorByOrder(prev => ({ ...prev, [orderId]: err.message || 'Erreur de chargement du dossier' }));
|
||||
} finally {
|
||||
setProductionLoadingByOrder(prev => ({ ...prev, [orderId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePlanExport = async (orderId, exportId) => {
|
||||
const confirmed = window.confirm('Supprimer cet export ?');
|
||||
if (!confirmed) return;
|
||||
|
||||
setPlanExportDeletingById(prev => ({ ...prev, [exportId]: true }));
|
||||
setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: '' }));
|
||||
try {
|
||||
await deleteOrderPlanExport(orderId, exportId);
|
||||
const data = await fetchOrderPlanExports(orderId);
|
||||
setPlanExportsByOrder(prev => ({ ...prev, [orderId]: data }));
|
||||
} catch (err) {
|
||||
setPlanExportsErrorByOrder(prev => ({ ...prev, [orderId]: err.message || 'Erreur de suppression' }));
|
||||
} finally {
|
||||
setPlanExportDeletingById(prev => ({ ...prev, [exportId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
@@ -104,6 +359,7 @@ export default function Admin() {
|
||||
<div className={styles.header}>
|
||||
<h1>Administration</h1>
|
||||
<button type="button" onClick={handleLogout} className={styles.logout}>
|
||||
<Icon name="logout" size={15} />
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
@@ -121,8 +377,18 @@ export default function Admin() {
|
||||
disabled={syncing}
|
||||
className={styles.syncBtn}
|
||||
>
|
||||
<Icon name="sync" size={15} />
|
||||
{syncing ? 'Synchronisation en cours…' : 'Synchroniser Formex'}
|
||||
</button>
|
||||
<label className={styles.forceLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceOverride}
|
||||
onChange={e => setForceOverride(e.target.checked)}
|
||||
disabled={syncing}
|
||||
/>
|
||||
Forcer la resynchronisation complète
|
||||
</label>
|
||||
{syncResult && !syncing && (
|
||||
<span className={syncResult.success ? styles.success : styles.error}>
|
||||
{syncResult.success
|
||||
@@ -132,28 +398,36 @@ export default function Admin() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar and live logs during sync */}
|
||||
{syncing && (
|
||||
{/* Progress bar and live logs — stays visible after sync ends */}
|
||||
{(syncing || liveMessages.length > 0) && (
|
||||
<div className={styles.liveSync}>
|
||||
<div className={styles.progressWrap}>
|
||||
<div className={styles.progressInfo}>
|
||||
<span>{progress.collection || 'Initialisation…'}</span>
|
||||
<span>{progressPercent}%</span>
|
||||
<span>{progress.collection || (syncing ? 'Initialisation…' : 'Terminé')}</span>
|
||||
<span>{syncing ? `${progressPercent}%` : (syncResult?.success ? '✓ Succès' : '✗ Échec')}</span>
|
||||
</div>
|
||||
<div className={styles.progressBar}>
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
className={`${styles.progressFill} ${!syncing && syncResult ? (syncResult.success ? styles.progressDone : styles.progressError) : ''}`}
|
||||
style={{ width: `${syncing ? progressPercent : 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.progressStats}>
|
||||
{progress.current} / {progress.total} collections
|
||||
{progress.total > 0 && `${progress.current} / ${progress.total} collections`}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.liveLogsPanel}>
|
||||
<div className={styles.liveLogsHeader}>
|
||||
<span>Journal ({liveMessages.length} lignes)</span>
|
||||
{!syncing && (
|
||||
<button type="button" onClick={handleClearLogs} className={styles.clearBtn}>
|
||||
Effacer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.liveLogsContent}>
|
||||
{liveMessages.map((msg, idx) => (
|
||||
<div key={idx} className={styles.logLine}>{msg}</div>
|
||||
{liveMessages.map(({ msg, kind }, idx) => (
|
||||
<div key={idx} className={`${styles.logLine} ${styles[`logLine_${kind}`]}`}>{msg}</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
@@ -161,7 +435,7 @@ export default function Admin() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{syncLogs.length > 0 && !syncing && (
|
||||
{syncLogs.length > 0 && !syncing && liveMessages.length === 0 && (
|
||||
<div className={styles.logsWrap}>
|
||||
<h3>Historique des synchronisations</h3>
|
||||
<table className={styles.logsTable}>
|
||||
@@ -193,6 +467,146 @@ export default function Admin() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<section className={styles.section}>
|
||||
<h2>Synchronisation Häfele (URLs ciblées)</h2>
|
||||
<p className={styles.muted}>
|
||||
Collez une URL par ligne (ou séparées par virgule). Seules les URL Häfele seront traitées.
|
||||
</p>
|
||||
<div className={styles.haefeleInputWrap}>
|
||||
<textarea
|
||||
className={styles.urlTextarea}
|
||||
value={haefeleUrlsInput}
|
||||
onChange={(e) => persistHaefeleUrls(e.target.value)}
|
||||
placeholder={`https://www.haefele.ch/fr/produkt/.../P-01319387/\nhttps://www.haefele.ch/fr/produkt/.../P-xxxxxxx/`}
|
||||
rows={6}
|
||||
disabled={haefeleSyncing}
|
||||
/>
|
||||
<div className={styles.syncActions}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHaefeleSync}
|
||||
disabled={haefeleSyncing}
|
||||
className={styles.syncBtn}
|
||||
>
|
||||
<Icon name="sync" size={15} />
|
||||
{haefeleSyncing ? 'Synchronisation Häfele en cours…' : 'Synchroniser URLs Häfele'}
|
||||
</button>
|
||||
{haefeleSyncResult && !haefeleSyncing && (
|
||||
<span className={haefeleSyncResult.success ? styles.success : styles.error}>
|
||||
{haefeleSyncResult.success
|
||||
? `${haefeleSyncResult.itemsCount} produits synchronisés (${haefeleSyncResult.duration}s)`
|
||||
: `Erreur: ${haefeleSyncResult.error}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(haefeleSyncing || haefeleLiveMessages.length > 0) && (
|
||||
<div className={styles.liveSync}>
|
||||
<div className={styles.progressWrap}>
|
||||
<div className={styles.progressInfo}>
|
||||
<span>{haefeleProgress.collection || (haefeleSyncing ? 'Initialisation…' : 'Terminé')}</span>
|
||||
<span>
|
||||
{haefeleSyncing
|
||||
? `${haefeleProgressPercent}%`
|
||||
: (haefeleSyncResult?.success ? '✓ Succès' : '✗ Échec')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.progressBar}>
|
||||
<div
|
||||
className={`${styles.progressFill} ${!haefeleSyncing && haefeleSyncResult ? (haefeleSyncResult.success ? styles.progressDone : styles.progressError) : ''}`}
|
||||
style={{ width: `${haefeleSyncing ? haefeleProgressPercent : 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.progressStats}>
|
||||
{haefeleProgress.total > 0 && `${haefeleProgress.current} / ${haefeleProgress.total} URLs`}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.liveLogsPanel}>
|
||||
<div className={styles.liveLogsHeader}>
|
||||
<span>Journal Häfele ({haefeleLiveMessages.length} lignes)</span>
|
||||
{!haefeleSyncing && (
|
||||
<button type="button" onClick={handleClearHaefeleLogs} className={styles.clearBtn}>
|
||||
Effacer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.liveLogsContent}>
|
||||
{haefeleLiveMessages.map(({ msg, kind }, idx) => (
|
||||
<div key={idx} className={`${styles.logLine} ${styles[`logLine_${kind}`]}`}>{msg}</div>
|
||||
))}
|
||||
<div ref={haefeleLogsEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.haefeleListHeader}>
|
||||
<h3>Articles Häfele récupérés</h3>
|
||||
<div className={styles.haefeleListActions}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.searchInput}
|
||||
value={haefeleSearch}
|
||||
onChange={(e) => setHaefeleSearch(e.target.value)}
|
||||
placeholder="Rechercher (nom, code, URL)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.detailsBtn}
|
||||
onClick={() => refreshHaefeleMaterials()}
|
||||
disabled={haefeleLoading}
|
||||
>
|
||||
<Icon name="sync" size={14} />
|
||||
{haefeleLoading ? 'Chargement…' : 'Actualiser'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{haefeleError && <p className={styles.error}>{haefeleError}</p>}
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.logsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Nom</th>
|
||||
<th>Prix</th>
|
||||
<th>Épaisseur</th>
|
||||
<th>Dernière sync</th>
|
||||
<th>URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{haefeleMaterials.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className={styles.empty}>
|
||||
{haefeleLoading ? 'Chargement…' : 'Aucun article Häfele synchronisé.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
haefeleMaterials.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>{item.formex_code || '—'}</td>
|
||||
<td>{item.name || '—'}</td>
|
||||
<td>{item.price_chf != null ? `${Number(item.price_chf).toFixed(2)} CHF` : '—'}</td>
|
||||
<td>{item.thickness_mm != null ? `${item.thickness_mm} mm` : '—'}</td>
|
||||
<td>{item.last_synced_at ? new Date(item.last_synced_at).toLocaleString('fr-CH') : '—'}</td>
|
||||
<td>
|
||||
{item.formex_url ? (
|
||||
<a href={item.formex_url} target="_blank" rel="noreferrer" className={styles.downloadLink}>
|
||||
Voir page
|
||||
</a>
|
||||
) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Orders Section */}
|
||||
{!loading && !error && (
|
||||
<section className={styles.section}>
|
||||
@@ -208,17 +622,33 @@ export default function Admin() {
|
||||
<th>Email</th>
|
||||
<th>Téléphone</th>
|
||||
<th>Remarques</th>
|
||||
<th>Fournisseurs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className={styles.empty}>
|
||||
<td colSpan={8} className={styles.empty}>
|
||||
Aucune commande.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
orders.map((o) => (
|
||||
orders.flatMap((o) => {
|
||||
const isExpanded = expandedOrderId === o.id;
|
||||
const vouchersData = supplierVouchersByOrder[o.id];
|
||||
const supplierRows = vouchersData?.suppliers || [];
|
||||
const isLoadingVouchers = !!voucherLoadingByOrder[o.id];
|
||||
const vouchersError = voucherErrorByOrder[o.id];
|
||||
const planExports = planExportsByOrder[o.id] || [];
|
||||
const isLoadingPlanExports = !!planExportsLoadingByOrder[o.id];
|
||||
const planExportsError = planExportsErrorByOrder[o.id];
|
||||
const isGeneratingPlanExports = !!planExportsGeneratingByOrder[o.id];
|
||||
const productionDetails = productionDetailsByOrder[o.id];
|
||||
const isProductionLoading = !!productionLoadingByOrder[o.id];
|
||||
const productionError = productionErrorByOrder[o.id];
|
||||
const isProductionVisible = !!productionVisibleByOrder[o.id];
|
||||
|
||||
const summaryRow = (
|
||||
<tr key={o.id}>
|
||||
<td>{o.id}</td>
|
||||
<td>{o.created_at ? new Date(o.created_at).toLocaleString('fr-CH') : '—'}</td>
|
||||
@@ -227,8 +657,264 @@ export default function Admin() {
|
||||
<td>{o.email || '—'}</td>
|
||||
<td>{o.phone || '—'}</td>
|
||||
<td>{o.notes || '—'}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.detailsBtn}
|
||||
onClick={() => handleToggleSupplierDetails(o.id)}
|
||||
>
|
||||
<Icon name="eye" size={14} />
|
||||
{isExpanded ? 'Masquer' : 'Voir éléments'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
);
|
||||
|
||||
if (!isExpanded) return [summaryRow];
|
||||
|
||||
return [
|
||||
summaryRow,
|
||||
<tr key={`details-${o.id}`} className={styles.detailsRow}>
|
||||
<td colSpan={8}>
|
||||
<div className={styles.supplierDetails}>
|
||||
<div className={styles.detailsSection}>
|
||||
<h3 className={styles.detailsSectionTitle}>Exports plans / débitage</h3>
|
||||
<div className={styles.planExportActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.generateBtn}
|
||||
onClick={() => handleGeneratePlanExports(o.id)}
|
||||
disabled={isGeneratingPlanExports}
|
||||
>
|
||||
<Icon name="file" size={14} />
|
||||
{isGeneratingPlanExports ? 'Génération…' : 'Générer les exports'}
|
||||
</button>
|
||||
</div>
|
||||
{isLoadingPlanExports && (
|
||||
<p className={styles.muted}>Chargement des exports enregistrés…</p>
|
||||
)}
|
||||
{!isLoadingPlanExports && planExportsError && (
|
||||
<p className={styles.error}>{planExportsError}</p>
|
||||
)}
|
||||
{!isLoadingPlanExports && !planExportsError && planExports.length === 0 && (
|
||||
<p className={styles.muted}>Aucun export enregistré pour cette commande.</p>
|
||||
)}
|
||||
{!isLoadingPlanExports && !planExportsError && planExports.length > 0 && (
|
||||
<table className={styles.planExportsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Type</th>
|
||||
<th>Fichier</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{planExports.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>{item.created_at ? new Date(item.created_at).toLocaleString('fr-CH') : '—'}</td>
|
||||
<td>{item.export_type || '—'}</td>
|
||||
<td>{item.file_name || '—'}</td>
|
||||
<td>
|
||||
<a
|
||||
href={getOrderPlanExportViewUrl(o.id, item.id)}
|
||||
className={styles.downloadLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Icon name="eye" size={13} />
|
||||
Voir
|
||||
</a>
|
||||
{' · '}
|
||||
<a
|
||||
href={getOrderPlanExportDownloadUrl(o.id, item.id)}
|
||||
className={styles.downloadLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Icon name="download" size={13} />
|
||||
Télécharger
|
||||
</a>
|
||||
{' · '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.deleteLink}
|
||||
disabled={!!planExportDeletingById[item.id]}
|
||||
onClick={() => handleDeletePlanExport(o.id, item.id)}
|
||||
>
|
||||
<Icon name="trash" size={13} />
|
||||
{planExportDeletingById[item.id] ? 'Suppression…' : 'Supprimer'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.detailsSection}>
|
||||
<h3 className={styles.detailsSectionTitle}>Dossier technique complet</h3>
|
||||
<div className={styles.planExportActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.generateBtn}
|
||||
onClick={() => handleToggleProductionDetails(o.id)}
|
||||
>
|
||||
<Icon name="blueprint" size={14} />
|
||||
{isProductionVisible ? 'Masquer le dossier' : 'Afficher le dossier'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isProductionVisible && isProductionLoading && (
|
||||
<p className={styles.muted}>Génération du dossier technique…</p>
|
||||
)}
|
||||
{isProductionVisible && !isProductionLoading && productionError && (
|
||||
<p className={styles.error}>{productionError}</p>
|
||||
)}
|
||||
{isProductionVisible && !isProductionLoading && !productionError && productionDetails && (
|
||||
<div className={styles.productionWrap}>
|
||||
<div className={styles.productionSummary}>
|
||||
<span>{productionDetails.config.widthMm} × {productionDetails.config.heightMm} × {productionDetails.config.depthMm} mm</span>
|
||||
<span>•</span>
|
||||
<span>{productionDetails.config.installationType === 'niche' ? 'Encastrée' : 'Libre'}</span>
|
||||
<span>•</span>
|
||||
<span>{productionDetails.config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</span>
|
||||
<span>•</span>
|
||||
<span>{productionDetails.config.moduleCount} modules</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.planGrid}>
|
||||
<div className={styles.planCard}>
|
||||
<h4>Vue de face</h4>
|
||||
<div dangerouslySetInnerHTML={{ __html: productionDetails.plans?.assembly || '' }} />
|
||||
</div>
|
||||
<div className={styles.planCard}>
|
||||
<h4>Vue de côté</h4>
|
||||
<div dangerouslySetInnerHTML={{ __html: productionDetails.plans?.side || '' }} />
|
||||
</div>
|
||||
<div className={styles.planCard}>
|
||||
<h4>Vue de dessus</h4>
|
||||
<div dangerouslySetInnerHTML={{ __html: productionDetails.plans?.top || '' }} />
|
||||
</div>
|
||||
<div className={styles.planCard}>
|
||||
<h4>Détail usinage</h4>
|
||||
<div dangerouslySetInnerHTML={{ __html: productionDetails.plans?.detail || '' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.productionTables}>
|
||||
<h4>Liste de découpage</h4>
|
||||
<table className={styles.planExportsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pièce</th>
|
||||
<th>L × H × Ép.</th>
|
||||
<th>Qté</th>
|
||||
<th>Finition</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(productionDetails.cuttingList?.pieces || []).map((piece) => (
|
||||
<tr key={piece.id}>
|
||||
<td>{piece.description}</td>
|
||||
<td>{piece.widthMm} × {piece.heightMm} × {piece.thicknessMm} mm</td>
|
||||
<td>{piece.quantity}</td>
|
||||
<td>{piece.finishLabel || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className={styles.productionTables}>
|
||||
<h4>Colisage</h4>
|
||||
<table className={styles.planExportsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Colis</th>
|
||||
<th>Niveau</th>
|
||||
<th>Poids</th>
|
||||
<th>Pièces</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(productionDetails.packing?.boxes || []).map((box) => (
|
||||
<tr key={box.id}>
|
||||
<td>{box.id}</td>
|
||||
<td>{box.level}</td>
|
||||
<td>{Number(box.totalWeightKg || 0).toFixed(1)} kg</td>
|
||||
<td>{box.pieces?.length || 0}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.detailsSection}>
|
||||
<h3 className={styles.detailsSectionTitle}>Éléments envoyés fournisseurs</h3>
|
||||
{isLoadingVouchers && (
|
||||
<p className={styles.muted}>Chargement des éléments fournisseurs…</p>
|
||||
)}
|
||||
{!isLoadingVouchers && vouchersError && (
|
||||
<p className={styles.error}>{vouchersError}</p>
|
||||
)}
|
||||
{!isLoadingVouchers && !vouchersError && supplierRows.length === 0 && (
|
||||
<p className={styles.muted}>Aucun élément fournisseur résolu pour cette commande.</p>
|
||||
)}
|
||||
{!isLoadingVouchers && !vouchersError && supplierRows.length > 0 && (
|
||||
<div className={styles.supplierGrid}>
|
||||
{supplierRows.map((supplier) => (
|
||||
<div key={supplier.supplierId} className={styles.supplierCard}>
|
||||
<div className={styles.supplierHeader}>
|
||||
<strong>{supplier.supplierName}</strong>
|
||||
<span>{supplier.supplierEmail}</span>
|
||||
</div>
|
||||
<table className={styles.supplierItemsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Désignation</th>
|
||||
<th>Qté</th>
|
||||
<th>PU</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{supplier.items.map((item, idx) => (
|
||||
<tr key={`${supplier.supplierId}-${idx}`}>
|
||||
<td>{item.code || '—'}</td>
|
||||
<td>{item.name || '—'}</td>
|
||||
<td>{item.qty}</td>
|
||||
<td>{item.unitPrice != null ? `${Number(item.unitPrice).toFixed(2)} CHF` : '—'}</td>
|
||||
<td>
|
||||
{item.unitPrice != null
|
||||
? `${(Number(item.unitPrice) * Number(item.qty || 0)).toFixed(2)} CHF`
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={4}>Sous-total</td>
|
||||
<td>{Number(supplier.subtotal || 0).toFixed(2)} CHF</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
];
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -19,18 +19,24 @@
|
||||
}
|
||||
|
||||
.logout {
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.95rem;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
border-radius: 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.logout:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--primary);
|
||||
border-color: var(--action);
|
||||
color: var(--action);
|
||||
box-shadow: 0 6px 18px rgba(79, 70, 229, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.muted {
|
||||
@@ -112,19 +118,22 @@
|
||||
}
|
||||
|
||||
.syncBtn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: var(--accent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.625rem 1rem;
|
||||
background: linear-gradient(135deg, var(--action), var(--action-hover));
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 0.9375rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.syncBtn:hover:not(:disabled) {
|
||||
background: var(--primary);
|
||||
box-shadow: 0 10px 22px rgba(79, 70, 229, 0.26);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.syncBtn:disabled {
|
||||
@@ -132,6 +141,25 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.forceLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #666);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.forceLabel input[type="checkbox"] {
|
||||
accent-color: var(--accent, #e67e22);
|
||||
}
|
||||
|
||||
.forceLabel:has(input:disabled) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #16a34a;
|
||||
}
|
||||
@@ -233,8 +261,342 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.liveLogsHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.4rem 1rem;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #444;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.clearBtn {
|
||||
background: transparent;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
color: #aaa;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clearBtn:hover {
|
||||
border-color: #888;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.progressDone {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.progressError {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.haefeleInputWrap {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.urlTextarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.4;
|
||||
resize: vertical;
|
||||
margin-bottom: 0.75rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.urlTextarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--action);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.14);
|
||||
}
|
||||
|
||||
.haefeleListHeader {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.haefeleListActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
min-width: 280px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--action);
|
||||
}
|
||||
|
||||
.logLine {
|
||||
color: #d4d4d4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.logLine_error {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.logLine_warn {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.logLine_ok {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.logLine_info {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.detailsBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border: 1px solid var(--action);
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: var(--action);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.detailsBtn:hover {
|
||||
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.detailsRow td {
|
||||
background: #fcfcfd;
|
||||
}
|
||||
|
||||
.supplierDetails {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.detailsSection {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detailsSectionTitle {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.planExportActions {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.generateBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.42rem 0.72rem;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: var(--accent);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.generateBtn:hover:not(:disabled) {
|
||||
border-color: var(--action);
|
||||
color: var(--action);
|
||||
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.generateBtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.supplierGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.supplierCard {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.supplierHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.supplierHeader strong {
|
||||
color: var(--primary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.supplierHeader span {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.supplierItemsTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.supplierItemsTable th,
|
||||
.supplierItemsTable td {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.supplierItemsTable th {
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.supplierItemsTable tfoot td {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.planExportsTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.planExportsTable th,
|
||||
.planExportsTable td {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.planExportsTable th {
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.planExportsTable tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.downloadLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.downloadLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.deleteLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b91c1c;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.deleteLink:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.productionWrap {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.productionSummary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.planGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.planCard {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.planCard h4 {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.planCard svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.productionTables {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.productionTables h4 {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
+124
-1059
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { useAuth } from '../context/AuthContext';
|
||||
import {
|
||||
fetchFormexMaterials,
|
||||
fetchFormexCollections,
|
||||
fetchMaterialSuppliers,
|
||||
deleteFormexMaterial
|
||||
} from '../api/client';
|
||||
import MaterialModal from '../components/MaterialModal';
|
||||
@@ -16,6 +17,7 @@ export default function Materials() {
|
||||
// Data state
|
||||
const [materials, setMaterials] = useState([]);
|
||||
const [collections, setCollections] = useState([]);
|
||||
const [suppliers, setSuppliers] = useState([]);
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 25, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -23,6 +25,7 @@ export default function Materials() {
|
||||
// Filter state
|
||||
const [search, setSearch] = useState('');
|
||||
const [collection, setCollection] = useState('');
|
||||
const [supplier, setSupplier] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
|
||||
// Modal state
|
||||
@@ -36,7 +39,7 @@ export default function Materials() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await fetchFormexMaterials({ search, collection, page, limit: 25 });
|
||||
const result = await fetchFormexMaterials({ search, collection, supplier, page, limit: 25 });
|
||||
setMaterials(result.materials);
|
||||
setPagination(result.pagination);
|
||||
} catch (err) {
|
||||
@@ -44,18 +47,15 @@ export default function Materials() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, collection]);
|
||||
}, [search, collection, supplier]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
navigate('/login?next=/materiaux', { replace: true });
|
||||
return;
|
||||
}
|
||||
// Load collections for filter dropdown
|
||||
fetchFormexCollections()
|
||||
.then(setCollections)
|
||||
.catch(() => {});
|
||||
// Load materials
|
||||
fetchFormexCollections().then(setCollections).catch(() => {});
|
||||
fetchMaterialSuppliers().then(setSuppliers).catch(() => {});
|
||||
loadMaterials(1);
|
||||
}, [isAdmin, navigate, loadMaterials]);
|
||||
|
||||
@@ -68,10 +68,9 @@ export default function Materials() {
|
||||
setCollection(e.target.value);
|
||||
};
|
||||
|
||||
// Reload when filters change
|
||||
useEffect(() => {
|
||||
loadMaterials(1);
|
||||
}, [search, collection, loadMaterials]);
|
||||
}, [search, collection, supplier, loadMaterials]);
|
||||
|
||||
const handlePageChange = (newPage) => {
|
||||
loadMaterials(newPage);
|
||||
@@ -123,13 +122,12 @@ export default function Materials() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.header}>
|
||||
<h1>Matériaux Formex</h1>
|
||||
<h1>Matériaux</h1>
|
||||
<button type="button" onClick={handleCreate} className={styles.createBtn}>
|
||||
+ Nouveau matériau
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className={styles.filters}>
|
||||
<form onSubmit={handleSearch} className={styles.searchForm}>
|
||||
<input
|
||||
@@ -141,6 +139,16 @@ export default function Materials() {
|
||||
/>
|
||||
<button type="submit" className={styles.searchBtn}>Rechercher</button>
|
||||
</form>
|
||||
<select
|
||||
value={supplier}
|
||||
onChange={(e) => setSupplier(e.target.value)}
|
||||
className={styles.collectionSelect}
|
||||
>
|
||||
<option value="">Tous les fournisseurs</option>
|
||||
{suppliers.map((s) => (
|
||||
<option key={s.code} value={s.code}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={collection}
|
||||
onChange={handleCollectionChange}
|
||||
@@ -163,30 +171,58 @@ export default function Materials() {
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fournisseur</th>
|
||||
<th>Code</th>
|
||||
<th>Nom</th>
|
||||
<th>Collection</th>
|
||||
<th>Dimensions (mm)</th>
|
||||
<th>Perçage</th>
|
||||
<th>Prix CHF</th>
|
||||
<th>Unité</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{materials.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className={styles.empty}>
|
||||
<td colSpan={9} className={styles.empty}>
|
||||
Aucun matériau trouvé.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
materials.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td><span className={styles.supplierBadge}>{m.supplier_name || '—'}</span></td>
|
||||
<td className={styles.code}>{m.formex_code}</td>
|
||||
<td>{m.name}</td>
|
||||
<td>{m.collection}</td>
|
||||
<td>
|
||||
{m.width_mm && m.height_mm && m.thickness_mm
|
||||
{m.diameter_mm
|
||||
? `Ø ${m.diameter_mm}${m.length_mm ? ` × ${m.length_mm}` : ''}`
|
||||
: m.width_mm && m.height_mm && m.thickness_mm
|
||||
? `${m.width_mm} × ${m.height_mm} × ${m.thickness_mm}`
|
||||
: '—'}
|
||||
: m.thickness_mm ? `ép. ${m.thickness_mm}` : '—'}
|
||||
</td>
|
||||
<td className={styles.boreCell}>
|
||||
{(m.bore_diameter_mm != null || m.bore_depth_base_mm != null || m.bore_depth_piece_mm != null) ? (
|
||||
<div className={styles.boreInfo}>
|
||||
{m.bore_diameter_mm != null && <span className={styles.boreBadge}>Ø {m.bore_diameter_mm}</span>}
|
||||
{m.bore_depth_base_mm != null && <span className={styles.boreDepth} title="Profondeur perçage fond">fond: {m.bore_depth_base_mm}</span>}
|
||||
{m.bore_depth_piece_mm != null && <span className={styles.boreDepth} title="Profondeur perçage pièce">pièce: {m.bore_depth_piece_mm}</span>}
|
||||
</div>
|
||||
) : <span className={styles.priceNone}>—</span>}
|
||||
</td>
|
||||
<td>
|
||||
{m.price_chf != null
|
||||
? <span className={styles.priceOk}>{Number(m.price_chf).toFixed(2)}</span>
|
||||
: <span className={styles.priceNone}>—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{m.price_unit
|
||||
? <span className={styles.priceUnit}>{m.price_unit}</span>
|
||||
: <span className={styles.priceNone}>—</span>
|
||||
}
|
||||
</td>
|
||||
<td className={styles.actions}>
|
||||
<button
|
||||
|
||||
@@ -129,6 +129,72 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.supplierBadge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.boreCell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.boreInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.boreBadge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.boreDepth {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.priceOk {
|
||||
display: inline-block;
|
||||
padding: 0.175rem 0.5rem;
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.priceNone {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.priceUnit {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import {
|
||||
fetchSettings, updateSettings,
|
||||
fetchSuppliers, createSupplier, updateSupplier, deleteSupplier,
|
||||
fetchEngineeringRules, createEngineeringRule, updateEngineeringRule, deleteEngineeringRule
|
||||
} from '../api/client';
|
||||
import styles from './Settings.module.css';
|
||||
|
||||
// Human-readable labels for settings keys
|
||||
const SETTINGS_META = [
|
||||
{ key: 'kit_preparation_fee_chf', label: 'Forfait préparation kit (CHF)', type: 'number', min: 0, step: 10, unit: 'CHF' },
|
||||
{ key: 'panel_thickness_mm', label: 'Épaisseur panneaux', type: 'number', min: 1, step: 1, unit: 'mm' },
|
||||
{ key: 'plinth_height_min_mm', label: 'Hauteur plinthe minimale', type: 'number', min: 0, step: 5, unit: 'mm' },
|
||||
{ key: 'plinth_height_max_mm', label: 'Hauteur plinthe maximale', type: 'number', min: 0, step: 5, unit: 'mm' },
|
||||
{ key: 'sliding_doors_min_width_mm', label: 'Largeur min. portes coulissantes', type: 'number', min: 0, step: 50, unit: 'mm' },
|
||||
{ key: 'allow_sliding_doors', label: 'Autoriser portes coulissantes (1/0)', type: 'number', min: 0, step: 1, unit: '' },
|
||||
{ key: 'sanitary_gap_mm', label: 'Vide sanitaire niche', type: 'number', min: 0, step: 1, unit: 'mm' },
|
||||
{ key: 'drawer_depth_min_mm', label: 'Tiroir profondeur min', type: 'number', min: 0, step: 10, unit: 'mm' },
|
||||
{ key: 'drawer_depth_max_mm', label: 'Tiroir profondeur max', type: 'number', min: 0, step: 10, unit: 'mm' },
|
||||
{ key: 'drawer_depth_step_mm', label: 'Tiroir pas profondeur', type: 'number', min: 1, step: 1, unit: 'mm' },
|
||||
{ key: 'drawer_back_gap_mm', label: 'Vide sanitaire derrière tiroir', type: 'number', min: 0, step: 1, unit: 'mm' },
|
||||
{ key: 'drawer_hidden_handle_reduction_mm', label: 'Réduction profondeur tiroir caché', type: 'number', min: 0, step: 1, unit: 'mm' },
|
||||
{ key: 'standard_handle_axis_mm', label: 'Axe perçage poignée standard', type: 'number', min: 0, step: 10, unit: 'mm' },
|
||||
{ key: 'standard_handle_tolerance_mm', label: 'Tolérance axe poignée', type: 'number', min: 0, step: 10, unit: 'mm' },
|
||||
{ key: 'groove_width_mm', label: 'Rainure largeur', type: 'number', min: 0, step: 0.5, unit: 'mm' },
|
||||
{ key: 'groove_depth_mm', label: 'Rainure profondeur', type: 'number', min: 0, step: 0.5, unit: 'mm' },
|
||||
{ key: 'groove_edge_offset_mm', label: 'Rainure distance au bord', type: 'number', min: 0, step: 0.5, unit: 'mm' },
|
||||
{ key: 'niche_clearance_multiplier', label: 'Multiplicateur jeu niche', type: 'number', min: 1, step: 0.5, unit: '' },
|
||||
{ key: 'min_clearance_mm', label: 'Jeu minimum', type: 'number', min: 0, step: 1, unit: 'mm' },
|
||||
];
|
||||
|
||||
const SUPPLIER_TYPES = [
|
||||
{ value: 'panels', label: 'Panneaux / Matériaux' },
|
||||
{ value: 'hardware', label: 'Quincaillerie' },
|
||||
{ value: 'handles', label: 'Poignées' },
|
||||
{ value: 'other', label: 'Autre' },
|
||||
];
|
||||
|
||||
const EMPTY_SUPPLIER = { code: '', name: '', email: '', type: 'panels', active: 1 };
|
||||
const EMPTY_RULE = { sort_order: 9999, chapter: '', title: '', details: '' };
|
||||
|
||||
export default function Settings() {
|
||||
const { isAdmin } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// App settings state
|
||||
const [settings, setSettings] = useState({});
|
||||
const [settingsDraft, setSettingsDraft] = useState({});
|
||||
const [settingsSaving, setSettingsSaving] = useState(false);
|
||||
const [settingsMsg, setSettingsMsg] = useState(null); // { ok: bool, text: string }
|
||||
|
||||
// Suppliers state
|
||||
const [suppliers, setSuppliers] = useState([]);
|
||||
const [editingSupplier, setEditingSupplier] = useState(null); // null | supplier object | 'new'
|
||||
const [supplierDraft, setSupplierDraft] = useState(EMPTY_SUPPLIER);
|
||||
const [supplierSaving, setSupplierSaving] = useState(false);
|
||||
const [supplierMsg, setSupplierMsg] = useState(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null); // supplier id to confirm delete
|
||||
|
||||
// Engineering rules state
|
||||
const [engineeringRules, setEngineeringRules] = useState([]);
|
||||
const [editingRule, setEditingRule] = useState(null); // null | rule object | 'new'
|
||||
const [ruleDraft, setRuleDraft] = useState(EMPTY_RULE);
|
||||
const [ruleSaving, setRuleSaving] = useState(false);
|
||||
const [ruleMsg, setRuleMsg] = useState(null);
|
||||
const [ruleDeleteConfirm, setRuleDeleteConfirm] = useState(null);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
navigate('/login?next=/parametres', { replace: true });
|
||||
return;
|
||||
}
|
||||
Promise.all([fetchSettings(), fetchSuppliers(), fetchEngineeringRules()])
|
||||
.then(([s, sup, rules]) => {
|
||||
setSettings(s);
|
||||
setSettingsDraft(s);
|
||||
setSuppliers(sup);
|
||||
setEngineeringRules(rules);
|
||||
})
|
||||
.catch(err => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [isAdmin, navigate]);
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
// ─── App Settings ──────────────────────────────────────────────────────────
|
||||
|
||||
function handleSettingChange(key, value) {
|
||||
setSettingsDraft(prev => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleSettingsSave(e) {
|
||||
e.preventDefault();
|
||||
setSettingsSaving(true);
|
||||
setSettingsMsg(null);
|
||||
try {
|
||||
const saved = await updateSettings(settingsDraft);
|
||||
setSettings(saved);
|
||||
setSettingsDraft(saved);
|
||||
setSettingsMsg({ ok: true, text: 'Paramètres sauvegardés.' });
|
||||
} catch (err) {
|
||||
setSettingsMsg({ ok: false, text: err.message });
|
||||
} finally {
|
||||
setSettingsSaving(false);
|
||||
setTimeout(() => setSettingsMsg(null), 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSettingsReset() {
|
||||
setSettingsDraft(settings);
|
||||
setSettingsMsg(null);
|
||||
}
|
||||
|
||||
// ─── Suppliers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function openNewSupplier() {
|
||||
setEditingSupplier('new');
|
||||
setSupplierDraft(EMPTY_SUPPLIER);
|
||||
setSupplierMsg(null);
|
||||
}
|
||||
|
||||
function openEditSupplier(supplier) {
|
||||
setEditingSupplier(supplier);
|
||||
setSupplierDraft({ ...supplier });
|
||||
setSupplierMsg(null);
|
||||
}
|
||||
|
||||
function cancelSupplierEdit() {
|
||||
setEditingSupplier(null);
|
||||
setSupplierDraft(EMPTY_SUPPLIER);
|
||||
setSupplierMsg(null);
|
||||
}
|
||||
|
||||
function handleSupplierDraftChange(field, value) {
|
||||
setSupplierDraft(prev => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
async function handleSupplierSave(e) {
|
||||
e.preventDefault();
|
||||
setSupplierSaving(true);
|
||||
setSupplierMsg(null);
|
||||
try {
|
||||
if (editingSupplier === 'new') {
|
||||
const created = await createSupplier(supplierDraft);
|
||||
setSuppliers(prev => [...prev, created]);
|
||||
} else {
|
||||
const updated = await updateSupplier(editingSupplier.id, supplierDraft);
|
||||
setSuppliers(prev => prev.map(s => s.id === updated.id ? updated : s));
|
||||
}
|
||||
setEditingSupplier(null);
|
||||
setSupplierDraft(EMPTY_SUPPLIER);
|
||||
setSupplierMsg({ ok: true, text: 'Fournisseur sauvegardé.' });
|
||||
setTimeout(() => setSupplierMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setSupplierMsg({ ok: false, text: err.message });
|
||||
} finally {
|
||||
setSupplierSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSupplierDelete(id) {
|
||||
try {
|
||||
await deleteSupplier(id);
|
||||
setSuppliers(prev => prev.filter(s => s.id !== id));
|
||||
setDeleteConfirm(null);
|
||||
} catch (err) {
|
||||
setSupplierMsg({ ok: false, text: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const typeLabel = v => SUPPLIER_TYPES.find(t => t.value === v)?.label ?? v;
|
||||
|
||||
// ─── Engineering Rules ─────────────────────────────────────────────────────
|
||||
|
||||
function openNewRule() {
|
||||
setEditingRule('new');
|
||||
setRuleDraft(EMPTY_RULE);
|
||||
setRuleMsg(null);
|
||||
}
|
||||
|
||||
function openEditRule(rule) {
|
||||
setEditingRule(rule);
|
||||
setRuleDraft({
|
||||
sort_order: rule.sort_order ?? 9999,
|
||||
chapter: rule.chapter ?? '',
|
||||
title: rule.title ?? '',
|
||||
details: rule.details ?? ''
|
||||
});
|
||||
setRuleMsg(null);
|
||||
}
|
||||
|
||||
function cancelRuleEdit() {
|
||||
setEditingRule(null);
|
||||
setRuleDraft(EMPTY_RULE);
|
||||
setRuleMsg(null);
|
||||
}
|
||||
|
||||
function handleRuleDraftChange(field, value) {
|
||||
setRuleDraft(prev => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
async function handleRuleSave(e) {
|
||||
e.preventDefault();
|
||||
setRuleSaving(true);
|
||||
setRuleMsg(null);
|
||||
try {
|
||||
const payload = {
|
||||
sort_order: Number(ruleDraft.sort_order),
|
||||
chapter: ruleDraft.chapter,
|
||||
title: ruleDraft.title,
|
||||
details: ruleDraft.details
|
||||
};
|
||||
|
||||
if (editingRule === 'new') {
|
||||
const created = await createEngineeringRule(payload);
|
||||
setEngineeringRules(prev =>
|
||||
[...prev, created].sort((a, b) => (a.sort_order - b.sort_order) || (a.id - b.id))
|
||||
);
|
||||
} else {
|
||||
const updated = await updateEngineeringRule(editingRule.id, payload);
|
||||
setEngineeringRules(prev =>
|
||||
prev
|
||||
.map(r => r.id === updated.id ? updated : r)
|
||||
.sort((a, b) => (a.sort_order - b.sort_order) || (a.id - b.id))
|
||||
);
|
||||
}
|
||||
setEditingRule(null);
|
||||
setRuleDraft(EMPTY_RULE);
|
||||
setRuleMsg({ ok: true, text: 'Règle sauvegardée.' });
|
||||
setTimeout(() => setRuleMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setRuleMsg({ ok: false, text: err.message });
|
||||
} finally {
|
||||
setRuleSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRuleDelete(id) {
|
||||
try {
|
||||
await deleteEngineeringRule(id);
|
||||
setEngineeringRules(prev => prev.filter(r => r.id !== id));
|
||||
setRuleDeleteConfirm(null);
|
||||
} catch (err) {
|
||||
setRuleMsg({ ok: false, text: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.header}>
|
||||
<h1>Paramètres</h1>
|
||||
</div>
|
||||
|
||||
{loading && <p className={styles.muted}>Chargement…</p>}
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{/* ── App Settings ─────────────────────────────────────── */}
|
||||
<section className={styles.section}>
|
||||
<h2>Paramètres généraux</h2>
|
||||
<form onSubmit={handleSettingsSave} className={styles.settingsForm}>
|
||||
<div className={styles.settingsGrid}>
|
||||
{SETTINGS_META.map(({ key, label, type, min, step, unit }) => (
|
||||
<div className={styles.field} key={key}>
|
||||
<label className={styles.label} htmlFor={`setting-${key}`}>
|
||||
{label}
|
||||
{unit && <span className={styles.unit}>{unit}</span>}
|
||||
</label>
|
||||
<input
|
||||
id={`setting-${key}`}
|
||||
className={styles.input}
|
||||
type={type}
|
||||
min={min}
|
||||
step={step}
|
||||
value={settingsDraft[key] ?? ''}
|
||||
onChange={e => handleSettingChange(key, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.formActions}>
|
||||
<button type="submit" className={styles.btnPrimary} disabled={settingsSaving}>
|
||||
{settingsSaving ? 'Sauvegarde…' : 'Sauvegarder'}
|
||||
</button>
|
||||
<button type="button" className={styles.btnGhost} onClick={handleSettingsReset}>
|
||||
Annuler
|
||||
</button>
|
||||
{settingsMsg && (
|
||||
<span className={settingsMsg.ok ? styles.success : styles.error}>
|
||||
{settingsMsg.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ── Engineering Rules ───────────────────────────────────── */}
|
||||
<section className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2>Règles APEX 4.0 (ingénierie, ergonomie, légalité)</h2>
|
||||
<button type="button" className={styles.btnPrimary} onClick={openNewRule}>
|
||||
+ Nouvelle règle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className={styles.muted}>
|
||||
Liste de référence triée par ordre. Chaque règle est modifiable pour ajuster les contraintes du configurateur.
|
||||
</p>
|
||||
|
||||
{ruleMsg && !editingRule && (
|
||||
<p className={ruleMsg.ok ? styles.success : styles.error}>{ruleMsg.text}</p>
|
||||
)}
|
||||
|
||||
{editingRule && (
|
||||
<form onSubmit={handleRuleSave} className={styles.supplierForm}>
|
||||
<h3>{editingRule === 'new' ? 'Nouvelle règle' : `Modifier — ${editingRule.title}`}</h3>
|
||||
<div className={styles.ruleFormGrid}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Ordre *</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="number"
|
||||
required
|
||||
value={ruleDraft.sort_order}
|
||||
onChange={e => handleRuleDraftChange('sort_order', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Chapitre *</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
required
|
||||
placeholder="ex. 5.C"
|
||||
value={ruleDraft.chapter}
|
||||
onChange={e => handleRuleDraftChange('chapter', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Titre *</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
required
|
||||
placeholder="Titre court de la règle"
|
||||
value={ruleDraft.title}
|
||||
onChange={e => handleRuleDraftChange('title', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.fieldWide}>
|
||||
<label className={styles.label}>Détails *</label>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
required
|
||||
rows={6}
|
||||
placeholder="Contenu détaillé de la règle"
|
||||
value={ruleDraft.details}
|
||||
onChange={e => handleRuleDraftChange('details', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.formActions}>
|
||||
<button type="submit" className={styles.btnPrimary} disabled={ruleSaving}>
|
||||
{ruleSaving ? 'Sauvegarde…' : 'Sauvegarder'}
|
||||
</button>
|
||||
<button type="button" className={styles.btnGhost} onClick={cancelRuleEdit}>
|
||||
Annuler
|
||||
</button>
|
||||
{ruleMsg && (
|
||||
<span className={ruleMsg.ok ? styles.success : styles.error}>
|
||||
{ruleMsg.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ordre</th>
|
||||
<th>Chapitre</th>
|
||||
<th>Titre</th>
|
||||
<th>Détails</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{engineeringRules.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className={styles.empty}>Aucune règle.</td>
|
||||
</tr>
|
||||
) : (
|
||||
engineeringRules.map(rule => (
|
||||
<tr key={rule.id} className={editingRule?.id === rule.id ? styles.rowEditing : ''}>
|
||||
<td>{rule.sort_order}</td>
|
||||
<td><code>{rule.chapter}</code></td>
|
||||
<td>{rule.title}</td>
|
||||
<td className={styles.ruleDetailsCell}>{rule.details}</td>
|
||||
<td className={styles.actions}>
|
||||
{ruleDeleteConfirm === rule.id ? (
|
||||
<>
|
||||
<span className={styles.confirmText}>Supprimer ?</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnDanger}
|
||||
onClick={() => handleRuleDelete(rule.id)}
|
||||
>Oui</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnGhost}
|
||||
onClick={() => setRuleDeleteConfirm(null)}
|
||||
>Non</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnGhost}
|
||||
onClick={() => openEditRule(rule)}
|
||||
>Modifier</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnDangerGhost}
|
||||
onClick={() => setRuleDeleteConfirm(rule.id)}
|
||||
>Supprimer</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── SMTP / Email info ─────────────────────────────────── */}
|
||||
<section className={styles.section}>
|
||||
<h2>Configuration email (SMTP)</h2>
|
||||
<p className={styles.muted}>
|
||||
Ces valeurs sont définies dans le fichier <code>.env</code> du serveur. Redémarrez le serveur après modification.
|
||||
</p>
|
||||
<div className={styles.smtpGrid}>
|
||||
{[
|
||||
{ var: 'SMTP_HOST', desc: 'Serveur SMTP', example: 'smtp.gmail.com' },
|
||||
{ var: 'SMTP_PORT', desc: 'Port', example: '587' },
|
||||
{ var: 'SMTP_USER', desc: 'Utilisateur / adresse expéditeur', example: '[email protected]' },
|
||||
{ var: 'SMTP_PASS', desc: 'Mot de passe / App Password', example: '••••••••••••' },
|
||||
{ var: 'SMTP_FROM', desc: 'Nom et adresse affichés', example: '"Apex Configurateur <[email protected]>"' },
|
||||
].map(({ var: v, desc, example }) => (
|
||||
<div key={v} className={styles.smtpRow}>
|
||||
<code className={styles.smtpVar}>{v}</code>
|
||||
<span className={styles.smtpDesc}>{desc}</span>
|
||||
<span className={styles.smtpExample}>{example}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Suppliers ─────────────────────────────────────────── */}
|
||||
<section className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2>Fournisseurs</h2>
|
||||
<button type="button" className={styles.btnPrimary} onClick={openNewSupplier}>
|
||||
+ Nouveau fournisseur
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{supplierMsg && !editingSupplier && (
|
||||
<p className={supplierMsg.ok ? styles.success : styles.error}>{supplierMsg.text}</p>
|
||||
)}
|
||||
|
||||
{/* Supplier form (inline) */}
|
||||
{editingSupplier && (
|
||||
<form onSubmit={handleSupplierSave} className={styles.supplierForm}>
|
||||
<h3>{editingSupplier === 'new' ? 'Nouveau fournisseur' : `Modifier — ${editingSupplier.name}`}</h3>
|
||||
<div className={styles.supplierFormGrid}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Code *</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
required
|
||||
placeholder="ex. formex"
|
||||
value={supplierDraft.code}
|
||||
onChange={e => handleSupplierDraftChange('code', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Nom *</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
required
|
||||
placeholder="ex. Formex SA"
|
||||
value={supplierDraft.name}
|
||||
onChange={e => handleSupplierDraftChange('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Email commandes *</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="email"
|
||||
required
|
||||
placeholder="[email protected]"
|
||||
value={supplierDraft.email}
|
||||
onChange={e => handleSupplierDraftChange('email', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Type *</label>
|
||||
<select
|
||||
className={styles.input}
|
||||
value={supplierDraft.type}
|
||||
onChange={e => handleSupplierDraftChange('type', e.target.value)}
|
||||
>
|
||||
{SUPPLIER_TYPES.map(t => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Statut</label>
|
||||
<select
|
||||
className={styles.input}
|
||||
value={supplierDraft.active}
|
||||
onChange={e => handleSupplierDraftChange('active', Number(e.target.value))}
|
||||
>
|
||||
<option value={1}>Actif</option>
|
||||
<option value={0}>Inactif</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.formActions}>
|
||||
<button type="submit" className={styles.btnPrimary} disabled={supplierSaving}>
|
||||
{supplierSaving ? 'Sauvegarde…' : 'Sauvegarder'}
|
||||
</button>
|
||||
<button type="button" className={styles.btnGhost} onClick={cancelSupplierEdit}>
|
||||
Annuler
|
||||
</button>
|
||||
{supplierMsg && (
|
||||
<span className={supplierMsg.ok ? styles.success : styles.error}>
|
||||
{supplierMsg.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Suppliers table */}
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Nom</th>
|
||||
<th>Email commandes</th>
|
||||
<th>Type</th>
|
||||
<th>Statut</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{suppliers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className={styles.empty}>Aucun fournisseur.</td>
|
||||
</tr>
|
||||
) : (
|
||||
suppliers.map(s => (
|
||||
<tr key={s.id} className={editingSupplier?.id === s.id ? styles.rowEditing : ''}>
|
||||
<td><code>{s.code}</code></td>
|
||||
<td>{s.name}</td>
|
||||
<td>{s.email}</td>
|
||||
<td>{typeLabel(s.type)}</td>
|
||||
<td>
|
||||
<span className={s.active ? styles.badgeActive : styles.badgeInactive}>
|
||||
{s.active ? 'Actif' : 'Inactif'}
|
||||
</span>
|
||||
</td>
|
||||
<td className={styles.actions}>
|
||||
{deleteConfirm === s.id ? (
|
||||
<>
|
||||
<span className={styles.confirmText}>Supprimer ?</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnDanger}
|
||||
onClick={() => handleSupplierDelete(s.id)}
|
||||
>Oui</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnGhost}
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
>Non</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnGhost}
|
||||
onClick={() => openEditSupplier(s)}
|
||||
>Modifier</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnDangerGhost}
|
||||
onClick={() => setDeleteConfirm(s.id)}
|
||||
>Supprimer</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
.page {
|
||||
padding: 1.5rem 0 4rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-size: 0.9375rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #16a34a;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
/* ── Sections ─────────────────────────────────── */
|
||||
|
||||
.section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.sectionHeader h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Settings Form ────────────────────────────── */
|
||||
|
||||
.settingsForm {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.settingsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1.25rem 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--primary);
|
||||
background: #fff;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── SMTP Info Grid ───────────────────────────── */
|
||||
|
||||
.smtpGrid {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.smtpRow {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.smtpRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.smtpRow:nth-child(odd) {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.smtpVar {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.smtpDesc {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.smtpExample {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Supplier Form ────────────────────────────── */
|
||||
|
||||
.supplierForm {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.supplierFormGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1rem 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ruleFormGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1rem 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.fieldWide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.4;
|
||||
color: var(--primary);
|
||||
background: #fff;
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Form Actions ─────────────────────────────── */
|
||||
|
||||
.formActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Buttons ──────────────────────────────────── */
|
||||
|
||||
.btnPrimary {
|
||||
padding: 0.5625rem 1.25rem;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btnPrimary:hover:not(:disabled) {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.btnPrimary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btnGhost {
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btnGhost:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.btnDanger {
|
||||
padding: 0.4375rem 0.875rem;
|
||||
background: #b91c1c;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btnDanger:hover {
|
||||
background: #991b1b;
|
||||
}
|
||||
|
||||
.btnDangerGhost {
|
||||
padding: 0.4375rem 0.875rem;
|
||||
background: transparent;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 6px;
|
||||
color: #b91c1c;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btnDangerGhost:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
/* ── Suppliers Table ──────────────────────────── */
|
||||
|
||||
.tableWrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: var(--bg);
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.rowEditing {
|
||||
background: #eff6ff !important;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.confirmText {
|
||||
font-size: 0.8125rem;
|
||||
color: #b91c1c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ruleDetailsCell {
|
||||
white-space: pre-wrap;
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
/* ── Status Badges ────────────────────────────── */
|
||||
|
||||
.badgeActive {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badgeInactive {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
background: #f1f5f9;
|
||||
color: var(--text-muted);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': path.resolve(__dirname, '../shared'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
Generated
+325
-2
@@ -1,6 +1,329 @@
|
||||
{
|
||||
"name": "apex",
|
||||
"name": "configurateur-armoires",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "configurateur-armoires",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "4.1.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.3",
|
||||
"supports-color": "8.1.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -1,15 +1,20 @@
|
||||
{
|
||||
"name": "configurateur-armoires",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Configurateur d'armoires sur mesure — SIGNATURE",
|
||||
"scripts": {
|
||||
"install:all": "npm install --prefix server && npm install --prefix client",
|
||||
"db:init": "npm run db:init --prefix server",
|
||||
"db:seed": "npm run db:seed --prefix server",
|
||||
"dev": "concurrently -n server,client -c blue,green \"npm run dev --prefix server\" \"npm run dev --prefix client\"",
|
||||
"server": "npm start --prefix server",
|
||||
"client": "npm run dev --prefix client",
|
||||
"build": "npm run build --prefix client",
|
||||
"start": "npm run build --prefix client && npm start --prefix server"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,27 @@
|
||||
# DATABASE_PATH=./data/configurateur.db
|
||||
PORT=3001
|
||||
|
||||
# Formex account credentials (required for price scraping)
|
||||
FORMEX_EMAIL=[email protected]
|
||||
FORMEX_PASSWORD=[email protected]
|
||||
|
||||
# Formex sync schedule (cron expression, default: 3:00 AM daily)
|
||||
#FORMEX_SYNC_CRON=0 3 * * *
|
||||
# Delay between requests in ms (to avoid rate limiting)
|
||||
#FORMEX_CRAWL_DELAY_MS=500
|
||||
|
||||
# Häfele account credentials (optional; needed if product price requires authentication)
|
||||
#HAFELE_USERNAME=CHxxxxxxx
|
||||
#HAFELE_PASSWORD=your-password
|
||||
# Optional explicit customer number (defaults to HAFELE_USERNAME)
|
||||
#HAFELE_CUSTOMER_NO=CHxxxxxxx
|
||||
# Delay between Häfele requests in ms
|
||||
#HAFELE_CRAWL_DELAY_MS=500
|
||||
# Browser fallback for Cloudflare-protected pages (Playwright)
|
||||
# Set to 0 to run visible browser, 1 (default) for headless
|
||||
#HAFELE_BROWSER_HEADLESS=1
|
||||
# Browser timeout in ms
|
||||
#HAFELE_BROWSER_TIMEOUT_MS=90000
|
||||
|
||||
# OpenAI API Key for room photo analysis (GPT-4 Vision)
|
||||
OPENAI_API_KEY=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
|
||||
|
||||
@@ -7,9 +7,47 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dataDir = path.join(__dirname, '..', 'data');
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||
const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db');
|
||||
const schemaPath = path.join(__dirname, 'schema.sql');
|
||||
|
||||
let db = null;
|
||||
|
||||
/** Création idempotente — bases anciennes sans ces tables (avant migration 014). */
|
||||
const HARDWARE_CATALOG_DDL = `
|
||||
CREATE TABLE IF NOT EXISTS blum_hardware (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
variant TEXT,
|
||||
price_chf REAL NOT NULL,
|
||||
specs_json TEXT,
|
||||
image_url TEXT,
|
||||
active INTEGER DEFAULT 1
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS handle_variants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
style TEXT,
|
||||
length_mm INTEGER,
|
||||
price_chf REAL NOT NULL,
|
||||
image_url TEXT,
|
||||
active INTEGER DEFAULT 1
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS hinge_variants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
brand TEXT DEFAULT 'Blum',
|
||||
price_chf REAL NOT NULL,
|
||||
active INTEGER DEFAULT 1
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_blum_hardware_type ON blum_hardware(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_handle_variants_style ON handle_variants(style);
|
||||
CREATE INDEX IF NOT EXISTS idx_hinge_variants_type ON hinge_variants(type);
|
||||
`;
|
||||
|
||||
function toSqlitePlaceholders(sql) {
|
||||
return sql.replace(/\$(\d+)/g, '?');
|
||||
}
|
||||
@@ -31,6 +69,18 @@ async function getDb() {
|
||||
buffer = fs.readFileSync(dbPath);
|
||||
}
|
||||
db = new SQL.Database(buffer || null);
|
||||
try {
|
||||
// Ensure full schema exists for legacy/partial DB files.
|
||||
if (fs.existsSync(schemaPath)) {
|
||||
const schemaSql = fs.readFileSync(schemaPath, 'utf8');
|
||||
db.exec(schemaSql);
|
||||
}
|
||||
db.exec(HARDWARE_CATALOG_DDL);
|
||||
const data = db.export();
|
||||
fs.writeFileSync(dbPath, Buffer.from(data));
|
||||
} catch (e) {
|
||||
console.error('[db] ensureHardwareCatalogTables:', e.message);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add price_chf to formex_dimensions so each size variant can have its own price
|
||||
ALTER TABLE formex_dimensions ADD COLUMN price_chf REAL;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Migration 007: Suppliers table for order voucher emails
|
||||
|
||||
CREATE TABLE IF NOT EXISTS suppliers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- 'panels', 'hardware', 'handles', 'other'
|
||||
active INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Seed default suppliers
|
||||
INSERT OR IGNORE INTO suppliers (code, name, email, type) VALUES
|
||||
('formex', 'Formex', '[email protected]', 'panels'),
|
||||
('blum', 'Blum Switzerland', '[email protected]', 'hardware');
|
||||
@@ -91,6 +91,7 @@ CREATE TABLE IF NOT EXISTS formex_dimensions (
|
||||
width_mm INTEGER NOT NULL,
|
||||
height_mm INTEGER NOT NULL,
|
||||
thickness_mm INTEGER NOT NULL,
|
||||
price_chf REAL,
|
||||
is_default INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
@@ -200,3 +201,16 @@ CREATE INDEX IF NOT EXISTS idx_blum_hardware_type ON blum_hardware(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_handle_variants_style ON handle_variants(style);
|
||||
CREATE INDEX IF NOT EXISTS idx_hinge_variants_type ON hinge_variants(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_element_colors_order ON element_colors(order_id);
|
||||
|
||||
-- Exports de plans/debitage enregistrés par commande
|
||||
CREATE TABLE IF NOT EXISTS plan_exports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
export_type TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
mime_type TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_plan_exports_order ON plan_exports(order_id);
|
||||
|
||||
+233
-5
@@ -4,7 +4,7 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import cors from 'cors';
|
||||
import { dbReady } from './db/index.js';
|
||||
import { dbReady, run, query } from './db/index.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
dotenv.config({ path: path.join(__dirname, '.env') });
|
||||
@@ -12,12 +12,32 @@ import { getProducts } from './routes/products.js';
|
||||
import { getBlockTypes } from './routes/blockTypes.js';
|
||||
import { getFinishes } from './routes/finishes.js';
|
||||
import { getServiceOptions } from './routes/serviceOptions.js';
|
||||
import { getSettings } from './routes/settings.js';
|
||||
import { getSettings, updateSettings } from './routes/settings.js';
|
||||
import {
|
||||
ensureEngineeringRulesSeed,
|
||||
getEngineeringRules,
|
||||
createEngineeringRule,
|
||||
updateEngineeringRule,
|
||||
deleteEngineeringRule
|
||||
} from './routes/engineeringRules.js';
|
||||
import { postPrice } from './routes/price.js';
|
||||
import { getOrders, postOrder } from './routes/orders.js';
|
||||
import {
|
||||
getOrders,
|
||||
postOrder,
|
||||
getOrderSupplierVouchers,
|
||||
listOrderPlanExports,
|
||||
createOrderPlanExport,
|
||||
downloadOrderPlanExport,
|
||||
generateOrderPlanExports,
|
||||
getOrderProductionDetails,
|
||||
deleteOrderPlanExport
|
||||
} from './routes/orders.js';
|
||||
import { getSuppliers, createSupplier, updateSupplier, deleteSupplier } from './routes/suppliers.js';
|
||||
import {
|
||||
triggerFormexSync,
|
||||
streamFormexSync,
|
||||
triggerHaefeleSync,
|
||||
streamHaefeleSync,
|
||||
getLogs,
|
||||
getStatus,
|
||||
getFormexMaterials,
|
||||
@@ -26,7 +46,9 @@ import {
|
||||
updateFormexMaterial,
|
||||
deleteFormexMaterial,
|
||||
getFormexCollections,
|
||||
getFormexCategories
|
||||
getFormexCategories,
|
||||
getFormexSuppliers,
|
||||
getHaefeleMaterials
|
||||
} from './routes/sync.js';
|
||||
import {
|
||||
uploadMiddleware,
|
||||
@@ -53,7 +75,11 @@ import {
|
||||
generatePlansRoute,
|
||||
generateCuttingListRoute,
|
||||
exportCuttingListText,
|
||||
exportPDFHtml
|
||||
exportPDFHtml,
|
||||
exportCncRoute,
|
||||
exportMprRoute,
|
||||
exportMprPieceRoute,
|
||||
generateHardwareListRoute
|
||||
} from './routes/plans.js';
|
||||
import {
|
||||
designTipsRoute,
|
||||
@@ -76,13 +102,33 @@ app.get('/api/block-types', getBlockTypes);
|
||||
app.get('/api/finishes', getFinishes);
|
||||
app.get('/api/service-options', getServiceOptions);
|
||||
app.get('/api/settings', getSettings);
|
||||
app.put('/api/settings', updateSettings);
|
||||
app.get('/api/engineering-rules', getEngineeringRules);
|
||||
app.post('/api/engineering-rules', createEngineeringRule);
|
||||
app.put('/api/engineering-rules/:id', updateEngineeringRule);
|
||||
app.delete('/api/engineering-rules/:id', deleteEngineeringRule);
|
||||
app.post('/api/price', postPrice);
|
||||
app.get('/api/orders', getOrders);
|
||||
app.post('/api/orders', postOrder);
|
||||
app.get('/api/orders/:id/supplier-vouchers', getOrderSupplierVouchers);
|
||||
app.get('/api/orders/:id/plan-exports', listOrderPlanExports);
|
||||
app.post('/api/orders/:id/plan-exports', createOrderPlanExport);
|
||||
app.post('/api/orders/:id/plan-exports/generate', generateOrderPlanExports);
|
||||
app.get('/api/orders/:id/plan-exports/:exportId/download', downloadOrderPlanExport);
|
||||
app.delete('/api/orders/:id/plan-exports/:exportId', deleteOrderPlanExport);
|
||||
app.get('/api/orders/:id/production-details', getOrderProductionDetails);
|
||||
|
||||
// Supplier routes
|
||||
app.get('/api/suppliers', getSuppliers);
|
||||
app.post('/api/suppliers', createSupplier);
|
||||
app.put('/api/suppliers/:id', updateSupplier);
|
||||
app.delete('/api/suppliers/:id', deleteSupplier);
|
||||
|
||||
// Sync routes (Formex crawler)
|
||||
app.post('/api/sync/formex', triggerFormexSync);
|
||||
app.get('/api/sync/formex/stream', streamFormexSync);
|
||||
app.post('/api/sync/haefele', triggerHaefeleSync);
|
||||
app.get('/api/sync/haefele/stream', streamHaefeleSync);
|
||||
app.get('/api/sync/logs', getLogs);
|
||||
app.get('/api/sync/status', getStatus);
|
||||
|
||||
@@ -94,6 +140,8 @@ app.put('/api/formex/materials/:id', updateFormexMaterial);
|
||||
app.delete('/api/formex/materials/:id', deleteFormexMaterial);
|
||||
app.get('/api/formex/collections', getFormexCollections);
|
||||
app.get('/api/formex/categories', getFormexCategories);
|
||||
app.get('/api/formex/suppliers', getFormexSuppliers);
|
||||
app.get('/api/haefele/materials', getHaefeleMaterials);
|
||||
|
||||
// Room analysis routes (AI-powered)
|
||||
app.post('/api/room/analyze', uploadMiddleware, analyzeRoom);
|
||||
@@ -125,8 +173,12 @@ app.post('/api/ai/render', renderRoute);
|
||||
// Plans and cutting list routes
|
||||
app.post('/api/plans/generate', generatePlansRoute);
|
||||
app.post('/api/plans/cutting-list', generateCuttingListRoute);
|
||||
app.post('/api/plans/hardware-list', generateHardwareListRoute);
|
||||
app.post('/api/plans/export-text', exportCuttingListText);
|
||||
app.post('/api/plans/export-pdf', exportPDFHtml);
|
||||
app.post('/api/plans/export-cnc', exportCncRoute);
|
||||
app.post('/api/plans/export-mpr', exportMprRoute);
|
||||
app.post('/api/plans/export-mpr-piece', exportMprPieceRoute);
|
||||
|
||||
if (hasClientBuild) {
|
||||
app.use(express.static(clientDist));
|
||||
@@ -152,6 +204,182 @@ if (hasClientBuild) {
|
||||
|
||||
await dbReady;
|
||||
|
||||
// Apply migration 007: suppliers table + supplier_id columns
|
||||
async function applyMigration007() {
|
||||
try {
|
||||
await run(`CREATE TABLE IF NOT EXISTS suppliers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
active INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)`);
|
||||
|
||||
// Add supplier_id to formex_materials if missing
|
||||
const fmCols = (await query("PRAGMA table_info(formex_materials)")).rows;
|
||||
if (!fmCols.find(c => c.name === 'supplier_id')) {
|
||||
await run('ALTER TABLE formex_materials ADD COLUMN supplier_id INTEGER REFERENCES suppliers(id)');
|
||||
}
|
||||
// Add supplier_id to blum_hardware if missing
|
||||
const bhCols = (await query("PRAGMA table_info(blum_hardware)")).rows;
|
||||
if (!bhCols.find(c => c.name === 'supplier_id')) {
|
||||
await run('ALTER TABLE blum_hardware ADD COLUMN supplier_id INTEGER REFERENCES suppliers(id)');
|
||||
}
|
||||
// Add supplier_id to handle_variants if missing
|
||||
const hvCols = (await query("PRAGMA table_info(handle_variants)")).rows;
|
||||
if (!hvCols.find(c => c.name === 'supplier_id')) {
|
||||
await run('ALTER TABLE handle_variants ADD COLUMN supplier_id INTEGER REFERENCES suppliers(id)');
|
||||
}
|
||||
// Add supplier_id to hinge_variants if missing
|
||||
const hingeCols = (await query("PRAGMA table_info(hinge_variants)")).rows;
|
||||
if (!hingeCols.find(c => c.name === 'supplier_id')) {
|
||||
await run('ALTER TABLE hinge_variants ADD COLUMN supplier_id INTEGER REFERENCES suppliers(id)');
|
||||
}
|
||||
|
||||
// Seed default suppliers
|
||||
await run(`INSERT OR IGNORE INTO suppliers (code, name, email, type) VALUES
|
||||
('formex', 'Formex', '[email protected]', 'panels'),
|
||||
('blum', 'Blum Switzerland', '[email protected]', 'hardware'),
|
||||
('haefele', 'Hafele', '[email protected]', 'materials')`);
|
||||
|
||||
// Assign existing rows to their suppliers
|
||||
await run(`UPDATE formex_materials SET supplier_id = (SELECT id FROM suppliers WHERE code = 'formex') WHERE supplier_id IS NULL`);
|
||||
await run(`UPDATE blum_hardware SET supplier_id = (SELECT id FROM suppliers WHERE code = 'blum') WHERE supplier_id IS NULL`);
|
||||
await run(`UPDATE handle_variants SET supplier_id = (SELECT id FROM suppliers WHERE code = 'blum') WHERE supplier_id IS NULL`);
|
||||
await run(`UPDATE hinge_variants SET supplier_id = (SELECT id FROM suppliers WHERE code = 'blum') WHERE supplier_id IS NULL`);
|
||||
|
||||
console.log('[migration-007] Suppliers table ready.');
|
||||
} catch (err) {
|
||||
console.error('[migration-007] Error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
await applyMigration007();
|
||||
|
||||
// Apply migration 008: price_unit column on formex_materials
|
||||
async function applyMigration008() {
|
||||
try {
|
||||
const cols = (await query("PRAGMA table_info(formex_materials)")).rows;
|
||||
if (!cols.find(c => c.name === 'price_unit')) {
|
||||
await run('ALTER TABLE formex_materials ADD COLUMN price_unit TEXT');
|
||||
console.log('[migration-008] Added price_unit column to formex_materials.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[migration-008] Error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
await applyMigration008();
|
||||
|
||||
// Apply migration 009: persist generated plan exports
|
||||
async function applyMigration009() {
|
||||
try {
|
||||
await run(`CREATE TABLE IF NOT EXISTS plan_exports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
export_type TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
mime_type TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)`);
|
||||
await run('CREATE INDEX IF NOT EXISTS idx_plan_exports_order ON plan_exports(order_id)');
|
||||
console.log('[migration-009] Plan exports table ready.');
|
||||
} catch (err) {
|
||||
console.error('[migration-009] Error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
await applyMigration009();
|
||||
|
||||
async function applyMigration010() {
|
||||
try {
|
||||
const cols = (await query("PRAGMA table_info(formex_materials)")).rows;
|
||||
if (!cols.find(c => c.name === 'bore_diameter_mm')) {
|
||||
await run('ALTER TABLE formex_materials ADD COLUMN bore_diameter_mm REAL');
|
||||
console.log('[migration-010] Added bore_diameter_mm to formex_materials.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[migration-010] Error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
await applyMigration010();
|
||||
|
||||
async function applyMigration011() {
|
||||
try {
|
||||
const cols = (await query("PRAGMA table_info(formex_materials)")).rows;
|
||||
const colNames = cols.map(c => c.name);
|
||||
if (!colNames.includes('bore_depth_base_mm')) {
|
||||
await run('ALTER TABLE formex_materials ADD COLUMN bore_depth_base_mm REAL');
|
||||
console.log('[migration-011] Added bore_depth_base_mm to formex_materials.');
|
||||
}
|
||||
if (!colNames.includes('bore_depth_piece_mm')) {
|
||||
await run('ALTER TABLE formex_materials ADD COLUMN bore_depth_piece_mm REAL');
|
||||
console.log('[migration-011] Added bore_depth_piece_mm to formex_materials.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[migration-011] Error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
await applyMigration011();
|
||||
|
||||
async function applyMigration012() {
|
||||
try {
|
||||
const cols = (await query("PRAGMA table_info(formex_materials)")).rows;
|
||||
const colNames = cols.map(c => c.name);
|
||||
if (!colNames.includes('diameter_mm')) {
|
||||
await run('ALTER TABLE formex_materials ADD COLUMN diameter_mm REAL');
|
||||
console.log('[migration-012] Added diameter_mm to formex_materials.');
|
||||
}
|
||||
if (!colNames.includes('length_mm')) {
|
||||
await run('ALTER TABLE formex_materials ADD COLUMN length_mm REAL');
|
||||
console.log('[migration-012] Added length_mm to formex_materials.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[migration-012] Error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
await applyMigration012();
|
||||
|
||||
async function applyMigration013() {
|
||||
try {
|
||||
await run(`CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)`);
|
||||
|
||||
const defaults = [
|
||||
['allow_sliding_doors', '0'],
|
||||
['sanitary_gap_mm', '30'],
|
||||
['drawer_depth_min_mm', '400'],
|
||||
['drawer_depth_max_mm', '650'],
|
||||
['drawer_depth_step_mm', '50'],
|
||||
['drawer_back_gap_mm', '10'],
|
||||
['drawer_hidden_handle_reduction_mm', '40'],
|
||||
['standard_handle_axis_mm', '1000'],
|
||||
['standard_handle_tolerance_mm', '200'],
|
||||
['groove_width_mm', '9'],
|
||||
['groove_depth_mm', '10.5'],
|
||||
['groove_edge_offset_mm', '16']
|
||||
];
|
||||
|
||||
for (const [key, value] of defaults) {
|
||||
await run('INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2)', [key, value]);
|
||||
}
|
||||
console.log('[migration-013] Rule settings defaults ready.');
|
||||
} catch (err) {
|
||||
console.error('[migration-013] Error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
await applyMigration013();
|
||||
await ensureEngineeringRulesSeed();
|
||||
|
||||
// Initialize the scheduler for automated sync
|
||||
initScheduler();
|
||||
|
||||
|
||||
Generated
+266
@@ -12,12 +12,25 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.0",
|
||||
"jszip": "^3.10.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^8.0.1",
|
||||
"openai": "^4.28.0",
|
||||
"pdfkit": "^0.17.2",
|
||||
"playwright": "^1.58.2",
|
||||
"sql.js": "^1.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
|
||||
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "18.19.130",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
||||
@@ -92,6 +105,26 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
@@ -122,6 +155,15 @@
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -219,6 +261,15 @@
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -305,6 +356,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
@@ -370,6 +427,12 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
@@ -618,6 +681,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@@ -636,6 +705,23 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
@@ -689,6 +775,20 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -858,6 +958,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -879,6 +985,59 @@
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jpeg-exif": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -1055,6 +1214,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
@@ -1130,6 +1298,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
@@ -1194,6 +1368,54 @@
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdfkit": {
|
||||
"version": "0.17.2",
|
||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
|
||||
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.2.0",
|
||||
"fontkit": "^2.0.4",
|
||||
"jpeg-exif": "^1.1.4",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
|
||||
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
@@ -1273,6 +1495,12 @@
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -1344,6 +1572,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -1460,6 +1694,12 @@
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -1475,6 +1715,12 @@
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
@@ -1509,6 +1755,26 @@
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "configurateur-armoires-server",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"description": "API Node/Express pour le configurateur d'armoires sur mesure",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
@@ -16,9 +16,13 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.0",
|
||||
"jszip": "^3.10.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^8.0.1",
|
||||
"openai": "^4.28.0",
|
||||
"pdfkit": "^0.17.2",
|
||||
"playwright": "^1.58.2",
|
||||
"sql.js": "^1.11.0"
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -29,15 +29,16 @@ export async function designTipsRoute(req, res) {
|
||||
|
||||
/**
|
||||
* POST /api/ai/recommend-materials
|
||||
* Body: { config, finishes: [{ id, name, collection }], roomAnalysis? }
|
||||
* Body: { config, finishes: [{ id, name, collection, price_chf? }], roomAnalysis?, priorities? }
|
||||
* priorities: { price: 1-5, quality: 1-5, durability: 1-5, aesthetics: 1-5 }
|
||||
*/
|
||||
export async function recommendMaterialsRoute(req, res) {
|
||||
try {
|
||||
const { config, finishes, roomAnalysis } = req.body;
|
||||
const { config, finishes, roomAnalysis, priorities } = req.body;
|
||||
if (!config || !finishes) {
|
||||
return res.status(400).json({ error: 'Missing config or finishes in body' });
|
||||
}
|
||||
const result = await recommendMaterials(config, finishes, roomAnalysis);
|
||||
const result = await recommendMaterials(config, finishes, roomAnalysis, priorities);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[AI recommend-materials]', err.message);
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import pool, { run } from '../db/index.js';
|
||||
|
||||
const DEFAULT_ENGINEERING_RULES = [
|
||||
{
|
||||
sort_order: 10,
|
||||
chapter: '1.A',
|
||||
title: "Hiérarchie d'assemblage (Côtés filants vs chapeau)",
|
||||
details:
|
||||
"Si hauteur >= 1200 mm: montage en Côtés filants (montants verticaux enferment fond et dessus).\n" +
|
||||
"Si hauteur < 1200 mm: montage en Chapeau (panneau dessus recouvre les montants)."
|
||||
},
|
||||
{
|
||||
sort_order: 20,
|
||||
chapter: '1.B',
|
||||
title: 'Sens du fil du bois (variable sens_du_fil)',
|
||||
details:
|
||||
"Le matériau doit porter une variable booléenne sens_du_fil.\n" +
|
||||
'Forcé vertical: portes, montants, joues.\n' +
|
||||
'Forcé horizontal: tiroirs, tablettes/rayons, fonds/dessus.'
|
||||
},
|
||||
{
|
||||
sort_order: 30,
|
||||
chapter: '2.A',
|
||||
title: 'Portée étagères anti-flexion',
|
||||
details:
|
||||
"Rayon libre mélaminé 19 mm <= 1200 mm.\n" +
|
||||
"Si > 1200 mm: forcer une séparation verticale OU imposer épaisseur 25 mm."
|
||||
},
|
||||
{
|
||||
sort_order: 40,
|
||||
chapter: '2.A',
|
||||
title: 'Portes et gauchissement',
|
||||
details:
|
||||
'Porte monobloc <= 2500 mm (hauteur) et <= 1000 mm (largeur).\n' +
|
||||
'Au-delà: imposer automatiquement des tendeurs de redressement encastrés.'
|
||||
},
|
||||
{
|
||||
sort_order: 50,
|
||||
chapter: '2.A',
|
||||
title: 'Socle et passage de pieds',
|
||||
details:
|
||||
"Recul face plinthe >= 50 mm par rapport à la façade.\n" +
|
||||
'Calcul de référence: porte 19 + jeu 3 + retrait base 28 = 50 mm.\n' +
|
||||
"Avec plinthe 19 mm: centre pied Axilo à 69 mm des bords avant/arrière."
|
||||
},
|
||||
{
|
||||
sort_order: 60,
|
||||
chapter: '2.B',
|
||||
title: "Tolérance jeu périphérique des façades",
|
||||
details: 'Façade (porte/tiroir) plus petite de 1.5 mm à 2 mm sur chaque bord.'
|
||||
},
|
||||
{
|
||||
sort_order: 70,
|
||||
chapter: '2.B',
|
||||
title: "Tolérance jeu entre façades (fuge)",
|
||||
details: 'Jeu entre deux portes/tiroirs: 2 mm à 3 mm.'
|
||||
},
|
||||
{
|
||||
sort_order: 80,
|
||||
chapter: '2.B',
|
||||
title: 'Jeu au sol porte descendante',
|
||||
details: 'Sans socle visible: déduire 10 mm à 15 mm en bas de la hauteur de porte.'
|
||||
},
|
||||
{
|
||||
sort_order: 90,
|
||||
chapter: '2.C',
|
||||
title: 'Surcote de tablettage (doublage)',
|
||||
details:
|
||||
'Prévoir du gras pour ajustement mur.\n' +
|
||||
'Profondeur = ép. porte 19 + jeu butée 3 + profondeur caisse + surcote tablettage 20.'
|
||||
},
|
||||
{
|
||||
sort_order: 100,
|
||||
chapter: '3',
|
||||
title: 'Placage chants - déduction CNC',
|
||||
details: 'La machine doit soustraire 2 mm par chant appliqué sur la coupe brute.'
|
||||
},
|
||||
{
|
||||
sort_order: 110,
|
||||
chapter: '3',
|
||||
title: 'Placage chants - côté caisse / porte / face tiroir / plinthe',
|
||||
details: '4 chants. Ordre plaqueuse: 2 largeurs puis 2 longueurs.'
|
||||
},
|
||||
{
|
||||
sort_order: 120,
|
||||
chapter: '3',
|
||||
title: 'Placage chants - fonds et dessus structure',
|
||||
details: '2 chants: longueurs avant et arrière.'
|
||||
},
|
||||
{
|
||||
sort_order: 130,
|
||||
chapter: '3',
|
||||
title: 'Placage chants - rayons et séparations',
|
||||
details: '1 chant: bord avant.'
|
||||
},
|
||||
{
|
||||
sort_order: 140,
|
||||
chapter: '3',
|
||||
title: 'Placage chants - joues de doublage',
|
||||
details: '3 chants: 2 largeurs puis chant avant sur toute la hauteur.'
|
||||
},
|
||||
{
|
||||
sort_order: 150,
|
||||
chapter: '3',
|
||||
title: "Placage chants - dessus d'habillage (chapeau)",
|
||||
details:
|
||||
'3 chants: 2 largeurs puis chant avant sur la longueur.\n' +
|
||||
"Coupe à 45° avec petit plat 2x2 mm sur l'angle extérieur."
|
||||
},
|
||||
{
|
||||
sort_order: 160,
|
||||
chapter: '3',
|
||||
title: 'Pièces sans chants',
|
||||
details: 'Dos 8 mm et fonds de tiroirs: 0 chant.'
|
||||
},
|
||||
{
|
||||
sort_order: 170,
|
||||
chapter: '4.A',
|
||||
title: 'Mode accessibilité SIA 500',
|
||||
details:
|
||||
'Option UI "Conception SIA 500".\n' +
|
||||
"Si activée: verrouiller la hauteur des éléments interactifs entre 800 mm et 1100 mm du sol."
|
||||
},
|
||||
{
|
||||
sort_order: 180,
|
||||
chapter: '4.B',
|
||||
title: 'Penderie - profondeur cintres',
|
||||
details:
|
||||
'Profondeur intérieure utile >= 600 mm (meuble total env. 647 mm).\n' +
|
||||
'Si profondeur < 400 mm: désactiver le bouton "Ajouter penderie".'
|
||||
},
|
||||
{
|
||||
sort_order: 190,
|
||||
chapter: '4.B',
|
||||
title: 'Penderie - hauteurs libres',
|
||||
details: 'Vestes courtes >= 1100 mm. Manteaux longs >= 1600 mm.'
|
||||
},
|
||||
{
|
||||
sort_order: 200,
|
||||
chapter: '4.C',
|
||||
title: 'Tiroirs intérieurs - hauteur maximale',
|
||||
details: "Interdire un module tiroir au-delà de 1400 mm du sol (ligne d'épaule)."
|
||||
},
|
||||
{
|
||||
sort_order: 210,
|
||||
chapter: '4.C',
|
||||
title: 'Tiroirs intérieurs - hauteurs de façades',
|
||||
details: 'Variantes autorisées: 60, 90, 128, 177, 240 mm.'
|
||||
},
|
||||
{
|
||||
sort_order: 220,
|
||||
chapter: '4.C',
|
||||
title: 'Calepinage tiroirs',
|
||||
details: 'H façade = [H ouverture - (nb jeux * dimension jeu)] / nb tiroirs.'
|
||||
},
|
||||
{
|
||||
sort_order: 230,
|
||||
chapter: '4.C',
|
||||
title: "Tiroirs à l'anglaise (cales de compensation)",
|
||||
details:
|
||||
"Si tiroir derrière porte à charnières: générer une cale (ép. 25 mm x prof. int., hauteur = hauteur bloc tiroirs).\n" +
|
||||
"La cale doit être dans la même couleur que l'intérieur."
|
||||
},
|
||||
{
|
||||
sort_order: 240,
|
||||
chapter: '5.A',
|
||||
title: 'Mode cuisine - standards de largeur',
|
||||
details:
|
||||
'Snapping des largeurs sur multiples de 60 cm (EN) ou 55 cm (SMS).\n' +
|
||||
'Meubles bas: caisse 720/780 mm, plan fini 860-920 mm.\n' +
|
||||
'Meubles hauts: profondeur 350 mm, crédence 500-600 mm (650 mm gaz), hauteurs 360/480/600/720/900 mm.\n' +
|
||||
'Colonnes alignées aux meubles hauts: 2070/2150/2330 mm.'
|
||||
},
|
||||
{
|
||||
sort_order: 250,
|
||||
chapter: '5.B',
|
||||
title: 'Meubles hauts suspendus - quincaillerie et usinages',
|
||||
details:
|
||||
'Ferrure droite 290.54.900 et gauche 290.54.901.\n' +
|
||||
'Entaille haute: 120x12 mm, prof. 25 mm, à 50 mm depuis le haut (bord arrière).\n' +
|
||||
'Entaille basse: 120x12 mm, prof. 68 mm, à 50 mm du bord arrière.\n' +
|
||||
'Perçages réglage face intérieure: axe 110 mm; +22 mm et -10 mm; profondeur 10 mm; à 60 mm du bord arrière.'
|
||||
},
|
||||
{
|
||||
sort_order: 260,
|
||||
chapter: '5.C',
|
||||
title: 'Gestion thermique fours/frigos - règle absolue du dos',
|
||||
details:
|
||||
'Ne jamais générer de dos 8 mm ni rainure derrière four/réfrigérateur.\n' +
|
||||
"Prévoir un vide arrière d'au moins 50 mm."
|
||||
},
|
||||
{
|
||||
sort_order: 270,
|
||||
chapter: '5.C',
|
||||
title: 'Aération réfrigérateurs',
|
||||
details:
|
||||
'Plinthe >= 100 mm pour la grille.\n' +
|
||||
'Générer 1 grille basse (plinthe) + 1 grille haute (bandeau), découpe 457x92 mm.\n' +
|
||||
'Références: Inox 959.10.000, Blanc 959.10.001, Pin 959.10.002, Acajou 959.10.003, Noyer 959.10.004.'
|
||||
},
|
||||
{
|
||||
sort_order: 280,
|
||||
chapter: '5.C',
|
||||
title: 'Porte frigo - règle logistique',
|
||||
details:
|
||||
"Ne pas générer ni facturer les accessoires de liaison porte armoire/porte frigo (fournis avec l'électroménager)."
|
||||
},
|
||||
{
|
||||
sort_order: 290,
|
||||
chapter: '5.D',
|
||||
title: 'Dimensions de niches appareils',
|
||||
details:
|
||||
'Fours EN 600: standard 590/560/550, compact 450/560/550.\n' +
|
||||
'Fours SMS 550: standard 762/512/550, compact 381 ou 508 / 512 / 550.\n' +
|
||||
'Doubles fours: séparation fixe 19 mm obligatoire.\n' +
|
||||
'Plaque cuisson: protéger le tiroir inférieur avec panneau horizontal + vide ventilé 20 mm.\n' +
|
||||
"Sous-évier: dos partiel (traverses 150 mm haut/bas) + option fond de protection aluminium."
|
||||
},
|
||||
{
|
||||
sort_order: 300,
|
||||
chapter: '6.A',
|
||||
title: 'Système 32 - axe charnière à 103 mm',
|
||||
details:
|
||||
"Pour réversibilité: centre embase à 105 mm, déduction jeu façade 2 mm => axe perçage porte à 103 mm du haut/bas.\n" +
|
||||
'Répartition intermédiaire: arrondir au multiple de 32 mm.'
|
||||
},
|
||||
{
|
||||
sort_order: 310,
|
||||
chapter: '6.B',
|
||||
title: 'Matrice quantité charnières (épaisseur 19 mm)',
|
||||
details:
|
||||
'<=900 mm ou <=6 kg: 2 charnières.\n' +
|
||||
'901-1600 mm ou <=17 kg: 3.\n' +
|
||||
'1601-2000 mm ou <=20 kg: 4.\n' +
|
||||
'2001-2400 mm ou <=22 kg: 5.\n' +
|
||||
'2401-2600 mm: 6.'
|
||||
},
|
||||
{
|
||||
sort_order: 320,
|
||||
chapter: '7.A',
|
||||
title: 'Sécurité LRFP - risque de basculement',
|
||||
details:
|
||||
"Si hauteur > 3 x profondeur: générer la ferrure murale obligatoire (Réf 988.98.010) et lever une alerte UI."
|
||||
},
|
||||
{
|
||||
sort_order: 330,
|
||||
chapter: '7.A',
|
||||
title: 'Éclairage LED (NIN)',
|
||||
details:
|
||||
'Le transformateur ne doit pas être enfermé derrière le dos.\n' +
|
||||
'Usinage profilés LED: rainure 14x9 mm + perçage traversant Ø8 mm en extrémité.'
|
||||
},
|
||||
{
|
||||
sort_order: 340,
|
||||
chapter: '7.A',
|
||||
title: 'Accès technique et pièces humides',
|
||||
details:
|
||||
'Option "caisson amovible": découpe fenêtre dans le dos 8 mm selon cotes.\n' +
|
||||
'Pièces humides: imposer colle PU étanche pour chants ABS et électricité IP44.'
|
||||
},
|
||||
{
|
||||
sort_order: 350,
|
||||
chapter: '7.B',
|
||||
title: 'E-commerce Suisse - conformité légale',
|
||||
details:
|
||||
'OChim: matériaux codés E1/CARB2 + badge "Swiss Quality / Faible émission E1".\n' +
|
||||
'ODoB: stocker Nom commercial, Essence (nom latin), Pays, Label et afficher sur facture.\n' +
|
||||
'OIP: affichage prix TTC 8.1% en direct.\n' +
|
||||
'TAR: si électronique incluse, afficher "TVA et TAR incluses".\n' +
|
||||
'LCD: afficher Impressum complet en pied de page.\n' +
|
||||
'PCI-DSS: passerelle certifiée (Datatrans/Twint/Stripe), aucune donnée bancaire hébergée par APEX.'
|
||||
}
|
||||
];
|
||||
|
||||
async function ensureEngineeringRulesTable() {
|
||||
await run(`CREATE TABLE IF NOT EXISTS engineering_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sort_order INTEGER NOT NULL,
|
||||
chapter TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
details TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)`);
|
||||
}
|
||||
|
||||
export async function ensureEngineeringRulesSeed() {
|
||||
await ensureEngineeringRulesTable();
|
||||
const { rows } = await pool.query('SELECT COUNT(*) AS total FROM engineering_rules');
|
||||
const total = Number(rows?.[0]?.total || 0);
|
||||
if (total > 0) return;
|
||||
|
||||
for (const rule of DEFAULT_ENGINEERING_RULES) {
|
||||
await run(
|
||||
'INSERT INTO engineering_rules (sort_order, chapter, title, details) VALUES (?, ?, ?, ?)',
|
||||
[rule.sort_order, rule.chapter, rule.title, rule.details]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEngineeringRules(req, res) {
|
||||
try {
|
||||
await ensureEngineeringRulesSeed();
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, sort_order, chapter, title, details, created_at, updated_at FROM engineering_rules ORDER BY sort_order ASC, id ASC'
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createEngineeringRule(req, res) {
|
||||
try {
|
||||
await ensureEngineeringRulesTable();
|
||||
const { sort_order, chapter, title, details } = req.body || {};
|
||||
|
||||
if (!chapter || !title || !details) {
|
||||
return res.status(400).json({ error: 'chapter, title et details sont requis' });
|
||||
}
|
||||
|
||||
const normalizedSort = Number.isFinite(Number(sort_order)) ? Number(sort_order) : 9999;
|
||||
await run(
|
||||
'INSERT INTO engineering_rules (sort_order, chapter, title, details) VALUES (?, ?, ?, ?)',
|
||||
[normalizedSort, String(chapter).trim(), String(title).trim(), String(details).trim()]
|
||||
);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, sort_order, chapter, title, details, created_at, updated_at FROM engineering_rules ORDER BY id DESC LIMIT 1'
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateEngineeringRule(req, res) {
|
||||
try {
|
||||
await ensureEngineeringRulesTable();
|
||||
const { id } = req.params;
|
||||
const { sort_order, chapter, title, details } = req.body || {};
|
||||
|
||||
const existing = await pool.query('SELECT id FROM engineering_rules WHERE id = ?', [id]);
|
||||
if (existing.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Règle non trouvée' });
|
||||
}
|
||||
|
||||
if (!chapter || !title || !details) {
|
||||
return res.status(400).json({ error: 'chapter, title et details sont requis' });
|
||||
}
|
||||
|
||||
const normalizedSort = Number.isFinite(Number(sort_order)) ? Number(sort_order) : 9999;
|
||||
await run(
|
||||
`UPDATE engineering_rules
|
||||
SET sort_order = ?, chapter = ?, title = ?, details = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
[normalizedSort, String(chapter).trim(), String(title).trim(), String(details).trim(), id]
|
||||
);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, sort_order, chapter, title, details, created_at, updated_at FROM engineering_rules WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteEngineeringRule(req, res) {
|
||||
try {
|
||||
await ensureEngineeringRulesTable();
|
||||
const { id } = req.params;
|
||||
const existing = await pool.query('SELECT id FROM engineering_rules WHERE id = ?', [id]);
|
||||
if (existing.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Règle non trouvée' });
|
||||
}
|
||||
|
||||
await run('DELETE FROM engineering_rules WHERE id = ?', [id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
+322
-1
@@ -1,4 +1,59 @@
|
||||
import pool, { run } from '../db/index.js';
|
||||
import { sendSupplierVouchers, buildSupplierVoucherPayload } from '../services/supplierVoucher.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { generateCuttingList, formatCuttingListForExport } from '../services/cuttingEngine.js';
|
||||
import { generatePackingList } from '../services/packingEngine.js';
|
||||
import { generateAllPlans } from '../services/planGenerator.js';
|
||||
import { generateOrderDossierPdf } from '../services/exportPdf.js';
|
||||
import { validateConfig } from '../services/configValidation.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const planExportsRoot = path.join(__dirname, '..', 'data', 'plan-exports');
|
||||
|
||||
function ensurePlanExportDir(orderId) {
|
||||
const dir = path.join(planExportsRoot, `order-${orderId}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function saveOrderPlanExportRecord(orderId, exportType, content, mimeType = 'text/plain', fileExtension = 'txt') {
|
||||
const safeExt = (fileExtension || 'txt').toLowerCase().replace(/[^a-z0-9]/g, '') || 'txt';
|
||||
const safeType = String(exportType || '').toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
||||
if (!safeType) throw new Error('exportType invalide.');
|
||||
if (!(typeof content === 'string' || Buffer.isBuffer(content))) throw new Error('content requis.');
|
||||
if ((typeof content === 'string' && content.length === 0) || (Buffer.isBuffer(content) && content.length === 0)) {
|
||||
throw new Error('content requis.');
|
||||
}
|
||||
|
||||
const exportDir = ensurePlanExportDir(orderId);
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const fileName = `${stamp}-${safeType}.${safeExt}`;
|
||||
const absolutePath = path.join(exportDir, fileName);
|
||||
const relativePath = path.join('plan-exports', `order-${orderId}`, fileName).replace(/\\/g, '/');
|
||||
if (Buffer.isBuffer(content)) {
|
||||
fs.writeFileSync(absolutePath, content);
|
||||
} else {
|
||||
fs.writeFileSync(absolutePath, content, 'utf8');
|
||||
}
|
||||
|
||||
await run(
|
||||
`INSERT INTO plan_exports (order_id, export_type, file_name, file_path, mime_type)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[orderId, safeType, fileName, relativePath, mimeType || 'text/plain']
|
||||
);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, order_id, export_type, file_name, mime_type, created_at
|
||||
FROM plan_exports
|
||||
WHERE order_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT 1`,
|
||||
[orderId]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function getOrders(req, res) {
|
||||
try {
|
||||
@@ -14,14 +69,280 @@ export async function getOrders(req, res) {
|
||||
export async function postOrder(req, res) {
|
||||
try {
|
||||
const { config, totalChf, email, name, phone, notes } = req.body || {};
|
||||
const settingsRows = await pool.query('SELECT key, value FROM settings');
|
||||
const settings = Object.fromEntries((settingsRows.rows || []).map((r) => [r.key, r.value]));
|
||||
const validation = validateConfig(config || {}, settings);
|
||||
if (validation.errors.length > 0) {
|
||||
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
|
||||
}
|
||||
await run(
|
||||
`INSERT INTO orders (config, total_chf, email, name, phone, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[JSON.stringify(config || {}), totalChf ?? null, email ?? null, name ?? null, phone ?? null, notes ?? null]
|
||||
);
|
||||
const { rows } = await pool.query('SELECT id, created_at FROM orders ORDER BY id DESC LIMIT 1');
|
||||
res.status(201).json(rows[0]);
|
||||
const order = rows[0];
|
||||
|
||||
// Fire-and-forget: send supplier vouchers without blocking the HTTP response
|
||||
sendSupplierVouchers(order.id, config || {}, name ?? '', order.created_at).catch(err => {
|
||||
console.error('[supplier-voucher] Email error:', err.message);
|
||||
});
|
||||
|
||||
res.status(201).json(order);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrderSupplierVouchers(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, config, name, created_at FROM orders WHERE id = $1 LIMIT 1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Commande introuvable.' });
|
||||
}
|
||||
|
||||
const order = rows[0];
|
||||
let config = {};
|
||||
try {
|
||||
config = typeof order.config === 'string' ? JSON.parse(order.config) : (order.config || {});
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
|
||||
const suppliers = await buildSupplierVoucherPayload(config);
|
||||
return res.json({
|
||||
orderId: order.id,
|
||||
customerName: order.name || null,
|
||||
createdAt: order.created_at,
|
||||
suppliers
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function listOrderPlanExports(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { rows: orderRows } = await pool.query('SELECT id FROM orders WHERE id = $1 LIMIT 1', [id]);
|
||||
if (orderRows.length === 0) {
|
||||
return res.status(404).json({ error: 'Commande introuvable.' });
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, order_id, export_type, file_name, mime_type, created_at
|
||||
FROM plan_exports
|
||||
WHERE order_id = $1
|
||||
ORDER BY created_at DESC, id DESC`,
|
||||
[id]
|
||||
);
|
||||
return res.json(rows);
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOrderPlanExport(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { exportType, content, mimeType, fileExtension } = req.body || {};
|
||||
|
||||
if (!exportType || typeof exportType !== 'string') {
|
||||
return res.status(400).json({ error: 'exportType requis.' });
|
||||
}
|
||||
if (typeof content !== 'string' || content.length === 0) {
|
||||
return res.status(400).json({ error: 'content requis.' });
|
||||
}
|
||||
|
||||
const { rows: orderRows } = await pool.query('SELECT id FROM orders WHERE id = $1 LIMIT 1', [id]);
|
||||
if (orderRows.length === 0) {
|
||||
return res.status(404).json({ error: 'Commande introuvable.' });
|
||||
}
|
||||
|
||||
const created = await saveOrderPlanExportRecord(id, exportType, content, mimeType, fileExtension);
|
||||
return res.status(201).json(created);
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateOrderPlanExports(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, config FROM orders WHERE id = $1 LIMIT 1',
|
||||
[id]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Commande introuvable.' });
|
||||
}
|
||||
|
||||
let config = {};
|
||||
try {
|
||||
config = typeof rows[0].config === 'string' ? JSON.parse(rows[0].config) : (rows[0].config || {});
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
if (!config || typeof config !== 'object' || !config.widthMm || !config.heightMm || !config.depthMm) {
|
||||
return res.status(400).json({ error: 'Configuration commande invalide pour générer les plans.' });
|
||||
}
|
||||
{
|
||||
const settingsRows = await pool.query('SELECT key, value FROM settings');
|
||||
const settings = Object.fromEntries((settingsRows.rows || []).map((r) => [r.key, r.value]));
|
||||
const validation = validateConfig(config, settings);
|
||||
if (validation.errors.length > 0) {
|
||||
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
|
||||
}
|
||||
}
|
||||
|
||||
const modules = Array.isArray(config.modules) ? config.modules : [];
|
||||
const colors = (config.colors && typeof config.colors === 'object') ? config.colors : {};
|
||||
const cuttingList = generateCuttingList(config, colors, modules);
|
||||
const packing = generatePackingList(cuttingList);
|
||||
const textContent = formatCuttingListForExport(cuttingList);
|
||||
const pdfBuffer = await generateOrderDossierPdf({
|
||||
orderId: id,
|
||||
config: {
|
||||
widthMm: config.widthMm,
|
||||
heightMm: config.heightMm,
|
||||
depthMm: config.depthMm,
|
||||
installationType: config.installationType,
|
||||
doorType: config.doorType,
|
||||
moduleCount: modules.length
|
||||
},
|
||||
cuttingList,
|
||||
packing
|
||||
});
|
||||
|
||||
const [textExport, pdfExport] = await Promise.all([
|
||||
saveOrderPlanExportRecord(id, 'cutting_list_text', textContent, 'text/plain; charset=utf-8', 'txt'),
|
||||
saveOrderPlanExportRecord(id, 'production_dossier_pdf', pdfBuffer, 'application/pdf', 'pdf')
|
||||
]);
|
||||
|
||||
return res.status(201).json({ created: [textExport, pdfExport] });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrderProductionDetails(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, config, total_chf, name, created_at FROM orders WHERE id = $1 LIMIT 1',
|
||||
[id]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Commande introuvable.' });
|
||||
}
|
||||
|
||||
let config = {};
|
||||
try {
|
||||
config = typeof rows[0].config === 'string' ? JSON.parse(rows[0].config) : (rows[0].config || {});
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
if (!config || typeof config !== 'object' || !config.widthMm || !config.heightMm || !config.depthMm) {
|
||||
return res.status(400).json({ error: 'Configuration commande invalide pour générer le dossier.' });
|
||||
}
|
||||
{
|
||||
const settingsRows = await pool.query('SELECT key, value FROM settings');
|
||||
const settings = Object.fromEntries((settingsRows.rows || []).map((r) => [r.key, r.value]));
|
||||
const validation = validateConfig(config, settings);
|
||||
if (validation.errors.length > 0) {
|
||||
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
|
||||
}
|
||||
}
|
||||
|
||||
const modules = Array.isArray(config.modules) ? config.modules : [];
|
||||
const colors = (config.colors && typeof config.colors === 'object') ? config.colors : {};
|
||||
const plans = generateAllPlans(config, modules, colors);
|
||||
const cuttingList = generateCuttingList(config, colors, modules);
|
||||
const packing = generatePackingList(cuttingList);
|
||||
|
||||
return res.json({
|
||||
orderId: rows[0].id,
|
||||
customerName: rows[0].name || null,
|
||||
totalChf: rows[0].total_chf,
|
||||
createdAt: rows[0].created_at,
|
||||
config: {
|
||||
widthMm: config.widthMm,
|
||||
heightMm: config.heightMm,
|
||||
depthMm: config.depthMm,
|
||||
plinthHeightMm: config.plinthHeightMm,
|
||||
installationType: config.installationType,
|
||||
doorType: config.doorType,
|
||||
slidingPanelsCount: config.slidingPanelsCount,
|
||||
moduleCount: modules.length
|
||||
},
|
||||
plans,
|
||||
cuttingList,
|
||||
packing
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadOrderPlanExport(req, res) {
|
||||
try {
|
||||
const { id, exportId } = req.params;
|
||||
const disposition = String(req.query.disposition || 'attachment').toLowerCase();
|
||||
const headerDisposition = disposition === 'inline' ? 'inline' : 'attachment';
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, order_id, file_name, file_path, mime_type
|
||||
FROM plan_exports
|
||||
WHERE id = $1 AND order_id = $2
|
||||
LIMIT 1`,
|
||||
[exportId, id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Export introuvable.' });
|
||||
}
|
||||
|
||||
const record = rows[0];
|
||||
const absolutePath = path.join(__dirname, '..', 'data', record.file_path);
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
return res.status(404).json({ error: 'Fichier export introuvable.' });
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', record.mime_type || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `${headerDisposition}; filename="${record.file_name}"`);
|
||||
return res.sendFile(absolutePath);
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteOrderPlanExport(req, res) {
|
||||
try {
|
||||
const { id, exportId } = req.params;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, order_id, file_path
|
||||
FROM plan_exports
|
||||
WHERE id = $1 AND order_id = $2
|
||||
LIMIT 1`,
|
||||
[exportId, id]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Export introuvable.' });
|
||||
}
|
||||
|
||||
const record = rows[0];
|
||||
const absolutePath = path.join(__dirname, '..', 'data', record.file_path);
|
||||
if (fs.existsSync(absolutePath)) {
|
||||
fs.unlinkSync(absolutePath);
|
||||
}
|
||||
|
||||
await run('DELETE FROM plan_exports WHERE id = $1 AND order_id = $2', [exportId, id]);
|
||||
return res.status(204).end();
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
+176
-6
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* Plans and cutting list routes
|
||||
* Plans, cutting list, and packing routes
|
||||
*/
|
||||
import { generateAllPlans, generatePDFHtml } from '../services/planGenerator.js';
|
||||
import { generateCuttingList, formatCuttingListForExport } from '../services/cuttingEngine.js';
|
||||
import { generatePackingList } from '../services/packingEngine.js';
|
||||
import { generateCncProgram } from '../services/cncGenerator.js';
|
||||
import { validateConfig } from '../services/configValidation.js';
|
||||
import { generateHardwareList } from '../services/hardwareListEngine.js';
|
||||
import { generateMprZipBuffer, generateMprPieceDownload } from '../services/mprGenerator.js';
|
||||
|
||||
/**
|
||||
* POST /api/plans/generate
|
||||
@@ -15,9 +20,13 @@ export async function generatePlansRoute(req, res) {
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
const validation = validateConfig(config);
|
||||
if (validation.errors.length > 0) {
|
||||
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
|
||||
}
|
||||
|
||||
const plans = generateAllPlans(config, modules, colors);
|
||||
res.json({ success: true, plans });
|
||||
res.json({ success: true, plans, warnings: validation.warnings });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Plan generation error:', error);
|
||||
@@ -27,7 +36,7 @@ export async function generatePlansRoute(req, res) {
|
||||
|
||||
/**
|
||||
* POST /api/plans/cutting-list
|
||||
* Generate cutting list for a configuration
|
||||
* Generate cutting list + packing list for a configuration
|
||||
*/
|
||||
export async function generateCuttingListRoute(req, res) {
|
||||
try {
|
||||
@@ -36,9 +45,15 @@ export async function generateCuttingListRoute(req, res) {
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
const validation = validateConfig(config);
|
||||
if (validation.errors.length > 0) {
|
||||
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
|
||||
}
|
||||
|
||||
const cuttingList = generateCuttingList(config, colors, modules);
|
||||
res.json({ success: true, cuttingList });
|
||||
const packing = generatePackingList(cuttingList);
|
||||
|
||||
res.json({ success: true, cuttingList, packing, warnings: validation.warnings });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Cutting list error:', error);
|
||||
@@ -57,6 +72,10 @@ export async function exportCuttingListText(req, res) {
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
const validation = validateConfig(config);
|
||||
if (validation.errors.length > 0) {
|
||||
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
|
||||
}
|
||||
|
||||
const cuttingList = generateCuttingList(config, colors, modules);
|
||||
const textContent = formatCuttingListForExport(cuttingList);
|
||||
@@ -73,7 +92,7 @@ export async function exportCuttingListText(req, res) {
|
||||
|
||||
/**
|
||||
* POST /api/plans/export-pdf
|
||||
* Export plans and cutting list as HTML (for PDF conversion)
|
||||
* Export plans, cutting list, and packing as HTML (for PDF conversion)
|
||||
*/
|
||||
export async function exportPDFHtml(req, res) {
|
||||
try {
|
||||
@@ -82,9 +101,14 @@ export async function exportPDFHtml(req, res) {
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
const validation = validateConfig(config);
|
||||
if (validation.errors.length > 0) {
|
||||
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
|
||||
}
|
||||
|
||||
const cuttingList = generateCuttingList(config, colors, modules);
|
||||
const html = generatePDFHtml(config, modules, cuttingList);
|
||||
const packing = generatePackingList(cuttingList);
|
||||
const html = generatePDFHtml(config, modules, cuttingList, packing);
|
||||
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(html);
|
||||
@@ -94,3 +118,149 @@ export async function exportPDFHtml(req, res) {
|
||||
res.status(500).json({ error: error.message || 'Erreur d\'export PDF' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/plans/export-cnc
|
||||
* Export CNC program as standard ISO G-code (.nc)
|
||||
*/
|
||||
export async function exportCncRoute(req, res) {
|
||||
try {
|
||||
const { config, modules = [], colors = {} } = req.body;
|
||||
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
const validation = validateConfig(config);
|
||||
if (validation.errors.length > 0) {
|
||||
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
|
||||
}
|
||||
|
||||
const cnc = generateCncProgram(config, modules, colors);
|
||||
res.setHeader('Content-Type', cnc.mimeType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${cnc.fileName}"`);
|
||||
res.send(cnc.content);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Export CNC error:', error);
|
||||
res.status(500).json({ error: error.message || 'Erreur d\'export CNC' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/plans/export-mpr
|
||||
* Archive ZIP avec un fichier .mpr Homag/Woodwop par panneau.
|
||||
*/
|
||||
export async function exportMprRoute(req, res) {
|
||||
try {
|
||||
const { config, modules = [], colors = {}, orderReference = '', stackListKm = '', mprMaterialArticle = '', mprGroupIndex = 1 } = req.body || {};
|
||||
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
const validation = validateConfig(config);
|
||||
if (validation.errors.length > 0) {
|
||||
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
|
||||
}
|
||||
|
||||
const gi = Number(mprGroupIndex);
|
||||
const groupIndex = Number.isFinite(gi) && gi >= 1 ? Math.floor(gi) : 1;
|
||||
|
||||
const { buffer, fileName, mimeType } = await generateMprZipBuffer({
|
||||
config,
|
||||
modules,
|
||||
colors,
|
||||
orderReference,
|
||||
stackListKm: typeof stackListKm === 'string' ? stackListKm : String(stackListKm || '01'),
|
||||
defaultMaterialArticle: typeof mprMaterialArticle === 'string' ? mprMaterialArticle : '',
|
||||
groupIndex
|
||||
});
|
||||
|
||||
const safeName =
|
||||
/^[\w\-_.]+$/.test(String(fileName)) ? fileName : 'apex-mpr.zip';
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
|
||||
res.send(Buffer.from(buffer));
|
||||
} catch (error) {
|
||||
console.error('Export MPR error:', error);
|
||||
res.status(500).json({ error: error.message || 'Erreur d\'export MPR' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/plans/export-mpr-piece
|
||||
* Export MPR pour une ligne de la liste de débitage (fichier unique ou mini-ZIP si plusieurs exemplaires).
|
||||
*/
|
||||
export async function exportMprPieceRoute(req, res) {
|
||||
try {
|
||||
const {
|
||||
config,
|
||||
modules = [],
|
||||
colors = {},
|
||||
pieceId,
|
||||
instanceIndex,
|
||||
orderReference = '',
|
||||
stackListKm = '',
|
||||
mprMaterialArticle = '',
|
||||
mprGroupIndex = 1
|
||||
} = req.body || {};
|
||||
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
if (!pieceId || typeof pieceId !== 'string') {
|
||||
return res.status(400).json({ error: 'pieceId requis' });
|
||||
}
|
||||
const validation = validateConfig(config);
|
||||
if (validation.errors.length > 0) {
|
||||
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
|
||||
}
|
||||
|
||||
const gi = Number(mprGroupIndex);
|
||||
const groupIndex = Number.isFinite(gi) && gi >= 1 ? Math.floor(gi) : 1;
|
||||
|
||||
const { buffer, fileName, mimeType } = await generateMprPieceDownload({
|
||||
config,
|
||||
modules,
|
||||
colors,
|
||||
pieceId,
|
||||
instanceIndex,
|
||||
orderReference,
|
||||
stackListKm: typeof stackListKm === 'string' ? stackListKm : String(stackListKm || '01'),
|
||||
defaultMaterialArticle: typeof mprMaterialArticle === 'string' ? mprMaterialArticle : '',
|
||||
groupIndex
|
||||
});
|
||||
|
||||
const safeName = /^[\w\-_.]+$/.test(String(fileName)) ? fileName : 'piece.mpr';
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
|
||||
res.send(Buffer.from(buffer));
|
||||
} catch (error) {
|
||||
console.error('Export MPR piece error:', error);
|
||||
const status = /introuvable|requis|invalide/i.test(error.message || '') ? 400 : 500;
|
||||
res.status(status).json({ error: error.message || 'Erreur d\'export MPR pièce' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/plans/hardware-list
|
||||
* Generate bill of materials for hardware/quincaillerie with prices (best-effort).
|
||||
*/
|
||||
export async function generateHardwareListRoute(req, res) {
|
||||
try {
|
||||
const { config, modules = [], colors = {} } = req.body;
|
||||
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
const validation = validateConfig(config);
|
||||
if (validation.errors.length > 0) {
|
||||
return res.status(400).json({ error: 'Configuration invalide', details: validation.errors });
|
||||
}
|
||||
|
||||
const result = await generateHardwareList({ config, modules, colors });
|
||||
res.json({ success: true, ...result, warnings: validation.warnings });
|
||||
} catch (error) {
|
||||
console.error('Hardware list error:', error);
|
||||
res.status(500).json({ error: error.message || 'Erreur de génération de la liste de quincaillerie' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,41 @@
|
||||
import pool from '../db/index.js';
|
||||
import { run } from '../db/index.js';
|
||||
|
||||
function parseSettingValue(raw) {
|
||||
if (typeof raw !== 'string') return raw;
|
||||
if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
|
||||
if (raw === 'true') return true;
|
||||
if (raw === 'false') return false;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function getSettings(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT key, value FROM settings');
|
||||
const obj = {};
|
||||
for (const r of rows) {
|
||||
let v = r.value;
|
||||
if (typeof v === 'string' && /^\d+$/.test(v)) v = Number(v);
|
||||
obj[r.key] = v;
|
||||
obj[r.key] = parseSettingValue(r.value);
|
||||
}
|
||||
res.json(obj);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSettings(req, res) {
|
||||
try {
|
||||
const updates = req.body || {};
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
await run(
|
||||
'INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
||||
[key, String(value)]
|
||||
);
|
||||
}
|
||||
// Return updated settings
|
||||
const { rows } = await pool.query('SELECT key, value FROM settings');
|
||||
const obj = {};
|
||||
for (const r of rows) {
|
||||
obj[r.key] = parseSettingValue(r.value);
|
||||
}
|
||||
res.json(obj);
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import pool, { run } from '../db/index.js';
|
||||
|
||||
export async function getSuppliers(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, code, name, email, type, active, created_at FROM suppliers ORDER BY name'
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSupplier(req, res) {
|
||||
try {
|
||||
const { code, name, email, type, active = 1 } = req.body || {};
|
||||
if (!code || !name || !email || !type) {
|
||||
return res.status(400).json({ error: 'code, name, email et type sont requis.' });
|
||||
}
|
||||
await run(
|
||||
'INSERT INTO suppliers (code, name, email, type, active) VALUES ($1, $2, $3, $4, $5)',
|
||||
[code, name, email, type, active]
|
||||
);
|
||||
const { rows } = await pool.query('SELECT * FROM suppliers ORDER BY id DESC LIMIT 1');
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSupplier(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, email, type, active } = req.body || {};
|
||||
await run(
|
||||
'UPDATE suppliers SET name = $1, email = $2, type = $3, active = $4 WHERE id = $5',
|
||||
[name, email, type, active, id]
|
||||
);
|
||||
const { rows } = await pool.query('SELECT * FROM suppliers WHERE id = $1', [id]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Fournisseur introuvable.' });
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSupplier(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await run('DELETE FROM suppliers WHERE id = $1', [id]);
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
+194
-30
@@ -1,7 +1,5 @@
|
||||
/**
|
||||
* Sync routes for Formex crawler
|
||||
*/
|
||||
import { sync, getSyncLogs, getMaterials } from '../services/formexCrawler.js';
|
||||
import { syncHaefeleByUrls } from '../services/haefeleCrawler.js';
|
||||
import { getSchedulerStatus } from '../services/scheduler.js';
|
||||
import pool, { run } from '../db/index.js';
|
||||
|
||||
@@ -58,9 +56,10 @@ export async function streamFormexSync(req, res) {
|
||||
// Client disconnected, cleanup if needed
|
||||
});
|
||||
|
||||
const force = req.query.force === '1' || req.query.force === 'true';
|
||||
|
||||
try {
|
||||
// Run sync with emitter
|
||||
await sync(emit);
|
||||
await sync(emit, { force });
|
||||
} catch (err) {
|
||||
emit({ type: 'error', error: err.message });
|
||||
}
|
||||
@@ -69,6 +68,144 @@ export async function streamFormexSync(req, res) {
|
||||
res.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/sync/haefele
|
||||
* Trigger Häfele sync for a provided list of product URLs
|
||||
* Body: { urls: string[] }
|
||||
*/
|
||||
export async function triggerHaefeleSync(req, res) {
|
||||
try {
|
||||
const urls = Array.isArray(req.body?.urls) ? req.body.urls : [];
|
||||
const credentials = {
|
||||
username: req.body?.username,
|
||||
password: req.body?.password,
|
||||
customerNo: req.body?.customerNo
|
||||
};
|
||||
if (urls.length === 0) {
|
||||
return res.status(400).json({ error: 'Le champ urls (tableau) est requis.' });
|
||||
}
|
||||
|
||||
const result = await syncHaefeleByUrls(urls, () => {}, { credentials });
|
||||
if (!result.success) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: result.error || 'Sync Häfele échouée',
|
||||
itemsCount: result.itemsCount || 0
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Synchronisation Häfele terminée: ${result.itemsCount} produits`,
|
||||
itemsCount: result.itemsCount,
|
||||
failedCount: result.failedCount || 0,
|
||||
duration: result.duration
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sync/haefele/stream
|
||||
* SSE endpoint for Häfele sync progress (URL list from query params)
|
||||
* Query:
|
||||
* - url=... (repeatable)
|
||||
* - urls=... (newline/comma/semicolon separated fallback)
|
||||
*/
|
||||
export async function streamHaefeleSync(req, res) {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders();
|
||||
|
||||
const emit = (data) => {
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
req.on('close', () => {
|
||||
// client disconnected
|
||||
});
|
||||
|
||||
const repeatable = req.query.url;
|
||||
let urls = [];
|
||||
if (Array.isArray(repeatable)) {
|
||||
urls = repeatable;
|
||||
} else if (typeof repeatable === 'string' && repeatable.trim()) {
|
||||
urls = [repeatable];
|
||||
} else if (typeof req.query.urls === 'string') {
|
||||
urls = req.query.urls.split(/[\n,;]+/g);
|
||||
}
|
||||
|
||||
try {
|
||||
await syncHaefeleByUrls(urls, emit);
|
||||
} catch (err) {
|
||||
emit({ type: 'error', error: err.message });
|
||||
}
|
||||
|
||||
res.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/haefele/materials
|
||||
* List Häfele materials that were fetched/synced.
|
||||
*/
|
||||
export async function getHaefeleMaterials(req, res) {
|
||||
try {
|
||||
const { search = '', active, page = '1', limit = '25' } = req.query;
|
||||
const pageNum = Math.max(1, parseInt(page, 10));
|
||||
const limitNum = Math.min(200, Math.max(1, parseInt(limit, 10)));
|
||||
const offset = (pageNum - 1) * limitNum;
|
||||
|
||||
const whereClauses = [
|
||||
`(supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele') OR collection = 'Hafele' OR formex_code LIKE 'HAF-%')`
|
||||
];
|
||||
const params = [];
|
||||
|
||||
if (search) {
|
||||
whereClauses.push('(name LIKE ? OR formex_code LIKE ? OR formex_url LIKE ?)');
|
||||
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
||||
}
|
||||
if (active === '1' || active === 'true') {
|
||||
whereClauses.push('active = 1');
|
||||
} else if (active === '0' || active === 'false') {
|
||||
whereClauses.push('active = 0');
|
||||
}
|
||||
|
||||
const whereSQL = `WHERE ${whereClauses.join(' AND ')}`;
|
||||
const countResult = await pool.query(
|
||||
`SELECT COUNT(*) as total FROM formex_materials ${whereSQL}`,
|
||||
params
|
||||
);
|
||||
const total = countResult.rows[0]?.total || 0;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, formex_code, name, category, thickness_mm, price_chf, price_unit, image_url, formex_url, active, last_synced_at
|
||||
FROM formex_materials
|
||||
${whereSQL}
|
||||
ORDER BY last_synced_at DESC, id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, limitNum, offset]
|
||||
);
|
||||
|
||||
res.json({
|
||||
materials: rows,
|
||||
pagination: {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limitNum)
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sync/logs
|
||||
* Get sync history
|
||||
@@ -98,47 +235,50 @@ export function getStatus(req, res) {
|
||||
*/
|
||||
export async function getFormexMaterials(req, res) {
|
||||
try {
|
||||
const { search, collection, category, active, page = '1', limit = '25' } = req.query;
|
||||
const { search, collection, category, active, supplier, page = '1', limit = '25' } = req.query;
|
||||
const pageNum = Math.max(1, parseInt(page, 10));
|
||||
const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10)));
|
||||
const offset = (pageNum - 1) * limitNum;
|
||||
|
||||
// Build query with optional filters
|
||||
let whereClauses = [];
|
||||
let params = [];
|
||||
|
||||
if (search) {
|
||||
whereClauses.push('(name LIKE ? OR formex_code LIKE ?)');
|
||||
whereClauses.push('(m.name LIKE ? OR m.formex_code LIKE ?)');
|
||||
params.push(`%${search}%`, `%${search}%`);
|
||||
}
|
||||
|
||||
if (collection) {
|
||||
whereClauses.push('collection = ?');
|
||||
whereClauses.push('m.collection = ?');
|
||||
params.push(collection);
|
||||
}
|
||||
|
||||
if (category) {
|
||||
whereClauses.push('category = ?');
|
||||
whereClauses.push('m.category = ?');
|
||||
params.push(category);
|
||||
}
|
||||
|
||||
// For configurator: only active materials
|
||||
if (active === '1' || active === 'true') {
|
||||
whereClauses.push('active = 1');
|
||||
whereClauses.push('m.active = 1');
|
||||
}
|
||||
|
||||
if (supplier) {
|
||||
whereClauses.push('s.code = ?');
|
||||
params.push(supplier);
|
||||
}
|
||||
|
||||
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
|
||||
|
||||
// Get total count
|
||||
const countResult = await pool.query(
|
||||
`SELECT COUNT(*) as total FROM formex_materials ${whereSQL}`,
|
||||
`SELECT COUNT(*) as total FROM formex_materials m LEFT JOIN suppliers s ON m.supplier_id = s.id ${whereSQL}`,
|
||||
params
|
||||
);
|
||||
const total = countResult.rows[0]?.total || 0;
|
||||
|
||||
// Get paginated results
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM formex_materials ${whereSQL} ORDER BY collection, name LIMIT ? OFFSET ?`,
|
||||
`SELECT m.*, s.code AS supplier_code, s.name AS supplier_name
|
||||
FROM formex_materials m LEFT JOIN suppliers s ON m.supplier_id = s.id
|
||||
${whereSQL} ORDER BY m.collection, m.name LIMIT ? OFFSET ?`,
|
||||
[...params, limitNum, offset]
|
||||
);
|
||||
|
||||
@@ -202,14 +342,14 @@ export async function getFormexMaterial(req, res) {
|
||||
*/
|
||||
export async function createFormexMaterial(req, res) {
|
||||
try {
|
||||
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active } = req.body;
|
||||
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
|
||||
diameter_mm, length_mm, bore_diameter_mm, bore_depth_base_mm, bore_depth_piece_mm,
|
||||
image_url, formex_url, price_chf, price_unit, active } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!formex_code || !name || !collection) {
|
||||
return res.status(400).json({ error: 'formex_code, name et collection sont requis' });
|
||||
}
|
||||
|
||||
// Check for duplicate code
|
||||
const existing = await pool.query(
|
||||
'SELECT id FROM formex_materials WHERE formex_code = ?',
|
||||
[formex_code]
|
||||
@@ -219,13 +359,17 @@ export async function createFormexMaterial(req, res) {
|
||||
}
|
||||
|
||||
await run(
|
||||
`INSERT INTO formex_materials (formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO formex_materials (formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
|
||||
diameter_mm, length_mm, bore_diameter_mm, bore_depth_base_mm, bore_depth_piece_mm,
|
||||
image_url, formex_url, price_chf, price_unit, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[formex_code, name, collection, category || null, width_mm || null, height_mm || null, thickness_mm || null,
|
||||
image_url || null, formex_url || null, price_chf || null, active !== undefined ? (active ? 1 : 0) : 1]
|
||||
diameter_mm ?? null, length_mm ?? null,
|
||||
bore_diameter_mm ?? null, bore_depth_base_mm ?? null, bore_depth_piece_mm ?? null,
|
||||
image_url || null, formex_url || null, price_chf ?? null, price_unit || null,
|
||||
active !== undefined ? (active ? 1 : 0) : 1]
|
||||
);
|
||||
|
||||
// Get the created material
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM formex_materials WHERE formex_code = ?',
|
||||
[formex_code]
|
||||
@@ -244,9 +388,10 @@ export async function createFormexMaterial(req, res) {
|
||||
export async function updateFormexMaterial(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active } = req.body;
|
||||
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
|
||||
diameter_mm, length_mm, bore_diameter_mm, bore_depth_base_mm, bore_depth_piece_mm,
|
||||
image_url, formex_url, price_chf, price_unit, active } = req.body;
|
||||
|
||||
// Check if material exists
|
||||
const existing = await pool.query(
|
||||
'SELECT * FROM formex_materials WHERE id = ?',
|
||||
[id]
|
||||
@@ -255,12 +400,10 @@ export async function updateFormexMaterial(req, res) {
|
||||
return res.status(404).json({ error: 'Matériau non trouvé' });
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (!formex_code || !name || !collection) {
|
||||
return res.status(400).json({ error: 'formex_code, name et collection sont requis' });
|
||||
}
|
||||
|
||||
// Check for duplicate code (if changed)
|
||||
if (formex_code !== existing.rows[0].formex_code) {
|
||||
const duplicate = await pool.query(
|
||||
'SELECT id FROM formex_materials WHERE formex_code = ? AND id != ?',
|
||||
@@ -274,13 +417,17 @@ export async function updateFormexMaterial(req, res) {
|
||||
await run(
|
||||
`UPDATE formex_materials
|
||||
SET formex_code = ?, name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
|
||||
image_url = ?, formex_url = ?, price_chf = ?, active = ?
|
||||
diameter_mm = ?, length_mm = ?,
|
||||
bore_diameter_mm = ?, bore_depth_base_mm = ?, bore_depth_piece_mm = ?,
|
||||
image_url = ?, formex_url = ?, price_chf = ?, price_unit = ?, active = ?
|
||||
WHERE id = ?`,
|
||||
[formex_code, name, collection, category || null, width_mm || null, height_mm || null, thickness_mm || null,
|
||||
image_url || null, formex_url || null, price_chf || null, active !== undefined ? (active ? 1 : 0) : 1, id]
|
||||
diameter_mm ?? null, length_mm ?? null,
|
||||
bore_diameter_mm ?? null, bore_depth_base_mm ?? null, bore_depth_piece_mm ?? null,
|
||||
image_url || null, formex_url || null, price_chf ?? null, price_unit || null,
|
||||
active !== undefined ? (active ? 1 : 0) : 1, id]
|
||||
);
|
||||
|
||||
// Get the updated material
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM formex_materials WHERE id = ?',
|
||||
[id]
|
||||
@@ -317,6 +464,23 @@ export async function deleteFormexMaterial(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/formex/suppliers
|
||||
* Get suppliers that have at least one material linked
|
||||
*/
|
||||
export async function getFormexSuppliers(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DISTINCT s.code, s.name FROM suppliers s
|
||||
INNER JOIN formex_materials m ON m.supplier_id = s.id
|
||||
ORDER BY s.name`
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/formex/collections
|
||||
* Get distinct collection names for filter dropdown
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { query } from '../db/index.js';
|
||||
|
||||
async function main() {
|
||||
const tables = (await query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")).rows.map(
|
||||
(r) => r.name
|
||||
);
|
||||
console.log('tables', tables);
|
||||
|
||||
const counts = ['formex_materials', 'formex_variants', 'formex_dimensions', 'finishes', 'products', 'suppliers'];
|
||||
for (const t of counts) {
|
||||
try {
|
||||
const r = await query(`SELECT COUNT(*) as c FROM ${t}`);
|
||||
console.log(t, r.rows[0]?.c);
|
||||
} catch {
|
||||
console.log(t, '(missing)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { query } from '../db/index.js';
|
||||
|
||||
async function main() {
|
||||
const countSql = `
|
||||
SELECT COUNT(*) as c
|
||||
FROM formex_materials
|
||||
WHERE (
|
||||
supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele')
|
||||
OR collection = 'Hafele'
|
||||
OR formex_code LIKE 'HAF-%'
|
||||
)
|
||||
`;
|
||||
const count = await query(countSql);
|
||||
console.log('haefele_count', count.rows[0]?.c ?? 0);
|
||||
|
||||
const sampleSql = `
|
||||
SELECT id, formex_code, name, collection, supplier_id, last_synced_at
|
||||
FROM formex_materials
|
||||
WHERE (
|
||||
supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele')
|
||||
OR collection = 'Hafele'
|
||||
OR formex_code LIKE 'HAF-%'
|
||||
)
|
||||
ORDER BY last_synced_at DESC, id DESC
|
||||
LIMIT 10
|
||||
`;
|
||||
const sample = await query(sampleSql);
|
||||
console.log('haefele_sample', sample.rows);
|
||||
|
||||
const syncCols = await query("PRAGMA table_info(sync_logs)");
|
||||
console.log('sync_logs_cols', syncCols.rows.map((c) => c.name));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { generateCuttingList } from './cuttingEngine.js';
|
||||
import { collectHoleSpecsForPiece } from './holeSpecifications.js';
|
||||
import { GROOVE, getBackGroovePlanOrientation } from '../../shared/manufacturing.js';
|
||||
|
||||
function toSafeNumber(value, fallback = 0) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function toLabel(value) {
|
||||
return String(value || '')
|
||||
.replace(/[^a-zA-Z0-9_\-]+/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function emitPanelOutline(lines, widthMm, heightMm, safeZ, cutZ, feed) {
|
||||
lines.push(`G0 Z${safeZ.toFixed(3)}`);
|
||||
lines.push('G0 X0.000 Y0.000');
|
||||
lines.push(`G1 Z${cutZ.toFixed(3)} F${Math.round(feed)}`);
|
||||
lines.push(`G1 X${widthMm.toFixed(3)} Y0.000`);
|
||||
lines.push(`G1 X${widthMm.toFixed(3)} Y${heightMm.toFixed(3)}`);
|
||||
lines.push(`G1 X0.000 Y${heightMm.toFixed(3)}`);
|
||||
lines.push('G1 X0.000 Y0.000');
|
||||
lines.push(`G0 Z${safeZ.toFixed(3)}`);
|
||||
}
|
||||
|
||||
function emitHole(lines, { xMm, yMm, diameterMm = 5, depthMm = 10, type = 'generic', face = 'face', end = null }) {
|
||||
let tail = `TYPE=${String(type)} FACE=${face}`;
|
||||
if (end) tail += ` END=${end}`;
|
||||
lines.push(
|
||||
`; HOLE X=${Number(xMm).toFixed(3)} Y=${Number(yMm).toFixed(3)} D=${Number(diameterMm).toFixed(3)} Z=${Number(depthMm).toFixed(3)} ${tail}`
|
||||
);
|
||||
}
|
||||
|
||||
function emitPieceHoleComments(lines, piece, widthMm, heightMm, partIndex = 0) {
|
||||
const { specs, thicknessMm: _tm } = collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex);
|
||||
for (const spec of specs) {
|
||||
emitHole(lines, spec);
|
||||
}
|
||||
|
||||
const m = piece?.machining || {};
|
||||
const orient = piece?.elementType ? getBackGroovePlanOrientation(piece.elementType) : null;
|
||||
if (m.groove && orient) {
|
||||
const gw = Number(m.groove.width ?? GROOVE.back.width);
|
||||
const gd = Number(m.groove.depth ?? GROOVE.back.depth);
|
||||
const ax = Number(m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge);
|
||||
lines.push(`; GROOVE BACK W=${gw.toFixed(3)} D=${gd.toFixed(3)} AXIS=${ax.toFixed(3)} ORIENT=${orient}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function generateCncProgram(config, modules = [], colors = {}) {
|
||||
const cuttingList = generateCuttingList(config, colors, modules);
|
||||
|
||||
const safeZ = 10;
|
||||
const cutDepth = 3;
|
||||
const feedCut = 1800;
|
||||
|
||||
const lines = [
|
||||
'%',
|
||||
'; APEX CNC ISO (G-code) - v1',
|
||||
`; Generated: ${new Date().toISOString()}`,
|
||||
`; Config: ${config.widthMm}x${config.heightMm}x${config.depthMm} mm`,
|
||||
'G21 ; metric units',
|
||||
'G90 ; absolute coordinates',
|
||||
'G17 ; XY plane',
|
||||
`G0 Z${safeZ.toFixed(3)}`
|
||||
];
|
||||
|
||||
let programPartCount = 0;
|
||||
|
||||
for (const piece of cuttingList.pieces || []) {
|
||||
const widthMm = toSafeNumber(piece.rawCutWidthMm, piece.widthMm);
|
||||
const heightMm = toSafeNumber(piece.rawCutHeightMm, piece.heightMm);
|
||||
const qty = Math.max(1, Math.round(toSafeNumber(piece.quantity, 1)));
|
||||
const thickness = toSafeNumber(piece.thicknessMm, 19);
|
||||
const pieceName = toLabel(piece.description || piece.elementType || 'PIECE');
|
||||
const targetDepth = -Math.min(cutDepth, Math.max(1, thickness * 0.25));
|
||||
|
||||
for (let i = 0; i < qty; i++) {
|
||||
programPartCount += 1;
|
||||
lines.push('');
|
||||
lines.push(`; --- PART ${programPartCount}: ${pieceName} #${i + 1} ---`);
|
||||
lines.push(`; SIZE_RAW: X=${widthMm.toFixed(3)} Y=${heightMm.toFixed(3)} T=${thickness.toFixed(3)}`);
|
||||
emitPieceHoleComments(lines, piece, widthMm, heightMm, i);
|
||||
emitPanelOutline(lines, widthMm, heightMm, safeZ, targetDepth, feedCut);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(`G0 Z${safeZ.toFixed(3)}`);
|
||||
lines.push('G0 X0.000 Y0.000');
|
||||
lines.push('M30');
|
||||
lines.push('%');
|
||||
|
||||
return {
|
||||
fileName: 'apex-program.nc',
|
||||
mimeType: 'text/plain; charset=utf-8',
|
||||
content: lines.join('\n'),
|
||||
metadata: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
parts: programPartCount
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
generateCncProgram
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { DRAWERS, NICHE } from '../../shared/manufacturing.js';
|
||||
|
||||
export function validateConfig(config = {}, settings = {}) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
const installationType = config.installationType || 'free';
|
||||
const doorType = config.doorType || 'hinged';
|
||||
const widthMm = Number(config.widthMm || 0);
|
||||
const heightMm = Number(config.heightMm || 0);
|
||||
const depthMm = Number(config.depthMm || 0);
|
||||
const modules = Array.isArray(config.modules) ? config.modules : [];
|
||||
const allowSliding = String(settings.allow_sliding_doors ?? '0') === '1';
|
||||
const sanitaryGapMm = Number(settings.sanitary_gap_mm || NICHE.sanitaryGapAround);
|
||||
|
||||
if (doorType === 'sliding' && !allowSliding) {
|
||||
errors.push({ field: 'doorType', message: 'Portes coulissantes désactivées.' });
|
||||
}
|
||||
|
||||
if (installationType === 'niche') {
|
||||
const nm = config.nicheMeasurements || {};
|
||||
const minW = Math.min(nm.widthTopMm || widthMm, nm.widthMidMm || widthMm, nm.widthBottomMm || widthMm);
|
||||
const minD = Math.min(nm.depthTopMm || depthMm, nm.depthMidMm || depthMm, nm.depthBottomMm || depthMm);
|
||||
const minH = Math.min(nm.heightLeftMm || heightMm, nm.heightRightMm || heightMm);
|
||||
const expectedW = Math.max(400, minW - sanitaryGapMm * 2);
|
||||
const expectedD = Math.max(400, minD - sanitaryGapMm * 2);
|
||||
const expectedH = Math.max(1800, minH - sanitaryGapMm * 2);
|
||||
if (Math.abs(expectedW - widthMm) > 2 || Math.abs(expectedD - depthMm) > 2 || Math.abs(expectedH - heightMm) > 2) {
|
||||
errors.push({ field: 'nicheMeasurements', message: 'Dimensions niche incohérentes avec les mesures mini et vide sanitaire.' });
|
||||
}
|
||||
}
|
||||
|
||||
const drawerDepthMin = Number(settings.drawer_depth_min_mm || DRAWERS.minDepth);
|
||||
const drawerDepthMax = Number(settings.drawer_depth_max_mm || DRAWERS.maxDepth);
|
||||
const drawerDepthStep = Number(settings.drawer_depth_step_mm || DRAWERS.depthStep);
|
||||
|
||||
/** Même logique que `useWardrobeConfig` : pas de 50 mm sur la profondeur utile, puis réserve tiroir caché. */
|
||||
const inferredEffectiveDrawerDepthMm = () => {
|
||||
const rawGap = depthMm - NICHE.drawerBackGap;
|
||||
const clamped = Math.max(drawerDepthMin, Math.min(drawerDepthMax, rawGap));
|
||||
const steppedMm = Math.round(clamped / drawerDepthStep) * drawerDepthStep;
|
||||
const rawDt = config.hardware?.drawerType || 'visible';
|
||||
const depthReductionMm =
|
||||
rawDt === 'hidden' || rawDt === 'hidden_with_handle' ? DRAWERS.hiddenHandleDepthReduction : 0;
|
||||
return Math.max(350, steppedMm - depthReductionMm);
|
||||
};
|
||||
|
||||
for (const [index, m] of modules.entries()) {
|
||||
const isDrawerCol = (m.drawers || 0) > 0 || m.type === 'tiroirs';
|
||||
if (isDrawerCol && Number(m.widthMm || 0) > DRAWERS.maxColumnWidth) {
|
||||
errors.push({ field: `modules[${index}]`, message: `Colonne tiroirs > ${DRAWERS.maxColumnWidth} mm.` });
|
||||
}
|
||||
if (m.hasRod && heightMm < 1200) {
|
||||
errors.push({ field: `modules[${index}]`, message: 'Tringle autorisée à partir de 1200 mm de hauteur armoire.' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Profondeur utile tiroir — alignée sur useWardrobeConfig (pas de 50 sur prof. caisse puis −40 si caché).
|
||||
* Ne pas exiger un multiple de 50 sur la valeur finale : ex. 550 − 40 = 510 mm est valide.
|
||||
*/
|
||||
const canonicalEffectiveDrawerDepthMm = inferredEffectiveDrawerDepthMm();
|
||||
const effectiveDrawerMinMm = 350;
|
||||
if (
|
||||
canonicalEffectiveDrawerDepthMm < effectiveDrawerMinMm ||
|
||||
canonicalEffectiveDrawerDepthMm > drawerDepthMax
|
||||
) {
|
||||
errors.push({
|
||||
field: 'effectiveDrawerDepthMm',
|
||||
message: `Profondeur tiroir utile hors bornes (${effectiveDrawerMinMm}-${drawerDepthMax} mm) pour cette profondeur caisse.`
|
||||
});
|
||||
}
|
||||
|
||||
const explicitEff = Number(config.effectiveDrawerDepthMm);
|
||||
if (
|
||||
Number.isFinite(explicitEff) &&
|
||||
explicitEff > 0 &&
|
||||
Math.abs(explicitEff - canonicalEffectiveDrawerDepthMm) > 2
|
||||
) {
|
||||
warnings.push({
|
||||
field: 'effectiveDrawerDepthMm',
|
||||
message:
|
||||
`Valeur stockée (${explicitEff} mm) différente du calcul attendu (${canonicalEffectiveDrawerDepthMm} mm) — le serveur utilise le calcul canonique.`
|
||||
});
|
||||
}
|
||||
|
||||
const handleAxis = Number(config.hardware?.handleBoreHeightMm || 1000);
|
||||
const stdAxis = Number(settings.standard_handle_axis_mm || 1000);
|
||||
const tolerance = Number(settings.standard_handle_tolerance_mm || 200);
|
||||
if (handleAxis < stdAxis - tolerance || handleAxis > stdAxis + tolerance) {
|
||||
warnings.push({ field: 'handleBoreHeightMm', message: 'Perçage poignée hors plage standard.' });
|
||||
}
|
||||
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
export default { validateConfig };
|
||||
+2698
-67
File diff suppressed because it is too large
Load Diff
@@ -64,40 +64,107 @@ export async function getDesignTip(config) {
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const MATERIAL_REC_PROMPT = `Tu es un conseiller en matériaux et couleurs pour armoires sur mesure suisses haut de gamme.
|
||||
À partir de la configuration actuelle et de la liste de matériaux disponibles, recommande 3 à 5 matériaux qui iraient parfaitement ensemble.
|
||||
À partir de la configuration actuelle, des PRIORITÉS UTILISATEUR (échelle 1–5) et de la liste de matériaux disponibles, recommande 3 à 5 finitions.
|
||||
|
||||
Critères de recommandation :
|
||||
- Harmonie des couleurs avec les matériaux déjà sélectionnés
|
||||
- Cohérence avec le style d'armoire (dimensions, configuration)
|
||||
- Tendances actuelles en design d'intérieur suisse
|
||||
- Si un contexte de pièce est fourni, s'accorder avec le style de la pièce
|
||||
Règles :
|
||||
- Les priorités indiquent ce qui compte le PLUS pour le client (5 = très important, 1 = peu important). Tu dois PONDÉRER tes choix en conséquence (ex. : priorité « prix » élevée → privilégier des décors à bon rapport qualité-prix et l'expliquer ; priorité « qualité » élevée → insister sur la noblesse de la finition, la précision du relief, etc.).
|
||||
- Si un prix indicatif [CHF] est fourni pour un matériau, utilise-le honnêtement pour le critère prix.
|
||||
- Pour CHAQUE recommandation, explique CLAIREMENT POURQUOI tu proposes CE matériau : lien explicite avec les priorités du client (pas seulement « joli »).
|
||||
- Remplis les champs criteria avec une phrase courte CHACUN (en français) : ce que ce décor apporte sur ce critère, au regard des priorités.
|
||||
|
||||
Réponds UNIQUEMENT en JSON :
|
||||
Réponds UNIQUEMENT en JSON valide :
|
||||
{
|
||||
"recommendations": [
|
||||
{ "finishId": <number>, "reason": "…raison courte en français…" }
|
||||
{
|
||||
"finishId": <number>,
|
||||
"summary": "2 phrases max : pourquoi ce choix au regard des priorités",
|
||||
"criteria": {
|
||||
"price": "phrase sur le rapport prix / gamme / accessibilité",
|
||||
"quality": "phrase sur la qualité de finition, rendu, sensation",
|
||||
"durability": "phrase sur tenue dans le temps, entretien, résistance",
|
||||
"aesthetics": "phrase sur l'harmonie couleur/style avec le projet"
|
||||
},
|
||||
"reason": "une ligne très courte pour infobulle (optionnel, peut résumer summary)"
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
function clampPriority(n, fallback = 3) {
|
||||
const x = Number(n);
|
||||
if (!Number.isFinite(x)) return fallback;
|
||||
return Math.min(5, Math.max(1, Math.round(x)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} [p] - { price, quality, durability, aesthetics } each 1–5
|
||||
*/
|
||||
export function normalizeRecommendationPriorities(p) {
|
||||
const d = { price: 3, quality: 3, durability: 3, aesthetics: 3 };
|
||||
if (!p || typeof p !== 'object') return { ...d };
|
||||
return {
|
||||
price: clampPriority(p.price, d.price),
|
||||
quality: clampPriority(p.quality, d.quality),
|
||||
durability: clampPriority(p.durability, d.durability),
|
||||
aesthetics: clampPriority(p.aesthetics, d.aesthetics)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMaterialRecommendation(rec) {
|
||||
if (!rec || rec.finishId == null) return null;
|
||||
const criteria = rec.criteria && typeof rec.criteria === 'object' ? rec.criteria : {};
|
||||
const summary = (rec.summary || rec.reason || '').trim();
|
||||
const reason = (rec.reason || summary).trim();
|
||||
return {
|
||||
finishId: Number(rec.finishId),
|
||||
summary,
|
||||
reason,
|
||||
criteria: {
|
||||
price: String(criteria.price || '').trim(),
|
||||
quality: String(criteria.quality || '').trim(),
|
||||
durability: String(criteria.durability || '').trim(),
|
||||
aesthetics: String(criteria.aesthetics || '').trim()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrioritiesBlock(priorities) {
|
||||
const p = normalizeRecommendationPriorities(priorities);
|
||||
return `PRIORITÉS UTILISATEUR (1 = peu important, 5 = très important — ajuste tes recommandations et tes explications en conséquence) :
|
||||
- Prix / rapport qualité-prix : ${p.price}
|
||||
- Qualité perçue & finition : ${p.quality}
|
||||
- Durabilité & entretien : ${p.durability}
|
||||
- Esthétique & harmonie avec le projet : ${p.aesthetics}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recommend materials that complement the current configuration
|
||||
* @param {object} config - Current wardrobe configuration
|
||||
* @param {Array} availableFinishes - [{ id, name, collection, code }]
|
||||
* @param {Array} availableFinishes - [{ id, name, collection, code, price_chf? }]
|
||||
* @param {object} [roomAnalysis] - Optional room photo analysis
|
||||
* @returns {{ recommendations: Array<{ finishId: number, reason: string }> }}
|
||||
* @param {object} [priorities] - { price, quality, durability, aesthetics } each 1–5
|
||||
* @returns {{ recommendations: Array<{ finishId, summary, reason, criteria }> }}
|
||||
*/
|
||||
export async function recommendMaterials(config, availableFinishes, roomAnalysis) {
|
||||
export async function recommendMaterials(config, availableFinishes, roomAnalysis, priorities) {
|
||||
const client = getOpenAIClient();
|
||||
|
||||
const configSummary = buildConfigSummary(config);
|
||||
const priBlock = buildPrioritiesBlock(priorities);
|
||||
|
||||
// Compact finish list (only names + ids to save tokens)
|
||||
// Compact finish list (names + ids + optional price for fair "prix" explanations)
|
||||
const finishList = availableFinishes
|
||||
.slice(0, 80) // limit to avoid token overflow
|
||||
.map(f => `${f.id}: ${f.name || f.label} (${f.collection || '?'})`)
|
||||
.map((f) => {
|
||||
const name = f.name || f.label || '?';
|
||||
const col = f.collection || '?';
|
||||
const price =
|
||||
f.price_chf != null && Number.isFinite(Number(f.price_chf))
|
||||
? ` — indicatif ${Number(f.price_chf).toFixed(2)} CHF`
|
||||
: '';
|
||||
return `${f.id}: ${name} (${col})${price}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
let userMessage = `CONFIGURATION ACTUELLE:\n${configSummary}\n\nMATÉRIAUX DISPONIBLES:\n${finishList}`;
|
||||
let userMessage = `${priBlock}\n\nCONFIGURATION ACTUELLE:\n${configSummary}\n\nMATÉRIAUX DISPONIBLES:\n${finishList}`;
|
||||
|
||||
if (roomAnalysis) {
|
||||
userMessage += `\n\nCONTEXTE PIÈCE:\n${JSON.stringify(roomAnalysis, null, 1)}`;
|
||||
@@ -105,8 +172,8 @@ export async function recommendMaterials(config, availableFinishes, roomAnalysis
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
temperature: 0.7,
|
||||
max_tokens: 400,
|
||||
temperature: 0.65,
|
||||
max_tokens: 1400,
|
||||
messages: [
|
||||
{ role: 'system', content: MATERIAL_REC_PROMPT },
|
||||
{ role: 'user', content: userMessage }
|
||||
@@ -117,9 +184,13 @@ export async function recommendMaterials(config, availableFinishes, roomAnalysis
|
||||
const raw = response.choices[0]?.message?.content;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return { recommendations: (parsed.recommendations || []).slice(0, 5) };
|
||||
const rawList = (parsed.recommendations || []).slice(0, 5);
|
||||
const recommendations = rawList
|
||||
.map(normalizeMaterialRecommendation)
|
||||
.filter(Boolean);
|
||||
return { recommendations, priorities: normalizeRecommendationPriorities(priorities) };
|
||||
} catch {
|
||||
return { recommendations: [] };
|
||||
return { recommendations: [], priorities: normalizeRecommendationPriorities(priorities) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import PDFDocument from 'pdfkit';
|
||||
|
||||
function addSectionTitle(doc, title) {
|
||||
doc.moveDown(0.5);
|
||||
doc.fontSize(14).font('Helvetica-Bold').text(title);
|
||||
doc.moveDown(0.3);
|
||||
doc.fontSize(10).font('Helvetica');
|
||||
}
|
||||
|
||||
function ensurePageSpace(doc, neededHeight = 80) {
|
||||
if (doc.y + neededHeight > doc.page.height - doc.page.margins.bottom) {
|
||||
doc.addPage();
|
||||
}
|
||||
}
|
||||
|
||||
function keyValue(doc, key, value) {
|
||||
doc.font('Helvetica-Bold').text(`${key}: `, { continued: true });
|
||||
doc.font('Helvetica').text(String(value ?? '-'));
|
||||
}
|
||||
|
||||
function tableRow(doc, cols, widths, opts = {}) {
|
||||
const startX = doc.x;
|
||||
const y = doc.y;
|
||||
const lineHeight = opts.lineHeight || 14;
|
||||
|
||||
let x = startX;
|
||||
for (let i = 0; i < cols.length; i++) {
|
||||
const text = cols[i] == null ? '-' : String(cols[i]);
|
||||
doc.text(text, x, y, {
|
||||
width: widths[i],
|
||||
lineBreak: false
|
||||
});
|
||||
x += widths[i];
|
||||
}
|
||||
doc.y = y + lineHeight;
|
||||
}
|
||||
|
||||
export function generateOrderDossierPdf({ orderId, createdAt, customerName, config, cuttingList, packing }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 40 });
|
||||
const chunks = [];
|
||||
|
||||
doc.on('data', (chunk) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
|
||||
doc.fontSize(18).font('Helvetica-Bold').text(`Dossier technique commande #${orderId}`);
|
||||
doc.fontSize(10).font('Helvetica').text(`Généré le ${new Date().toLocaleString('fr-CH')}`);
|
||||
if (createdAt) doc.text(`Commande du ${new Date(createdAt).toLocaleString('fr-CH')}`);
|
||||
if (customerName) doc.text(`Client: ${customerName}`);
|
||||
|
||||
addSectionTitle(doc, 'Configuration');
|
||||
keyValue(doc, 'Dimensions', `${config.widthMm} x ${config.heightMm} x ${config.depthMm} mm`);
|
||||
keyValue(doc, 'Installation', config.installationType === 'niche' ? 'Encastree' : 'Libre');
|
||||
keyValue(doc, 'Portes', config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes');
|
||||
keyValue(doc, 'Modules', config.moduleCount || 0);
|
||||
|
||||
addSectionTitle(doc, 'Liste de decoupage');
|
||||
const cutWidths = [170, 130, 40, 130];
|
||||
tableRow(doc, ['Piece', 'L x H x Ep.', 'Qte', 'Finition'], cutWidths, { lineHeight: 16 });
|
||||
doc.moveTo(doc.x, doc.y).lineTo(doc.page.width - doc.page.margins.right, doc.y).stroke();
|
||||
doc.moveDown(0.2);
|
||||
|
||||
for (const piece of (cuttingList?.pieces || [])) {
|
||||
ensurePageSpace(doc, 18);
|
||||
tableRow(
|
||||
doc,
|
||||
[
|
||||
piece.description,
|
||||
`${piece.widthMm}x${piece.heightMm}x${piece.thicknessMm}`,
|
||||
piece.quantity,
|
||||
piece.finishLabel || '-'
|
||||
],
|
||||
cutWidths
|
||||
);
|
||||
}
|
||||
|
||||
addSectionTitle(doc, 'Colisage');
|
||||
const packWidths = [50, 170, 70, 70];
|
||||
tableRow(doc, ['Colis', 'Niveau', 'Poids', 'Pieces'], packWidths, { lineHeight: 16 });
|
||||
doc.moveTo(doc.x, doc.y).lineTo(doc.page.width - doc.page.margins.right, doc.y).stroke();
|
||||
doc.moveDown(0.2);
|
||||
|
||||
for (const box of (packing?.boxes || [])) {
|
||||
ensurePageSpace(doc, 18);
|
||||
tableRow(
|
||||
doc,
|
||||
[
|
||||
box.id,
|
||||
box.level || '-',
|
||||
`${Number(box.totalWeightKg || 0).toFixed(1)} kg`,
|
||||
box.pieces?.length || 0
|
||||
],
|
||||
packWidths
|
||||
);
|
||||
}
|
||||
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
|
||||
+575
-146
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,851 @@
|
||||
import * as cheerio from 'cheerio';
|
||||
import pool, { run } from '../db/index.js';
|
||||
|
||||
const BASE_URL = 'https://www.haefele.ch';
|
||||
const CRAWL_DELAY = parseInt(process.env.HAFELE_CRAWL_DELAY_MS || '500', 10);
|
||||
const BROWSER_TIMEOUT_MS = parseInt(process.env.HAFELE_BROWSER_TIMEOUT_MS || '90000', 10);
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function normalizeSpaces(value) {
|
||||
return (value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function absoluteUrl(url) {
|
||||
if (!url) return null;
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
return `${BASE_URL}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
}
|
||||
|
||||
function parsePriceCHF(text) {
|
||||
const clean = normalizeSpaces(text)
|
||||
.replace(/[\u00a0\u202f]/g, ' ')
|
||||
.replace(/[’']/g, '');
|
||||
const match = clean.match(/([\d]+(?:[.,][\d]+)?)\s*CHF|CHF\s*([\d]+(?:[.,][\d]+)?)/i);
|
||||
if (!match) return null;
|
||||
return parseFloat((match[1] || match[2]).replace(',', '.'));
|
||||
}
|
||||
|
||||
function parseFirstMm(text) {
|
||||
const match = normalizeSpaces(text).match(/(\d+(?:[.,]\d+)?)\s*mm/i);
|
||||
if (!match) return null;
|
||||
return parseFloat(match[1].replace(',', '.'));
|
||||
}
|
||||
|
||||
function parseBoreDiameter(text) {
|
||||
const clean = normalizeSpaces(text);
|
||||
const patterns = [
|
||||
/[Øø∅]\s*(\d+(?:[.,]\d+)?)\s*mm/i,
|
||||
/(?:trou|per[çc]age|diam[eè]tre|boring|bohrung)\s*[:\s]*[Øø∅]?\s*(\d+(?:[.,]\d+)?)\s*mm/i,
|
||||
/(\d+(?:[.,]\d+)?)\s*mm\s*(?:trou|per[çc]age|boring|bohrung)/i,
|
||||
];
|
||||
for (const pat of patterns) {
|
||||
const m = clean.match(pat);
|
||||
if (m) {
|
||||
const val = parseFloat((m[1] || m[2]).replace(',', '.'));
|
||||
if (val > 0 && val <= 50) return val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseBoreDepths(specs) {
|
||||
const baseKeys = [
|
||||
'profondeur de perçage dans le fond',
|
||||
'profondeur de perçage fond',
|
||||
'profondeur perçage fond',
|
||||
'profondeur percage fond',
|
||||
'bohrtiefe in boden',
|
||||
'bohrtiefe boden',
|
||||
'drilling depth base',
|
||||
'profondeur de perçage dans la partie inférieure',
|
||||
];
|
||||
const pieceKeys = [
|
||||
'profondeur de perçage dans la pièce',
|
||||
'profondeur de perçage pièce',
|
||||
'profondeur perçage pièce',
|
||||
'profondeur percage piece',
|
||||
'profondeur de perçage dans le panneau latéral',
|
||||
'profondeur de perçage dans la pièce fixée',
|
||||
'bohrtiefe in seitenteil',
|
||||
'bohrtiefe seitenteil',
|
||||
'drilling depth piece',
|
||||
'profondeur de perçage dans la partie latérale',
|
||||
];
|
||||
|
||||
let depthBase = null;
|
||||
let depthPiece = null;
|
||||
|
||||
for (const [key, value] of Object.entries(specs)) {
|
||||
const k = key.toLowerCase();
|
||||
if (!depthBase) {
|
||||
for (const bk of baseKeys) {
|
||||
if (k.includes(bk)) { depthBase = parseFirstMm(value); break; }
|
||||
}
|
||||
}
|
||||
if (!depthPiece) {
|
||||
for (const pk of pieceKeys) {
|
||||
if (k.includes(pk)) { depthPiece = parseFirstMm(value); break; }
|
||||
}
|
||||
}
|
||||
if (depthBase && depthPiece) break;
|
||||
}
|
||||
|
||||
// Fallback: look for a generic "profondeur de perçage" with two values
|
||||
if (!depthBase && !depthPiece) {
|
||||
for (const [key, value] of Object.entries(specs)) {
|
||||
if (key.toLowerCase().includes('profondeur') && key.toLowerCase().includes('per')) {
|
||||
const allMm = [...value.matchAll(/(\d+(?:[.,]\d+)?)\s*mm/gi)].map(m => parseFloat(m[1].replace(',', '.')));
|
||||
if (allMm.length >= 2) {
|
||||
depthBase = allMm[0];
|
||||
depthPiece = allMm[1];
|
||||
} else if (allMm.length === 1 && !depthBase) {
|
||||
depthBase = allMm[0];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { depthBase, depthPiece };
|
||||
}
|
||||
|
||||
function isCloudflareChallenge(html = '') {
|
||||
const lower = normalizeSpaces(html).toLowerCase();
|
||||
return lower.includes('just a moment') || lower.includes('cloudflare') || lower.includes('cf-challenge');
|
||||
}
|
||||
|
||||
class HaefeleSession {
|
||||
constructor() {
|
||||
this.cookies = new Map();
|
||||
this.loggedIn = false;
|
||||
}
|
||||
|
||||
captureCookies(response) {
|
||||
let rawCookies = [];
|
||||
|
||||
if (typeof response.headers.getSetCookie === 'function') {
|
||||
rawCookies = response.headers.getSetCookie();
|
||||
} else {
|
||||
const raw = response.headers.get('set-cookie');
|
||||
if (raw) {
|
||||
rawCookies = raw.split(/,\s*(?=[A-Za-z0-9_\-]+=)/);
|
||||
}
|
||||
}
|
||||
|
||||
for (const header of rawCookies) {
|
||||
const [pair] = header.split(';');
|
||||
const eqIdx = pair.indexOf('=');
|
||||
if (eqIdx > 0) {
|
||||
this.cookies.set(pair.slice(0, eqIdx).trim(), pair.slice(eqIdx + 1).trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cookieHeader() {
|
||||
return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
}
|
||||
|
||||
headers(extra = {}) {
|
||||
return {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'fr-CH,fr;q=0.9,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'sec-ch-ua': '"Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"Windows"',
|
||||
...(this.cookies.size > 0 ? { Cookie: this.cookieHeader() } : {}),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async login(emit = () => {}, credentials = {}) {
|
||||
const username = credentials.username || process.env.HAFELE_USERNAME || process.env.HAFELE_USER;
|
||||
const password = credentials.password || process.env.HAFELE_PASSWORD;
|
||||
const customerNo = credentials.customerNo || process.env.HAFELE_CUSTOMER_NO || username;
|
||||
|
||||
if (!username || !password) {
|
||||
emit({ type: 'log', message: 'Identifiants Häfele absents (sync publique uniquement).' });
|
||||
return false;
|
||||
}
|
||||
|
||||
const homeUrl = `${BASE_URL}/fr/`;
|
||||
const accountStartUrl = `${BASE_URL}/fr/account/start/`;
|
||||
|
||||
try {
|
||||
// 1) Load FR home page (contains header login form + synchronizer token)
|
||||
const getRes = await fetch(homeUrl, {
|
||||
headers: this.headers(),
|
||||
redirect: 'follow',
|
||||
});
|
||||
this.captureCookies(getRes);
|
||||
const html = await getRes.text();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Prefer the known header login form from the provided markup.
|
||||
let form = $('#loginWithExistingAccountForm').first();
|
||||
if (!form.length) {
|
||||
form = $('form').filter((_, el) => {
|
||||
const id = ($(el).attr('id') || '').toLowerCase();
|
||||
const action = ($(el).attr('action') || '').toLowerCase();
|
||||
return id.includes('login') || action.includes('processlogin') || action.includes('/account/login');
|
||||
}).first();
|
||||
}
|
||||
|
||||
if (!form.length) {
|
||||
emit({ type: 'log', message: 'Formulaire de login Häfele introuvable, continuation sans session.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
const action = form.attr('action') || '/fr/account/login/';
|
||||
const loginAction = absoluteUrl(action);
|
||||
const payload = new URLSearchParams();
|
||||
|
||||
form.find('input').each((_, input) => {
|
||||
const name = ($(input).attr('name') || '').trim();
|
||||
const type = (($(input).attr('type') || 'text')).toLowerCase();
|
||||
const value = ($(input).attr('value') || '').trim();
|
||||
if (!name) return;
|
||||
|
||||
if (type === 'hidden') {
|
||||
payload.set(name, value);
|
||||
return;
|
||||
}
|
||||
|
||||
const lowered = name.toLowerCase();
|
||||
if (lowered.includes('pass')) {
|
||||
payload.set(name, password);
|
||||
} else if (lowered.includes('kunden') || lowered.includes('customer')) {
|
||||
payload.set(name, customerNo);
|
||||
} else if (lowered.includes('user') || lowered.includes('login') || lowered.includes('mitbenutzer')) {
|
||||
payload.set(name, username);
|
||||
}
|
||||
});
|
||||
|
||||
// Explicit Häfele CH header login fields from current markup.
|
||||
if (!payload.has('ShopLoginForm_Login_headerItemLogin')) {
|
||||
payload.set('ShopLoginForm_Login_headerItemLogin', customerNo || username);
|
||||
}
|
||||
if (!payload.has('ShopLoginForm_Password_headerItemLogin')) {
|
||||
payload.set('ShopLoginForm_Password_headerItemLogin', password);
|
||||
}
|
||||
if (!payload.has('ShopLoginForm_RememberLogin_headerItemLogin')) {
|
||||
payload.set('ShopLoginForm_RememberLogin_headerItemLogin', 'on');
|
||||
}
|
||||
|
||||
// Optional co-user login flow.
|
||||
if (username && customerNo && username !== customerNo) {
|
||||
payload.set('ShopLoginForm_LoginAsJointUser_headerItemLogin', 'on');
|
||||
if (!payload.has('ShopLoginForm_UserName_headerItemLogin')) {
|
||||
payload.set('ShopLoginForm_UserName_headerItemLogin', username);
|
||||
}
|
||||
}
|
||||
|
||||
const postRes = await fetch(loginAction, {
|
||||
method: 'POST',
|
||||
headers: this.headers({
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Referer: homeUrl,
|
||||
Origin: BASE_URL,
|
||||
}),
|
||||
body: payload.toString(),
|
||||
redirect: 'follow',
|
||||
});
|
||||
this.captureCookies(postRes);
|
||||
|
||||
// 2) Verify with account start page (authenticated area)
|
||||
const checkRes = await fetch(accountStartUrl, {
|
||||
headers: this.headers(),
|
||||
redirect: 'follow',
|
||||
});
|
||||
this.captureCookies(checkRes);
|
||||
const finalUrl = checkRes.url || '';
|
||||
const finalHtml = await checkRes.text();
|
||||
const pageText = normalizeSpaces(finalHtml).toLowerCase();
|
||||
const stillLogin =
|
||||
finalUrl.includes('/account/login') ||
|
||||
pageText.includes('mot de passe') ||
|
||||
pageText.includes('passwort') ||
|
||||
pageText.includes('connexion') && pageText.includes('numéro de client');
|
||||
|
||||
this.loggedIn = !stillLogin;
|
||||
emit({
|
||||
type: 'log',
|
||||
message: this.loggedIn
|
||||
? `Connecté à Häfele (${this.cookies.size} cookies).`
|
||||
: 'Login Häfele non confirmé, continuation de la sync.',
|
||||
});
|
||||
return this.loggedIn;
|
||||
} catch (err) {
|
||||
emit({ type: 'log', message: `Erreur login Häfele: ${err.message}` });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let session = new HaefeleSession();
|
||||
let currentCredentials = {};
|
||||
let currentEmit = () => {};
|
||||
|
||||
async function waitForCloudflare(page, maxSeconds = 30) {
|
||||
for (let elapsed = 0; elapsed < maxSeconds; elapsed += 3) {
|
||||
const title = await page.title().catch(() => '');
|
||||
const html = await page.content().catch(() => '');
|
||||
if (!isCloudflareChallenge(html) && !title.toLowerCase().includes('just a moment')) {
|
||||
return true;
|
||||
}
|
||||
currentEmit({ type: 'log', message: ` Cloudflare en cours… (${elapsed}s)` });
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let browserInstance = null;
|
||||
let browserContext = null;
|
||||
|
||||
async function getBrowserPage() {
|
||||
if (browserInstance && browserContext) {
|
||||
return browserContext.newPage();
|
||||
}
|
||||
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = await import('playwright'));
|
||||
} catch {
|
||||
throw new Error('Cloudflare challenge détecté et Playwright indisponible.');
|
||||
}
|
||||
|
||||
const headless = process.env.HAFELE_BROWSER_HEADLESS !== '0';
|
||||
browserInstance = await chromium.launch({
|
||||
headless,
|
||||
args: [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--no-sandbox'
|
||||
]
|
||||
});
|
||||
|
||||
browserContext = await browserInstance.newContext({
|
||||
locale: 'fr-CH',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
||||
viewport: { width: 1920, height: 1080 }
|
||||
});
|
||||
|
||||
return browserContext.newPage();
|
||||
}
|
||||
|
||||
async function closeBrowser() {
|
||||
if (browserInstance) {
|
||||
await browserInstance.close().catch(() => {});
|
||||
browserInstance = null;
|
||||
browserContext = null;
|
||||
}
|
||||
}
|
||||
|
||||
let browserLoggedIn = false;
|
||||
|
||||
async function ensureBrowserLogin() {
|
||||
if (browserLoggedIn) return;
|
||||
|
||||
const username = currentCredentials.username || process.env.HAFELE_USERNAME || process.env.HAFELE_USER;
|
||||
const password = currentCredentials.password || process.env.HAFELE_PASSWORD;
|
||||
const customerNo = currentCredentials.customerNo || process.env.HAFELE_CUSTOMER_NO || username;
|
||||
|
||||
if (!username || !password) {
|
||||
currentEmit({ type: 'log', message: 'Pas d\'identifiants Häfele, navigation sans login.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const page = await getBrowserPage();
|
||||
try {
|
||||
currentEmit({ type: 'log', message: 'Ouverture de la page de login Häfele…' });
|
||||
await page.goto(`${BASE_URL}/fr/account/login/`, { waitUntil: 'load', timeout: BROWSER_TIMEOUT_MS });
|
||||
|
||||
const cfCleared = await waitForCloudflare(page, 45);
|
||||
if (!cfCleared) {
|
||||
currentEmit({ type: 'log', message: 'Cloudflare non résolu sur la page login.' });
|
||||
const title = await page.title().catch(() => '?');
|
||||
currentEmit({ type: 'log', message: ` Page titre: "${title}", URL: ${page.url()}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUrl = page.url();
|
||||
const title = await page.title().catch(() => '');
|
||||
currentEmit({ type: 'log', message: `Page login chargée: "${title}" (${currentUrl})` });
|
||||
|
||||
// Use exact ID selectors — the form has #ShopLoginForm_Login (text input)
|
||||
// and #ShopLoginForm_LoginAsJointUser (hidden checkbox). Substring selectors
|
||||
// like [name*=] would match the checkbox first and break .first().
|
||||
const customerField = page.locator('#ShopLoginForm_Login');
|
||||
const pwField = page.locator('#ShopLoginForm_Password');
|
||||
|
||||
try {
|
||||
await customerField.waitFor({ state: 'visible', timeout: 15000 });
|
||||
} catch {
|
||||
currentEmit({ type: 'log', message: 'Champ #ShopLoginForm_Login non visible après attente.' });
|
||||
const bodySnippet = normalizeSpaces(await page.content().catch(() => '')).slice(0, 500);
|
||||
currentEmit({ type: 'log', message: ` Début HTML: ${bodySnippet}` });
|
||||
return;
|
||||
}
|
||||
|
||||
currentEmit({ type: 'log', message: 'Formulaire de login trouvé, saisie des identifiants…' });
|
||||
await customerField.fill(customerNo || username);
|
||||
await pwField.fill(password);
|
||||
|
||||
// Handle co-user mode if customer number differs from username.
|
||||
if (username && customerNo && username !== customerNo) {
|
||||
const coUserCheck = page.locator('#ShopLoginForm_LoginAsJointUser');
|
||||
const coUserCheckDiv = page.locator('#divShopLoginForm_LoginAsJointUser');
|
||||
// The real checkbox is hidden; the visible <div> acts as the toggle.
|
||||
if (await coUserCheckDiv.count()) {
|
||||
await coUserCheckDiv.click().catch(() => {});
|
||||
await page.waitForTimeout(800);
|
||||
} else if (await coUserCheck.count()) {
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('#ShopLoginForm_LoginAsJointUser').checked = true;
|
||||
document.querySelector('#ShopLoginForm_LoginAsJointUser').dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
const coUserName = page.locator('#ShopLoginForm_UserName');
|
||||
if (await coUserName.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await coUserName.fill(username);
|
||||
}
|
||||
}
|
||||
|
||||
const submitBtn = page.locator('button[data-testid="accountLoginFormBtn"]');
|
||||
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: 'load', timeout: 30000 }).catch(() => {}),
|
||||
submitBtn.click()
|
||||
]);
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
const afterUrl = page.url();
|
||||
browserLoggedIn = !afterUrl.includes('/account/login');
|
||||
currentEmit({
|
||||
type: 'log',
|
||||
message: browserLoggedIn
|
||||
? `Connecté à Häfele via navigateur (${afterUrl}).`
|
||||
: `Login navigateur non confirmé (${afterUrl}), continuation sans prix.`
|
||||
});
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPageWithBrowser(url) {
|
||||
await ensureBrowserLogin();
|
||||
|
||||
const page = await getBrowserPage();
|
||||
try {
|
||||
currentEmit({ type: 'log', message: `Navigation vers ${url}…` });
|
||||
await page.goto(url, { waitUntil: 'load', timeout: BROWSER_TIMEOUT_MS });
|
||||
|
||||
const cfCleared = await waitForCloudflare(page, 45);
|
||||
if (!cfCleared) {
|
||||
throw new Error('Cloudflare challenge non résolu automatiquement dans le navigateur.');
|
||||
}
|
||||
|
||||
// Wait for product content to appear.
|
||||
try {
|
||||
await page.locator('.productDetailPage, .productHeadline, h1').first()
|
||||
.waitFor({ state: 'visible', timeout: 15000 });
|
||||
} catch {
|
||||
// Page loaded but no product selector found — continue anyway.
|
||||
}
|
||||
|
||||
// Price is loaded via AJAX after page load — wait for it.
|
||||
try {
|
||||
await page.locator('#productPriceInformation span.price').first()
|
||||
.waitFor({ state: 'visible', timeout: 15000 });
|
||||
currentEmit({ type: 'log', message: 'Prix détecté dans la page.' });
|
||||
} catch {
|
||||
currentEmit({ type: 'log', message: 'Prix non chargé après 15s, récupération du contenu actuel.' });
|
||||
}
|
||||
|
||||
const html = await page.content();
|
||||
currentEmit({ type: 'log', message: 'Contenu récupéré via navigateur (Playwright).' });
|
||||
return html;
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPage(url) {
|
||||
const referer = `${BASE_URL}/fr/`;
|
||||
let response = await fetch(url, {
|
||||
headers: session.headers({
|
||||
Referer: referer,
|
||||
Origin: BASE_URL,
|
||||
'Sec-Fetch-Site': 'same-origin'
|
||||
}),
|
||||
redirect: 'follow',
|
||||
});
|
||||
session.captureCookies(response);
|
||||
let firstBody = '';
|
||||
if (response.status === 403) {
|
||||
firstBody = await response.text().catch(() => '');
|
||||
if (isCloudflareChallenge(firstBody)) {
|
||||
currentEmit({ type: 'log', message: 'Cloudflare détecté, tentative de fallback navigateur…' });
|
||||
return fetchPageWithBrowser(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Retry once after refreshing home page cookies when anti-bot blocks first hit.
|
||||
if (response.status === 403) {
|
||||
const warmup = await fetch(referer, {
|
||||
headers: session.headers({
|
||||
Referer: referer,
|
||||
Origin: BASE_URL,
|
||||
'Sec-Fetch-Site': 'same-origin'
|
||||
}),
|
||||
redirect: 'follow',
|
||||
});
|
||||
session.captureCookies(warmup);
|
||||
|
||||
response = await fetch(url, {
|
||||
headers: session.headers({
|
||||
Referer: referer,
|
||||
Origin: BASE_URL,
|
||||
'Sec-Fetch-Site': 'same-origin'
|
||||
}),
|
||||
redirect: 'follow',
|
||||
});
|
||||
session.captureCookies(response);
|
||||
if (response.status === 403) {
|
||||
const secondBody = await response.text().catch(() => '');
|
||||
if (isCloudflareChallenge(secondBody)) {
|
||||
currentEmit({ type: 'log', message: 'Challenge persistant, fallback navigateur…' });
|
||||
return fetchPageWithBrowser(url);
|
||||
}
|
||||
throw new Error(`Failed to fetch ${url}: 403`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
const sample = normalizeSpaces(body).slice(0, 180);
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status}${sample ? ` (${sample})` : ''}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function extractKeyValueSpecs($) {
|
||||
const specs = {};
|
||||
$('.productPropertiesSection.details-product-body').each((_, el) => {
|
||||
const key = normalizeSpaces($(el).find('.productPropertiesSectionHeader').first().text()).toLowerCase();
|
||||
const value = normalizeSpaces($(el).find('.productPropertiesSectionBody').first().text());
|
||||
if (key && value) specs[key] = value;
|
||||
});
|
||||
return specs;
|
||||
}
|
||||
|
||||
function extractArticleNumber($) {
|
||||
const text = normalizeSpaces($('.product-sku-title').first().text() || $.text());
|
||||
const match = text.match(/N°\s*d['’]art\.\s*([\d.]+)/i);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function buildMaterialCode(url, articleNumber) {
|
||||
if (articleNumber) {
|
||||
return `HAF-${articleNumber.replace(/[^\dA-Za-z]/g, '')}`;
|
||||
}
|
||||
const skuMatch = url.match(/\/(P-\d+)\//i);
|
||||
if (skuMatch) return `HAF-${skuMatch[1].toUpperCase()}`;
|
||||
const slug = (url.split('/').filter(Boolean).pop() || 'unknown').replace(/[^\dA-Za-z]/g, '').slice(0, 30);
|
||||
return `HAF-${slug.toUpperCase()}`;
|
||||
}
|
||||
|
||||
function parseProductPage(html, url) {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const name = normalizeSpaces($('h1.productHeadline[itemprop="name"]').first().text() || $('h1.productHeadline').first().text());
|
||||
const description = normalizeSpaces($('h2.productSubline[itemprop="description"]').first().text() || $('h2.productSubline').first().text());
|
||||
const articleNumber = extractArticleNumber($);
|
||||
|
||||
const priceText =
|
||||
normalizeSpaces($('#productPriceInformation .CUSTOMER_PRICE_Display .price span.price').first().text()) ||
|
||||
normalizeSpaces($('#productPriceInformation .pricedisplay:not(.hidden) span.price').first().text()) ||
|
||||
normalizeSpaces($('#productPriceInformation span.price').first().text()) ||
|
||||
normalizeSpaces($('[id^="selectedArticlePriceInfo"] span.price').first().text()) ||
|
||||
normalizeSpaces($('.priceContainer .price').first().text());
|
||||
const priceCHF = parsePriceCHF(priceText);
|
||||
if (priceText) {
|
||||
currentEmit({ type: 'log', message: ` Prix trouvé: "${priceText}" → ${priceCHF} CHF` });
|
||||
} else {
|
||||
currentEmit({ type: 'log', message: ' Aucun prix trouvé dans le HTML.' });
|
||||
}
|
||||
const priceUnit = normalizeSpaces($('#productPriceInformation span.perUnit').first().text()) ||
|
||||
normalizeSpaces($('[id^="selectedArticlePriceInfo"] span.perUnit').first().text()) || null;
|
||||
|
||||
const availabilityText = normalizeSpaces($('#productAvailabilityInformation .availability-flag').first().text());
|
||||
const inStock = availabilityText
|
||||
? (availabilityText.toLowerCase().includes('disponible') || availabilityText.toLowerCase().includes('stock')) &&
|
||||
!availabilityText.toLowerCase().includes('indisponible')
|
||||
: null;
|
||||
|
||||
// Deterministic image pick: prefer "primary" image, not the last diapo slide.
|
||||
const pickImg = (sel) => {
|
||||
const el = $(sel).first();
|
||||
if (!el?.length) return null;
|
||||
const src = el.attr('data-fullsrc') || el.attr('data-src') || el.attr('src') || '';
|
||||
return absoluteUrl(String(src).trim());
|
||||
};
|
||||
|
||||
// 1) Category/tile view markup (your snippet) — prefer when present.
|
||||
const tileImg = pickImg('div.product_picture a.shopItemImage img, a.shopItemImage img');
|
||||
|
||||
// 2) OpenGraph is often the canonical hero image.
|
||||
const ogImg = (
|
||||
$('meta[property="og:image"]').attr('content') ||
|
||||
$('meta[name="og:image"]').attr('content') ||
|
||||
''
|
||||
).trim();
|
||||
const ogFull = absoluteUrl(ogImg);
|
||||
|
||||
// 3) Product detail hero image (if present).
|
||||
const heroImg =
|
||||
pickImg('.productImagePanelInner img[itemprop="image"]') ||
|
||||
pickImg('.productImagePanelInner img');
|
||||
|
||||
// 4) First thumbnail (diapo) image.
|
||||
const thumbImg = pickImg('.thumbsList img');
|
||||
|
||||
const imageUrl = tileImg || ogFull || heroImg || thumbImg || null;
|
||||
|
||||
const specs = extractKeyValueSpecs($);
|
||||
|
||||
if (Object.keys(specs).length > 0) {
|
||||
const specsSummary = Object.entries(specs).map(([k, v]) => `${k}: ${v}`).join(' | ');
|
||||
currentEmit({ type: 'log', message: ` Specs: ${specsSummary}` });
|
||||
}
|
||||
|
||||
const category = normalizeSpaces($('.breadcrumb li:last-child').text()) || 'Hafele';
|
||||
const thickness = parseFirstMm(specs['pour épaisseur du bois'] || specs['epaisseur'] || specs['épaisseur'] || '');
|
||||
|
||||
const allText = `${description} ${Object.values(specs).join(' ')}`;
|
||||
const boreDiameter = parseBoreDiameter(allText);
|
||||
const { depthBase, depthPiece } = parseBoreDepths(specs);
|
||||
|
||||
// Parse physical dimensions: diameter + length for cylindrical parts
|
||||
let diameter = null;
|
||||
let length = null;
|
||||
const dimKeys = Object.keys(specs).map(k => k.toLowerCase());
|
||||
for (const [key, value] of Object.entries(specs)) {
|
||||
const k = key.toLowerCase();
|
||||
if (!diameter && (k.includes('diamètre') || k.includes('diametre') || k.includes('durchmesser') || k.includes('diameter'))) {
|
||||
diameter = parseFirstMm(value);
|
||||
}
|
||||
if (!length && (k.includes('longueur') || k.includes('länge') || k.includes('length') || k.includes('lang'))) {
|
||||
length = parseFirstMm(value);
|
||||
}
|
||||
}
|
||||
// Fallback: parse "Ø 8/60" or "8/60" pattern from the product name (e.g. SC 8/60 = Ø8mm, 60mm long)
|
||||
if (!diameter) {
|
||||
const nameMatch = (name || '').match(/(\d+(?:[.,]\d+)?)\s*\/\s*(\d+(?:[.,]\d+)?)/);
|
||||
if (nameMatch) {
|
||||
const d = parseFloat(nameMatch[1].replace(',', '.'));
|
||||
const l = parseFloat(nameMatch[2].replace(',', '.'));
|
||||
if (d > 0 && d <= 30) diameter = d;
|
||||
if (l > 0 && l <= 200 && !length) length = l;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
materialCode: buildMaterialCode(url, articleNumber),
|
||||
articleNumber,
|
||||
name: name || articleNumber || url,
|
||||
description,
|
||||
collection: 'Hafele',
|
||||
category,
|
||||
width_mm: null,
|
||||
height_mm: null,
|
||||
thickness_mm: thickness,
|
||||
diameter_mm: diameter,
|
||||
length_mm: length,
|
||||
bore_diameter_mm: boreDiameter,
|
||||
bore_depth_base_mm: depthBase,
|
||||
bore_depth_piece_mm: depthPiece,
|
||||
price_chf: priceCHF,
|
||||
price_unit: priceUnit,
|
||||
image_url: imageUrl,
|
||||
formex_url: url,
|
||||
in_stock: inStock,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureSupplier() {
|
||||
const existing = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['haefele']);
|
||||
if (existing.rows.length > 0) return existing.rows[0].id;
|
||||
|
||||
await run(
|
||||
'INSERT INTO suppliers (code, name, email, type, active) VALUES (?, ?, ?, ?, ?)',
|
||||
['haefele', 'Hafele', '[email protected]', 'materials', 1]
|
||||
);
|
||||
const created = await pool.query('SELECT id FROM suppliers WHERE code = ?', ['haefele']);
|
||||
return created.rows[0]?.id || null;
|
||||
}
|
||||
|
||||
async function upsertMaterial(material, supplierId) {
|
||||
const existing = await pool.query(
|
||||
'SELECT id FROM formex_materials WHERE formex_code = ?',
|
||||
[material.materialCode]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
await run(
|
||||
`UPDATE formex_materials
|
||||
SET name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
|
||||
diameter_mm = COALESCE(?, diameter_mm), length_mm = COALESCE(?, length_mm),
|
||||
bore_diameter_mm = COALESCE(?, bore_diameter_mm),
|
||||
bore_depth_base_mm = COALESCE(?, bore_depth_base_mm),
|
||||
bore_depth_piece_mm = COALESCE(?, bore_depth_piece_mm),
|
||||
image_url = ?, formex_url = ?, price_chf = COALESCE(?, price_chf), price_unit = COALESCE(?, price_unit),
|
||||
supplier_id = COALESCE(?, supplier_id), last_synced_at = datetime('now')
|
||||
WHERE formex_code = ?`,
|
||||
[
|
||||
material.name,
|
||||
material.collection,
|
||||
material.category,
|
||||
material.width_mm,
|
||||
material.height_mm,
|
||||
material.thickness_mm,
|
||||
material.diameter_mm ?? null,
|
||||
material.length_mm ?? null,
|
||||
material.bore_diameter_mm ?? null,
|
||||
material.bore_depth_base_mm ?? null,
|
||||
material.bore_depth_piece_mm ?? null,
|
||||
material.image_url,
|
||||
material.formex_url,
|
||||
material.price_chf ?? null,
|
||||
material.price_unit ?? null,
|
||||
supplierId,
|
||||
material.materialCode,
|
||||
]
|
||||
);
|
||||
return existing.rows[0].id;
|
||||
}
|
||||
|
||||
await run(
|
||||
`INSERT INTO formex_materials
|
||||
(formex_code, name, collection, category, width_mm, height_mm, thickness_mm,
|
||||
diameter_mm, length_mm, bore_diameter_mm, bore_depth_base_mm, bore_depth_piece_mm,
|
||||
image_url, formex_url, price_chf, price_unit, supplier_id, last_synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
[
|
||||
material.materialCode,
|
||||
material.name,
|
||||
material.collection,
|
||||
material.category,
|
||||
material.width_mm,
|
||||
material.height_mm,
|
||||
material.thickness_mm,
|
||||
material.diameter_mm ?? null,
|
||||
material.length_mm ?? null,
|
||||
material.bore_diameter_mm ?? null,
|
||||
material.bore_depth_base_mm ?? null,
|
||||
material.bore_depth_piece_mm ?? null,
|
||||
material.image_url,
|
||||
material.formex_url,
|
||||
material.price_chf ?? null,
|
||||
material.price_unit ?? null,
|
||||
supplierId,
|
||||
]
|
||||
);
|
||||
|
||||
const created = await pool.query('SELECT id FROM formex_materials WHERE formex_code = ?', [material.materialCode]);
|
||||
return created.rows[0]?.id || null;
|
||||
}
|
||||
|
||||
async function logSync(status, itemsCount, error = null) {
|
||||
await run(
|
||||
'INSERT INTO sync_logs (source, status, items_count, error) VALUES (?, ?, ?, ?)',
|
||||
['haefele', status, itemsCount, error]
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncHaefeleByUrls(urls = [], emit = () => {}, options = {}) {
|
||||
const credentials = options.credentials || {};
|
||||
currentCredentials = credentials;
|
||||
currentEmit = emit;
|
||||
const validUrls = [...new Set((urls || [])
|
||||
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.filter((v) => v.includes('haefele.ch')))];
|
||||
|
||||
if (validUrls.length === 0) {
|
||||
throw new Error('Aucune URL Häfele valide fournie.');
|
||||
}
|
||||
|
||||
session = new HaefeleSession();
|
||||
browserLoggedIn = false;
|
||||
const supplierId = await ensureSupplier();
|
||||
await session.login(emit, credentials);
|
||||
|
||||
let synced = 0;
|
||||
let failed = 0;
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
emit({ type: 'log', message: `${validUrls.length} URL(s) Häfele à traiter.` });
|
||||
emit({ type: 'progress', current: 0, total: validUrls.length, collection: 'Hafele' });
|
||||
|
||||
for (let i = 0; i < validUrls.length; i++) {
|
||||
const url = validUrls[i];
|
||||
emit({ type: 'log', message: `Traitement [${i + 1}/${validUrls.length}] ${url}` });
|
||||
emit({ type: 'progress', current: i, total: validUrls.length, collection: 'Hafele' });
|
||||
|
||||
if (i > 0) await sleep(CRAWL_DELAY);
|
||||
|
||||
try {
|
||||
const html = await fetchPage(url);
|
||||
const parsed = parseProductPage(html, url);
|
||||
await upsertMaterial(parsed, supplierId);
|
||||
synced++;
|
||||
const pricePart = parsed.price_chf != null ? ` - ${parsed.price_chf} CHF` : '';
|
||||
const boreParts = [
|
||||
parsed.bore_diameter_mm != null ? `Ø${parsed.bore_diameter_mm}` : null,
|
||||
parsed.bore_depth_base_mm != null ? `fond:${parsed.bore_depth_base_mm}` : null,
|
||||
parsed.bore_depth_piece_mm != null ? `pièce:${parsed.bore_depth_piece_mm}` : null,
|
||||
].filter(Boolean);
|
||||
const boreStr = boreParts.length ? ` [${boreParts.join(', ')}mm]` : '';
|
||||
emit({ type: 'log', message: ` ✓ ${parsed.name}${pricePart}${boreStr}` });
|
||||
} catch (err) {
|
||||
failed++;
|
||||
emit({ type: 'log', message: ` ✗ Erreur: ${err.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
const duration = ((Date.now() - start) / 1000).toFixed(1);
|
||||
const status = failed > 0 && synced === 0 ? 'error' : 'success';
|
||||
await logSync(status, synced, failed > 0 ? `${failed} url(s) failed` : null);
|
||||
const result = {
|
||||
success: status === 'success',
|
||||
itemsCount: synced,
|
||||
failedCount: failed,
|
||||
duration,
|
||||
error: status === 'error' ? 'Aucune URL Häfele n\'a pu être synchronisée.' : null
|
||||
};
|
||||
emit({ type: 'progress', current: validUrls.length, total: validUrls.length, collection: 'Hafele' });
|
||||
if (result.success) emit({ type: 'complete', result });
|
||||
else emit({ type: 'error', error: result.error });
|
||||
return result;
|
||||
} catch (err) {
|
||||
await logSync('error', synced, err.message);
|
||||
emit({ type: 'error', error: err.message });
|
||||
return { success: false, itemsCount: synced, error: err.message };
|
||||
} finally {
|
||||
await closeBrowser();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import pool from '../db/index.js';
|
||||
import { generateCuttingList } from './cuttingEngine.js';
|
||||
import { PLINTH, CARCASS_FASTENERS } from '../../shared/manufacturing.js';
|
||||
|
||||
const HAEFELE_PRODUCT_URLS = {
|
||||
hinge_blum_clip_top_blumotion_110: 'https://www.haefele.ch/fr/produkt/charni-re-corps-blum-clip-top-blumotion-110-pose-en-applique/34242663/?MasterSKU=P-00898218',
|
||||
back_panel_fastener_ixconnect_rpc_g_13_20:
|
||||
'https://www.haefele.ch/fr/produkt/ferrure-d-assemblage-pour-paroi-arri-re-haefele-ixconnect-rpc-g-13-20-enficher-dans-la-rainure/P-00861578/',
|
||||
};
|
||||
|
||||
function toMoney(n) {
|
||||
const x = Number(n);
|
||||
if (!Number.isFinite(x)) return 0;
|
||||
return Math.round(x * 100) / 100;
|
||||
}
|
||||
|
||||
function addQty(map, key, row) {
|
||||
const prev = map.get(key);
|
||||
if (!prev) {
|
||||
map.set(key, { ...row, quantity: Math.max(0, Number(row.quantity) || 0) });
|
||||
return;
|
||||
}
|
||||
map.set(key, {
|
||||
...prev,
|
||||
quantity: (Number(prev.quantity) || 0) + (Number(row.quantity) || 0),
|
||||
});
|
||||
}
|
||||
|
||||
function pricePackSizeFromUnitLabel(unitLabel, unit) {
|
||||
const u = String(unit || '').trim().toLowerCase();
|
||||
if (u !== 'pc') return null;
|
||||
const s = String(unitLabel || '').trim();
|
||||
if (!s) return null;
|
||||
|
||||
// Examples we see in Häfele sync:
|
||||
// - "par 100 Pièce (PC)"
|
||||
// - "par 10 pièce"
|
||||
// - "Par 25 PC"
|
||||
// - "par 100 Pieces"
|
||||
const m = s.match(/\bpar\s+(\d+)\s*(pi[eè]ce?s?|pc)\b/i);
|
||||
const n = m ? Number(m[1]) : NaN;
|
||||
if (!Number.isFinite(n) || n <= 1) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
async function fetchOne(sql, params) {
|
||||
const r = await pool.query(sql, params);
|
||||
return r?.rows?.[0] || null;
|
||||
}
|
||||
|
||||
async function priceById(table, id) {
|
||||
if (!id) return null;
|
||||
const row = await fetchOne(`SELECT id, code, name, price_chf FROM ${table} WHERE id = ? AND active = 1`, [id]);
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
unitPriceChf: toMoney(row.price_chf),
|
||||
source: table,
|
||||
};
|
||||
}
|
||||
|
||||
async function findHaefeleMaterialForModel(model) {
|
||||
const m = String(model || '').trim();
|
||||
if (!m) return null;
|
||||
|
||||
// Prefer strict matches (code contains model OR name contains model)
|
||||
// so we link to the exact Häfele product page when synced.
|
||||
const row = await fetchOne(
|
||||
`SELECT id, formex_code as code, name, price_chf, price_unit, image_url, formex_url
|
||||
FROM formex_materials
|
||||
WHERE active = 1
|
||||
AND (supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele') OR collection = 'Hafele' OR formex_code LIKE 'HAF-%')
|
||||
AND (formex_code LIKE ? OR name LIKE ?)
|
||||
ORDER BY last_synced_at DESC, id DESC
|
||||
LIMIT 1`,
|
||||
[`%${m}%`, `%${m}%`]
|
||||
);
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
unitPriceChf: Number.isFinite(Number(row.price_chf)) ? toMoney(row.price_chf) : null,
|
||||
unitLabel: row.price_unit || null,
|
||||
source: 'haefele_materials',
|
||||
url: row.formex_url || null,
|
||||
imageUrl: row.image_url || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function findHaefeleMaterialByUrl(url) {
|
||||
const raw = String(url || '').trim();
|
||||
if (!raw) return null;
|
||||
|
||||
// Normalize: strip query/hash (synced DB may store canonical URL).
|
||||
const uNoHash = raw.split('#')[0];
|
||||
const uBase = uNoHash.split('?')[0];
|
||||
const u = uNoHash;
|
||||
|
||||
// Extract a stable numeric product id if present (e.g. ".../34242663/").
|
||||
const m = uBase.match(/\/(\d{6,})\/?$/);
|
||||
const productId = m?.[1] || null;
|
||||
|
||||
const candidates = [
|
||||
u,
|
||||
uBase,
|
||||
].filter((v, idx, arr) => v && arr.indexOf(v) === idx);
|
||||
|
||||
const row = await fetchOne(
|
||||
`SELECT id, formex_code as code, name, price_chf, price_unit, image_url, formex_url
|
||||
FROM formex_materials
|
||||
WHERE active = 1
|
||||
AND (supplier_id = (SELECT id FROM suppliers WHERE code = 'haefele') OR collection = 'Hafele' OR formex_code LIKE 'HAF-%')
|
||||
AND (
|
||||
formex_url = ?
|
||||
OR formex_url = ?
|
||||
OR formex_url LIKE ?
|
||||
OR (? IS NOT NULL AND formex_url LIKE ?)
|
||||
)
|
||||
ORDER BY last_synced_at DESC, id DESC
|
||||
LIMIT 1`,
|
||||
[
|
||||
candidates[0] || raw,
|
||||
candidates[1] || raw,
|
||||
`${uBase}%`,
|
||||
productId,
|
||||
productId ? `%/${productId}/%` : null,
|
||||
]
|
||||
);
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
unitPriceChf: Number.isFinite(Number(row.price_chf)) ? toMoney(row.price_chf) : null,
|
||||
unitLabel: row.price_unit || null,
|
||||
source: 'haefele_materials',
|
||||
url: row.formex_url || null,
|
||||
imageUrl: row.image_url || null,
|
||||
};
|
||||
}
|
||||
|
||||
function computeCountsFromCuttingList(cuttingList) {
|
||||
const items = new Map();
|
||||
const pieces = Array.isArray(cuttingList?.pieces) ? cuttingList.pieces : [];
|
||||
|
||||
let doorCount = 0;
|
||||
let drawersCount = 0;
|
||||
|
||||
for (const p of pieces) {
|
||||
const qty = Math.max(0, Number(p?.quantity) || 0);
|
||||
const m = p?.machining || {};
|
||||
const wMm = Number(p?.widthMm) || 0;
|
||||
|
||||
if (p?.elementType === 'portes') {
|
||||
doorCount += qty;
|
||||
if (m.hingeCups?.count) {
|
||||
addQty(items, 'hinge', {
|
||||
key: 'hinge',
|
||||
label: 'Charnière',
|
||||
quantity: qty * (Number(m.hingeCups.count) || 0),
|
||||
unit: 'pc',
|
||||
});
|
||||
}
|
||||
// Assumption: 2 finish connectors per door (front/back).
|
||||
if (m.finishConnectors?.model) {
|
||||
addQty(items, `finish_connector:${m.finishConnectors.model}`, {
|
||||
key: `finish_connector:${m.finishConnectors.model}`,
|
||||
label: `Connecteur finition ${m.finishConnectors.model}`,
|
||||
quantity: qty * 2,
|
||||
unit: 'pc',
|
||||
meta: { model: m.finishConnectors.model },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Étagères amovibles — ferrures SP 15/8/5 (système ressort)
|
||||
// User spec: 4 pieces per shelf/tablette.
|
||||
if (p?.elementType === 'etageres' && m.mobileShelfSystem?.model === 'sp_15_8_5' && qty > 0) {
|
||||
addQty(items, 'shelf_ferrure_sp_15_8_5', {
|
||||
key: 'shelf_ferrure_sp_15_8_5',
|
||||
label: 'Ferrure d’assemblage de tablettes SP 15/8/5 (ressort)',
|
||||
quantity: qty * 4,
|
||||
unit: 'pc',
|
||||
meta: { model: 'SP 15/8/5' },
|
||||
});
|
||||
}
|
||||
|
||||
if (m.connectorHoles?.model && ['dessus', 'fond'].includes(p?.elementType)) {
|
||||
const joints = Number(m.connectorHoles.jointsPerPiece) || (Array.isArray(m.connectorHoles.ixconnectJointsAlongDepth) ? m.connectorHoles.ixconnectJointsAlongDepth.length : 0);
|
||||
const perJoint = Number(m.connectorHoles.connectorsPerJoint) || 1;
|
||||
const tPer = Number(m.connectorHoles.tourillonsPerJoint) || 0;
|
||||
addQty(items, `ixconnect:${m.connectorHoles.model}`, {
|
||||
key: `ixconnect:${m.connectorHoles.model}`,
|
||||
label: `Connecteur structure ${m.connectorHoles.model}`,
|
||||
quantity: qty * joints * perJoint,
|
||||
unit: 'pc',
|
||||
meta: { model: m.connectorHoles.model },
|
||||
});
|
||||
if (tPer > 0) {
|
||||
addQty(items, `tourillon:${m.connectorHoles.tourillonDiameter || 8}x30`, {
|
||||
key: `tourillon:${m.connectorHoles.tourillonDiameter || 8}x30`,
|
||||
label: `Tourillon Ø${m.connectorHoles.tourillonDiameter || 8}×30`,
|
||||
quantity: qty * joints * tPer,
|
||||
unit: 'pc',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (m.carcassFasteners?.positionsAlongJointMm?.length && ['dessus', 'fond'].includes(p?.elementType)) {
|
||||
const ferruresPerJoint = m.carcassFasteners.positionsAlongJointMm.length;
|
||||
const jointCount = Number(m.carcassFasteners.jointCount) || (Array.isArray(m.carcassFasteners.junctionAxesAcrossWidthMm) ? m.carcassFasteners.junctionAxesAcrossWidthMm.length : 0);
|
||||
const ferrures = qty * ferruresPerJoint * jointCount;
|
||||
addQty(items, 'ferrure_carcasse', {
|
||||
key: 'ferrure_carcasse',
|
||||
label: `Ferrure corps (jonction) — ${CARCASS_FASTENERS.model}`,
|
||||
quantity: ferrures,
|
||||
unit: 'pc',
|
||||
productUrl: CARCASS_FASTENERS.productUrl,
|
||||
meta: { model: CARCASS_FASTENERS.model },
|
||||
});
|
||||
// 2 dowels per ferrure (per spec text)
|
||||
addQty(items, `tourillon:${m.carcassFasteners.dowelDiameter || 8}x40`, {
|
||||
key: `tourillon:${m.carcassFasteners.dowelDiameter || 8}x40`,
|
||||
label: `Tourillon Ø${m.carcassFasteners.dowelDiameter || 8}×${m.carcassFasteners.dowelDepth || 40}`,
|
||||
quantity: ferrures * 2,
|
||||
unit: 'pc',
|
||||
});
|
||||
}
|
||||
|
||||
if (p?.elementType === 'renforts' && m.reinforcementFixing?.connectorModel) {
|
||||
const sideCount = Number(m.reinforcementFixing.sideCount) || 2;
|
||||
const ixPerSide = Number(m.reinforcementFixing.ixconnectPerSide) || 1;
|
||||
const tPerSide = Number(m.reinforcementFixing.tourillonsPerSide) || 0;
|
||||
addQty(items, `reinforcement_ix:${m.reinforcementFixing.connectorModel}`, {
|
||||
key: `reinforcement_ix:${m.reinforcementFixing.connectorModel}`,
|
||||
label: `Connecteur renfort ${m.reinforcementFixing.connectorModel}`,
|
||||
quantity: qty * sideCount * ixPerSide,
|
||||
unit: 'pc',
|
||||
meta: { model: m.reinforcementFixing.connectorModel },
|
||||
});
|
||||
if (tPerSide > 0) {
|
||||
addQty(items, `tourillon:${m.reinforcementFixing.tourillonDiameter || 8}x30`, {
|
||||
key: `tourillon:${m.reinforcementFixing.tourillonDiameter || 8}x30`,
|
||||
label: `Tourillon Ø${m.reinforcementFixing.tourillonDiameter || 8}×30`,
|
||||
quantity: qty * sideCount * tPerSide,
|
||||
unit: 'pc',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (m.blockAssembly?.middleCount && ['panneauxInterieurs', 'cotes'].includes(p?.elementType)) {
|
||||
// Note: block assembly “vis de jonction” doesn't provide exact count per piece,
|
||||
// so we do not count it here to avoid misleading totals.
|
||||
}
|
||||
|
||||
if (p?.elementType === 'tiroirs') {
|
||||
drawersCount += qty;
|
||||
}
|
||||
|
||||
// Fixation "dos" (paroi arrière) — 4 côtés (gauche/droite + haut/bas).
|
||||
// Règle atelier (pour une longueur E = L ou H):
|
||||
// - par côté: 1 pièce à 5cm de chaque extrémité (=> 2)
|
||||
// - + 1 pièce tous les 30cm disponibles entre les 2 extrémités
|
||||
// Donc: n(E) = 2 + floor(max(0, E-100) / 300) (E en mm)
|
||||
if (p?.elementType === 'dos' && wMm > 0 && qty > 0) {
|
||||
const hMm = Number(p?.heightMm) || 0;
|
||||
const perEdgeW = 2 + Math.floor(Math.max(0, wMm - 100) / 300);
|
||||
const perEdgeH = hMm > 0 ? 2 + Math.floor(Math.max(0, hMm - 100) / 300) : 0;
|
||||
const totalPerPanel = perEdgeW * 2 + perEdgeH * 2;
|
||||
const total = qty * totalPerPanel;
|
||||
addQty(items, 'back_panel_fastener_ixconnect_rpc_g_13_20', {
|
||||
key: 'back_panel_fastener_ixconnect_rpc_g_13_20',
|
||||
label: 'Ferrure dos — Häfele Ixconnect RPC G 13/20 (rainure)',
|
||||
quantity: total,
|
||||
unit: 'pc',
|
||||
productUrl: HAEFELE_PRODUCT_URLS.back_panel_fastener_ixconnect_rpc_g_13_20,
|
||||
meta: { model: 'Ixconnect RPC G 13/20' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { items: Array.from(items.values()), doorCount, drawersCount };
|
||||
}
|
||||
|
||||
export async function generateHardwareList({ config, modules = [], colors = {} }) {
|
||||
const cuttingList = generateCuttingList(config, colors, modules);
|
||||
const { items, doorCount } = computeCountsFromCuttingList(cuttingList);
|
||||
|
||||
// Add plinth clips (simple deterministic rule)
|
||||
const widthMm = Number(config?.widthMm) || 0;
|
||||
if (widthMm > 0) {
|
||||
const clips = Math.max(0, Math.ceil((widthMm / 1000) * (Number(PLINTH.clipsPerMeter) || 0)));
|
||||
if (clips > 0) {
|
||||
items.push({
|
||||
key: 'plinth_clips',
|
||||
label: `Clips plinthe (${PLINTH.model})`,
|
||||
quantity: clips,
|
||||
unit: 'pc',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Selected variants pricing (if configured)
|
||||
const handleVariantId = config?.hardware?.handleVariantId;
|
||||
const hingeVariantId = config?.hardware?.hingeVariantId;
|
||||
const drawerSystemId = config?.hardware?.drawerSystemId;
|
||||
|
||||
const [handleVariant, hingeVariant, drawerSystem] = await Promise.all([
|
||||
priceById('handle_variants', handleVariantId),
|
||||
priceById('hinge_variants', hingeVariantId),
|
||||
priceById('blum_hardware', drawerSystemId),
|
||||
]);
|
||||
|
||||
// Add handles as an item when selected
|
||||
if (handleVariant && doorCount > 0) {
|
||||
items.push({
|
||||
key: `handle_variant:${handleVariant.id}`,
|
||||
label: `Poignée — ${handleVariant.name}`,
|
||||
quantity: doorCount,
|
||||
unit: 'pc',
|
||||
unitPriceChf: handleVariant.unitPriceChf,
|
||||
source: handleVariant.source,
|
||||
code: handleVariant.code,
|
||||
});
|
||||
}
|
||||
|
||||
// Attach hinge price to computed hinges (prefer synced Häfele, even without selected variant)
|
||||
const hingeUrl = HAEFELE_PRODUCT_URLS.hinge_blum_clip_top_blumotion_110;
|
||||
const haefeleHinge = await findHaefeleMaterialByUrl(hingeUrl);
|
||||
for (const it of items) {
|
||||
if (it.key !== 'hinge') continue;
|
||||
|
||||
if (hingeVariant) {
|
||||
it.label = `Charnière — ${hingeVariant.name}`;
|
||||
it.source = it.source ?? hingeVariant.source;
|
||||
it.code = it.code ?? hingeVariant.code;
|
||||
it.unitPriceChf = it.unitPriceChf ?? hingeVariant.unitPriceChf;
|
||||
}
|
||||
|
||||
// Always attach product URL for the hinge line (useful for admins to sync/verify).
|
||||
it.productUrl = it.productUrl ?? hingeUrl;
|
||||
|
||||
// Prefer synced Häfele data when available.
|
||||
if (haefeleHinge) {
|
||||
it.unitPriceChf = haefeleHinge.unitPriceChf ?? it.unitPriceChf;
|
||||
it.productUrl = haefeleHinge.url || it.productUrl;
|
||||
it.imageUrl = it.imageUrl ?? haefeleHinge.imageUrl;
|
||||
it.unitLabel = it.unitLabel ?? haefeleHinge.unitLabel;
|
||||
it.code = haefeleHinge.code || it.code;
|
||||
it.source = it.source ?? haefeleHinge.source;
|
||||
}
|
||||
}
|
||||
|
||||
// Attach carcass fastener (Ferrure corps / jonction) reference + price
|
||||
const carcassUrl = CARCASS_FASTENERS.productUrl;
|
||||
const haefeleCarcass = await findHaefeleMaterialByUrl(carcassUrl);
|
||||
for (const it of items) {
|
||||
if (it.key !== 'ferrure_carcasse') continue;
|
||||
it.productUrl = it.productUrl ?? carcassUrl;
|
||||
if (haefeleCarcass) {
|
||||
it.unitPriceChf = haefeleCarcass.unitPriceChf ?? it.unitPriceChf;
|
||||
it.productUrl = haefeleCarcass.url || it.productUrl;
|
||||
it.imageUrl = it.imageUrl ?? haefeleCarcass.imageUrl;
|
||||
it.unitLabel = it.unitLabel ?? haefeleCarcass.unitLabel;
|
||||
it.code = haefeleCarcass.code || it.code;
|
||||
it.source = it.source ?? haefeleCarcass.source;
|
||||
// Keep user-facing label stable but include model when present.
|
||||
it.label = it.label || `Ferrure corps (jonction) — ${CARCASS_FASTENERS.model}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Attach back panel fastener (dos) reference + price
|
||||
const backFastenerUrl = HAEFELE_PRODUCT_URLS.back_panel_fastener_ixconnect_rpc_g_13_20;
|
||||
const haefeleBackFastener = await findHaefeleMaterialByUrl(backFastenerUrl);
|
||||
for (const it of items) {
|
||||
if (it.key !== 'back_panel_fastener_ixconnect_rpc_g_13_20') continue;
|
||||
it.productUrl = it.productUrl ?? backFastenerUrl;
|
||||
if (haefeleBackFastener) {
|
||||
it.unitPriceChf = haefeleBackFastener.unitPriceChf ?? it.unitPriceChf;
|
||||
it.productUrl = haefeleBackFastener.url || it.productUrl;
|
||||
it.imageUrl = it.imageUrl ?? haefeleBackFastener.imageUrl;
|
||||
it.unitLabel = it.unitLabel ?? haefeleBackFastener.unitLabel;
|
||||
it.code = haefeleBackFastener.code || it.code;
|
||||
it.source = it.source ?? haefeleBackFastener.source;
|
||||
}
|
||||
}
|
||||
|
||||
// Drawer system: assume priced “per drawer”
|
||||
if (drawerSystem) {
|
||||
const totalDrawers = (Array.isArray(config?.modules) ? config.modules : []).reduce((s, m) => s + (Number(m?.drawers) || 0), 0);
|
||||
if (totalDrawers > 0) {
|
||||
items.push({
|
||||
key: `drawer_system:${drawerSystem.id}`,
|
||||
label: `Système tiroirs — ${drawerSystem.name}`,
|
||||
quantity: totalDrawers,
|
||||
unit: 'pc',
|
||||
unitPriceChf: drawerSystem.unitPriceChf,
|
||||
source: drawerSystem.source,
|
||||
code: drawerSystem.code,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Price Häfele items (best-effort by name match)
|
||||
const haefeleLookups = new Map();
|
||||
for (const it of items) {
|
||||
const model = it?.meta?.model;
|
||||
if (!model) continue;
|
||||
if (!haefeleLookups.has(model)) {
|
||||
haefeleLookups.set(model, findHaefeleMaterialForModel(model));
|
||||
}
|
||||
}
|
||||
const haefeleResolved = new Map();
|
||||
await Promise.all(
|
||||
Array.from(haefeleLookups.entries()).map(async ([model, prom]) => {
|
||||
const r = await prom;
|
||||
haefeleResolved.set(model, r);
|
||||
})
|
||||
);
|
||||
for (const it of items) {
|
||||
const model = it?.meta?.model;
|
||||
if (!model) continue;
|
||||
const mat = haefeleResolved.get(model);
|
||||
if (!mat) continue;
|
||||
// For Häfele-connectors (SC 8/60, SC 8/25, SC 8/65...), prefer synced Häfele price when available.
|
||||
it.unitPriceChf = mat.unitPriceChf ?? it.unitPriceChf;
|
||||
it.source = it.source ?? mat.source;
|
||||
it.code = it.code ?? mat.code;
|
||||
it.productUrl = it.productUrl ?? mat.url;
|
||||
it.imageUrl = it.imageUrl ?? mat.imageUrl;
|
||||
it.unitLabel = it.unitLabel ?? mat.unitLabel;
|
||||
}
|
||||
|
||||
// Compute totals
|
||||
const normalized = items
|
||||
.filter((it) => (Number(it.quantity) || 0) > 0)
|
||||
.map((it) => {
|
||||
let unitPriceChf = it.unitPriceChf == null ? null : toMoney(it.unitPriceChf);
|
||||
const quantity = Math.max(0, Number(it.quantity) || 0);
|
||||
let unitLabel = it.unitLabel || null;
|
||||
|
||||
// Häfele sometimes provides prices as "par 100 pièces" etc.
|
||||
// Our UI + totals expect unitPriceChf to be per displayed `unit` (usually per pc).
|
||||
if (unitPriceChf != null) {
|
||||
const packSize = pricePackSizeFromUnitLabel(it.unitLabel, it.unit || 'pc');
|
||||
if (packSize) {
|
||||
unitPriceChf = toMoney(unitPriceChf / packSize);
|
||||
unitLabel = 'par pièce (PC)';
|
||||
}
|
||||
}
|
||||
|
||||
const lineTotalChf = unitPriceChf == null ? null : toMoney(unitPriceChf * quantity);
|
||||
return {
|
||||
key: String(it.key),
|
||||
label: String(it.label),
|
||||
code: it.code || null,
|
||||
quantity,
|
||||
unit: it.unit || 'pc',
|
||||
unitPriceChf,
|
||||
lineTotalChf,
|
||||
source: it.source || null,
|
||||
productUrl: it.productUrl || null,
|
||||
imageUrl: it.imageUrl || null,
|
||||
unitLabel,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label, 'fr'));
|
||||
|
||||
const totalChf = toMoney(
|
||||
normalized.reduce((s, it) => s + (Number(it.lineTotalChf) || 0), 0)
|
||||
);
|
||||
|
||||
return { items: normalized, totalChf };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Perçages par pièce pour exports CNC (.nc) et MPR (HOMAC/Woodwop XML-like).
|
||||
* Même jeu de coordonnées que la vue liste / G-code commentaires : origine bas-gauche, X/Y en mm.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildTopBottomPlanHoleSpecsMm,
|
||||
buildVerticalPanelIxconnectFaceHoleSpecsMm,
|
||||
buildShelfMobileHoleSpecsMm,
|
||||
buildFinishHabillageHoleSpecsMm,
|
||||
buildDoorHingeCupHoleSpecsMm,
|
||||
buildDrawerReinforcementHoleSpecsMm,
|
||||
buildDrawerRenfortPanelIxconnectSpecsMm
|
||||
} from '../../shared/cncPanelHoles.js';
|
||||
|
||||
export function collectHoleSpecsForPiece(piece, widthMm, heightMm, partIndex = 0) {
|
||||
const specs = [];
|
||||
|
||||
function pushHole(params) {
|
||||
specs.push(params);
|
||||
}
|
||||
|
||||
const m = piece?.machining || {};
|
||||
const thicknessMm = Math.max(1, Number(piece?.thicknessMm) || 19);
|
||||
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
|
||||
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
|
||||
|
||||
if (m.system32?.positions?.length) {
|
||||
const frontX = clampX(37);
|
||||
const backX = clampX(widthMm - 37);
|
||||
const d = Number(m.system32.holeDiameter || 5);
|
||||
const z = Number(m.system32.holeDepth || piece.thicknessMm || 12);
|
||||
const throughHole = Boolean(m.system32.throughHole);
|
||||
for (const pos of m.system32.positions) {
|
||||
const y = clampY(pos);
|
||||
pushHole({
|
||||
xMm: frontX,
|
||||
yMm: y,
|
||||
diameterMm: d,
|
||||
depthMm: z,
|
||||
type: 'system32',
|
||||
face: 'face',
|
||||
toolMode: throughHole ? 'through' : 'blind'
|
||||
});
|
||||
pushHole({
|
||||
xMm: backX,
|
||||
yMm: y,
|
||||
diameterMm: d,
|
||||
depthMm: z,
|
||||
type: 'system32',
|
||||
face: 'face',
|
||||
toolMode: throughHole ? 'through' : 'blind'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (['dessus', 'fond'].includes(piece.elementType || '')) {
|
||||
for (const spec of buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm)) {
|
||||
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
|
||||
}
|
||||
}
|
||||
|
||||
if (['cotes', 'panneauxInterieurs'].includes(piece.elementType || '')) {
|
||||
for (const spec of buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heightMm)) {
|
||||
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
|
||||
}
|
||||
for (const spec of buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm, partIndex)) {
|
||||
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
|
||||
}
|
||||
}
|
||||
|
||||
if (['cotes', 'habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus'].includes(piece.elementType || '')) {
|
||||
for (const spec of buildFinishHabillageHoleSpecsMm(piece, widthMm, heightMm)) {
|
||||
pushHole({ ...spec, toolMode: 'blind' });
|
||||
}
|
||||
}
|
||||
|
||||
if (piece.elementType === 'etageres' || piece.elementType === 'rayonFixe') {
|
||||
for (const spec of buildShelfMobileHoleSpecsMm(piece, widthMm, heightMm)) {
|
||||
pushHole({ ...spec, toolMode: spec.face === 'end' ? 'edge' : 'blind' });
|
||||
}
|
||||
}
|
||||
|
||||
if (piece.elementType === 'renfortsTiroir') {
|
||||
for (const spec of buildDrawerReinforcementHoleSpecsMm(piece, widthMm, heightMm)) {
|
||||
pushHole({ ...spec, toolMode: 'blind' });
|
||||
}
|
||||
}
|
||||
|
||||
if (piece.elementType === 'portes') {
|
||||
for (const spec of buildDoorHingeCupHoleSpecsMm(piece, widthMm, heightMm)) {
|
||||
pushHole({ ...spec, toolMode: 'blind', face: 'face' });
|
||||
}
|
||||
}
|
||||
|
||||
if (m.reinforcementFixing) {
|
||||
const rf = m.reinforcementFixing;
|
||||
const dIx = Number(rf.edgeHole?.diameter || 8);
|
||||
const dT = Number(rf.tourillonDiameter || 8);
|
||||
const zIx = Number(rf.edgeHole?.depth || 45);
|
||||
const zT = Number(piece.thicknessMm || 16);
|
||||
const yFromTop = (mm) => clampY(heightMm - Number(mm));
|
||||
|
||||
for (const end of ['left', 'right']) {
|
||||
const xEnd = end === 'left' ? 0 : widthMm;
|
||||
if (Number.isFinite(Number(rf.ixconnectAxisFromTopMm))) {
|
||||
pushHole({
|
||||
xMm: clampX(xEnd),
|
||||
yMm: yFromTop(rf.ixconnectAxisFromTopMm),
|
||||
diameterMm: dIx,
|
||||
depthMm: zIx,
|
||||
type: 'ixconnect',
|
||||
face: 'end',
|
||||
end,
|
||||
toolMode: 'edge'
|
||||
});
|
||||
}
|
||||
for (const a of rf.tourillonAxesFromTopMm || []) {
|
||||
pushHole({
|
||||
xMm: clampX(xEnd),
|
||||
yMm: yFromTop(a),
|
||||
diameterMm: dT,
|
||||
depthMm: zT,
|
||||
type: 'tourillon',
|
||||
face: 'end',
|
||||
end,
|
||||
toolMode: 'edge'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { specs, thicknessMm };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
let transporter = null;
|
||||
|
||||
function getTransporter() {
|
||||
if (transporter) return transporter;
|
||||
|
||||
const host = process.env.SMTP_HOST;
|
||||
const port = Number(process.env.SMTP_PORT) || 587;
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
throw new Error('SMTP configuration manquante (SMTP_HOST, SMTP_USER, SMTP_PASS)');
|
||||
}
|
||||
|
||||
transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure: port === 465,
|
||||
auth: { user, pass }
|
||||
});
|
||||
|
||||
return transporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email.
|
||||
* @param {{ to: string, subject: string, html: string }} options
|
||||
*/
|
||||
export async function sendEmail({ to, subject, html }) {
|
||||
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
|
||||
const t = getTransporter();
|
||||
await t.sendMail({ from, to, subject, html });
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* Export MPR (WOODWOP / Homag-family .mpr) pour le débit APEX — un fichier par panneau, ZIP.
|
||||
* Coordonnées XY = même repère que le G-code (.nc commentaires): origine bas-gauche, mm.
|
||||
* Macros RSO/* : chemins exemple — à présence confirmée sur le poste/cellule.
|
||||
*/
|
||||
|
||||
import JSZip from 'jszip';
|
||||
import { generateCuttingList } from './cuttingEngine.js';
|
||||
import { collectHoleSpecsForPiece } from './holeSpecifications.js';
|
||||
import { GROOVE, getBackGroovePlanOrientation, BACK_GROOVE_PLAN_VERTICAL_TYPES } from '../../shared/manufacturing.js';
|
||||
|
||||
const MPR_ARTICLE_CODES = Object.freeze({
|
||||
plintheBas: 'PL_BAS',
|
||||
bandeauHaut: 'BH',
|
||||
dessus: 'BO_U',
|
||||
fond: 'BO_L',
|
||||
cotes: 'SE',
|
||||
cotesDoublage: 'CD',
|
||||
fileur: 'FI',
|
||||
habillageFinitionGauche: 'FR_L_G',
|
||||
habillageFinitionDroite: 'FR_L_D',
|
||||
habillageFinitionDessus: 'FR_H',
|
||||
dos: 'DO',
|
||||
renforts: 'RE',
|
||||
portes: 'TU',
|
||||
panneauxInterieurs: 'ZA_O',
|
||||
rayonFixe: 'RF_X',
|
||||
etageres: 'EB_M',
|
||||
renfortsTiroir: 'LABO'
|
||||
});
|
||||
|
||||
function fmtMm(n) {
|
||||
const v = Number(n);
|
||||
if (!Number.isFinite(v)) return '0.0';
|
||||
const rounded = Math.round(v * 10) / 10;
|
||||
return Number.isInteger(rounded) ? `${rounded}.0` : `${rounded}`;
|
||||
}
|
||||
|
||||
function toSafe(value, fb) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fb;
|
||||
}
|
||||
|
||||
function escQuoted(s) {
|
||||
return String(s ?? '').replace(/"/g, "'");
|
||||
}
|
||||
|
||||
/** Scinde ligne « gauche+droit » qty 2 en deux fichiers SE_L / SE_R. */
|
||||
export function flattenPiecesForMprExport(pieces = []) {
|
||||
const out = [];
|
||||
for (const piece of pieces) {
|
||||
const qty = Math.max(1, Math.round(toSafe(piece.quantity, 1)));
|
||||
const splitSides =
|
||||
piece.elementType === 'cotes' &&
|
||||
qty === 2 &&
|
||||
piece.id === 'piece-cotes' &&
|
||||
String(piece.description || '').includes('gauche et droit');
|
||||
|
||||
if (splitSides) {
|
||||
out.push({ piece: { ...piece, quantity: 1 }, slug: 'SE_L', qtyIndex: 0 });
|
||||
out.push({ piece: { ...piece, quantity: 1 }, slug: 'SE_R', qtyIndex: 1 });
|
||||
continue;
|
||||
}
|
||||
|
||||
let baseSlug = MPR_ARTICLE_CODES[piece.elementType] || String(piece.elementType || '').toUpperCase() || 'PIECE';
|
||||
|
||||
for (let qi = 0; qi < qty; qi++) {
|
||||
let slug = baseSlug;
|
||||
if (
|
||||
qty > 1 &&
|
||||
['panneauxInterieurs', 'renforts', 'etageres', 'rayonFixe', 'bandeauHaut'].includes(piece.elementType || '')
|
||||
) {
|
||||
slug = `${baseSlug}_${qi + 1}`;
|
||||
}
|
||||
out.push({ piece, slug, qtyIndex: qi });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mprAnglesForPiece(piece) {
|
||||
const et = piece.elementType || '';
|
||||
if (habillageEtPorte(et)) {
|
||||
return {
|
||||
DRWIN: '270.0',
|
||||
beschGedreht: '0',
|
||||
turnMsg: et === 'portes' ? 'DREHEN X-ACHSE' : 'DREHEN X-ACHSE'
|
||||
};
|
||||
}
|
||||
if (['dessus', 'fond'].includes(et)) {
|
||||
return {
|
||||
DRWIN: '0.0',
|
||||
beschGedreht: '1',
|
||||
turnMsg: 'DREHEN Y-ACHSE'
|
||||
};
|
||||
}
|
||||
return {
|
||||
DRWIN: '0.0',
|
||||
beschGedreht: '0',
|
||||
turnMsg: 'DREHEN Y-ACHSE'
|
||||
};
|
||||
}
|
||||
|
||||
function habillageEtPorte(et) {
|
||||
return (
|
||||
['habillageFinitionGauche', 'habillageFinitionDroite', 'habillageFinitionDessus', 'portes'].includes(
|
||||
et
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Kantendicken : habillages façon exemple FR_* (tous faces 1 mm). */
|
||||
function kantenFlags(piece) {
|
||||
const fin = habillageEtPorte(piece.elementType || '');
|
||||
if (fin) {
|
||||
return { kavo: '1.0', kali: '1.0', kahi: '1.0', kare: '1.0' };
|
||||
}
|
||||
return { kavo: '1.0', kali: '0.0', kahi: '0.0', kare: '0.0' };
|
||||
}
|
||||
|
||||
function grooveSection(piece, _Lmm, _Bmm) {
|
||||
const m = piece?.machining || {};
|
||||
const orient = piece?.elementType ? getBackGroovePlanOrientation(piece.elementType) : null;
|
||||
if (!m.groove || !orient) return [];
|
||||
|
||||
const gw = fmtMm(Number(m.groove.width ?? GROOVE.back.width));
|
||||
const gd = fmtMm(Number(m.groove.depth ?? GROOVE.back.depth));
|
||||
const abst = fmtMm(Number(m.groove.axisFromPanelEdge ?? m.groove.axisFromBackEdge ?? GROOVE.back.axisFromPanelEdge));
|
||||
const vertical = BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(piece.elementType);
|
||||
const win = vertical ? '180.0' : '270.0';
|
||||
|
||||
return [
|
||||
'<121 \\Block\\',
|
||||
'MNM="Kantenbearbeitungen von oben"',
|
||||
'DP="1"',
|
||||
'KO="04"',
|
||||
'<139 \\Komponente\\',
|
||||
'IN="RSO/Makro/RSO_Nut_Flaeche.mpr"',
|
||||
'MNM="Nut Flaeche rueckseite"',
|
||||
'XA="L"',
|
||||
'YA="B"',
|
||||
'VA="LA L"',
|
||||
`VA="BR ${gw}"`,
|
||||
`VA="TI ${gd}"`,
|
||||
`VA="ABST ${abst}"`,
|
||||
`VA="WIN ${win}"`,
|
||||
'VA="ANF 0.0"',
|
||||
'VA="END 0.0"',
|
||||
'VA="MAT MAT"',
|
||||
'KO="04"'
|
||||
];
|
||||
}
|
||||
|
||||
/** Un trou → lignes BohrVert (projection face Z+ ; perçages chant restent approximation LS/LSL). */
|
||||
function bohrVertLines(spec, thicknessMm) {
|
||||
const dRaw = Number(spec.diameterMm) || 5;
|
||||
const depthRaw = Number(spec.depthMm) || 12;
|
||||
const edgeHint = Boolean(spec.toolMode === 'edge' || spec.face === 'end');
|
||||
const throughHint = Boolean(spec.toolMode === 'through');
|
||||
const looksThrough = depthRaw >= thicknessMm - 0.5;
|
||||
let bm = 'LS';
|
||||
let ti;
|
||||
if (throughHint || looksThrough) {
|
||||
bm = 'LSL';
|
||||
ti = `${fmtMm(thicknessMm)} + 1`;
|
||||
} else if (edgeHint && depthRaw >= 38) {
|
||||
bm = 'LSL';
|
||||
ti = fmtMm(depthRaw);
|
||||
} else {
|
||||
ti = fmtMm(depthRaw);
|
||||
}
|
||||
|
||||
const name = fmtMm(Math.round(dRaw * 10) / 10);
|
||||
const du = fmtMm(Math.round(dRaw * 10) / 10);
|
||||
const xa = fmtMm(spec.xMm);
|
||||
const ya = fmtMm(spec.yMm);
|
||||
|
||||
return [
|
||||
'<102 \\BohrVert\\',
|
||||
`MNM="Bohrung ${name} mm"`,
|
||||
`XA="${xa}"`,
|
||||
`YA="${ya}"`,
|
||||
`BM="${bm}"`,
|
||||
`TI="${ti}"`,
|
||||
`DU="${du}"`,
|
||||
'AN="1"',
|
||||
'MI="0"',
|
||||
'S_="2"',
|
||||
'ASG="2"',
|
||||
'MLM="0"',
|
||||
'KO="04"'
|
||||
];
|
||||
}
|
||||
|
||||
/** Supprime les doublons numériques (tolérance 0.02 mm — sans fusionner trous légitimes proches). */
|
||||
function dedupeHoleSpecs(specs, eps = 0.02) {
|
||||
const out = [];
|
||||
for (const s of specs) {
|
||||
const hit = out.find(
|
||||
(o) =>
|
||||
Math.abs(o.xMm - s.xMm) < eps &&
|
||||
Math.abs(o.yMm - s.yMm) < eps &&
|
||||
Math.abs((o.diameterMm || 0) - (s.diameterMm || 0)) < eps
|
||||
);
|
||||
if (!hit) out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildSingleMprText({
|
||||
piece,
|
||||
slug,
|
||||
qtyIndex,
|
||||
seqGlobal,
|
||||
groupIndex,
|
||||
orderReference,
|
||||
stackListKm,
|
||||
defaultMaterialArticle
|
||||
}) {
|
||||
const angles = mprAnglesForPiece(piece);
|
||||
const kant = kantenFlags(piece);
|
||||
|
||||
const L = toSafe(piece.rawCutWidthMm, piece.widthMm);
|
||||
const B = toSafe(piece.rawCutHeightMm, piece.heightMm);
|
||||
const D = toSafe(piece.thicknessMm, 19);
|
||||
|
||||
const posLabel = `${groupIndex}.${seqGlobal}`;
|
||||
const { specs, thicknessMm } = collectHoleSpecsForPiece(piece, L, B, qtyIndex);
|
||||
const unique = dedupeHoleSpecs(specs);
|
||||
|
||||
/* Bloc Bohrungen */
|
||||
const holeLines = [];
|
||||
for (const sp of unique) {
|
||||
holeLines.push(...bohrVertLines(sp, thicknessMm));
|
||||
}
|
||||
|
||||
const dp = Math.max(1, Math.min(98, holeLines.filter((ln) => ln.startsWith('<102')).length));
|
||||
|
||||
const matKm = escQuoted(
|
||||
piece.finishId != null && piece.finishId !== '' ? piece.finishId : defaultMaterialArticle || 'APEX'
|
||||
);
|
||||
|
||||
const lines = [];
|
||||
|
||||
lines.push('[H', 'VERSION="4.0 Alpha"', 'FW="1400"', 'FM="1"', 'ZS="40"', 'OP="1"');
|
||||
lines.push('[001', 'MAT="2"', 'KM="Materialart"', `DRWIN="${angles.DRWIN}"`, 'KM="Drehwinkel 0,90,180,270"');
|
||||
lines.push(`AUFT="${escQuoted(orderReference)}"`, `KM="AUFTRAG: ${escQuoted(orderReference)}"`);
|
||||
lines.push(`STKL="${escQuoted(stackListKm)}"`, `KM="STUECKLISTE: ${escQuoted(stackListKm)}"`);
|
||||
lines.push(`POS="${escQuoted(posLabel)}"`, `KM="POSITION: ${escQuoted(posLabel)}"`);
|
||||
lines.push('BEZ="0"', `KM="Bezeichnung: ${escQuoted(slug)}"`);
|
||||
lines.push(`L="${fmtMm(L)}"`, 'KM="Laenge in X"', `B="${fmtMm(B)}"`, 'KM="Breite in Y"', `D="${fmtMm(D)}"`, 'KM="Dicke in Z"');
|
||||
lines.push('ZF="2.0"', 'KM="Zugabe Fraesung"', 'ZB="3.0"', 'KM="Zugabe Bohrung"');
|
||||
lines.push('RITZEN="1.0"', 'KM="Vorritztiefe mm"');
|
||||
lines.push(`KAVODI="${kant.kavo}"`, 'KM="Kantendicke vorne"', `KALIDI="${kant.kali}"`, 'KM="Kantendicke links"');
|
||||
lines.push(`KAHIDI="${kant.kahi}"`, 'KM="Kantendicke hinten"', `KAREDI="${kant.kare}"`, 'KM="Kantendicke rechts"');
|
||||
lines.push('FORMAT="0"', 'KM="Formatieren 0 oder 1"');
|
||||
lines.push('ZUGLI="0.0"', 'ZUGRE="0.0"', 'ZUGVO="0.0"', 'ZUGHI="0.0"');
|
||||
lines.push('DW="IF DRWIN=0 OR DRWIN=90 OR DRWIN=180 THEN DRWIN ELSE 270"');
|
||||
lines.push('ld="IF DW=90 OR DW=270 THEN B ELSE L"', 'bd="IF DW=90 OR DW=270 THEN L ELSE B"');
|
||||
lines.push(
|
||||
'offxL="IF DW=180 THEN ZUGRE ELSE ZUGLI"',
|
||||
'offxB="IF DW=90 THEN ZUGHI ELSE ZUGVO"',
|
||||
'offyL="IF DW=180 THEN ZUGHI ELSE ZUGVO"',
|
||||
'offyB="IF DW=90 THEN ZUGLI ELSE ZUGRE"'
|
||||
);
|
||||
lines.push(
|
||||
'[K',
|
||||
'<00 \\Koordinatensystem\\',
|
||||
'NR="04"',
|
||||
'XP="IF DW=0 OR DW=270 THEN 0 ELSE ld"',
|
||||
'YP="IF DW=0 OR DW=90 THEN 0 ELSE bd"',
|
||||
'ZP="0"',
|
||||
'D1="DW"',
|
||||
'KI="0"',
|
||||
'D2="0"',
|
||||
'MI="0"',
|
||||
'<00 \\Koordinatensystem\\',
|
||||
'NR="F1"',
|
||||
'XP="IF DW=0 OR DW=270 THEN 0 ELSE ld"',
|
||||
'YP="IF DW=0 OR DW=90 THEN 0 ELSE bd"',
|
||||
'ZP="0"',
|
||||
'D1="DW"',
|
||||
'KI="90"',
|
||||
'D2="0"',
|
||||
'MI="0"',
|
||||
'<00 \\Koordinatensystem\\',
|
||||
'NR="F2"',
|
||||
'XP="IF DW=0 OR DW=270 THEN 0 ELSE ld"',
|
||||
'YP="IF DW=90 OR DW=0 THEN 0 ELSE bd"',
|
||||
'ZP="0"',
|
||||
'D1="DW+270"',
|
||||
'KI="90"',
|
||||
'D2="0"',
|
||||
'MI="1"',
|
||||
'<00 \\Koordinatensystem\\',
|
||||
'NR="F3"',
|
||||
'XP="IF DW=0 OR DW=90 THEN 0 ELSE ld"',
|
||||
'YP="IF DW=180 OR DW=90 THEN 0 ELSE bd"',
|
||||
'ZP="0"',
|
||||
'D1="DW+180"',
|
||||
'KI="90"',
|
||||
'D2="0"',
|
||||
'MI="1"',
|
||||
'<00 \\Koordinatensystem\\',
|
||||
'NR="F4"',
|
||||
'XP="IF DW=180 OR DW=270 THEN 0 ELSE ld"',
|
||||
'YP="IF DW=0 OR DW=270 THEN 0 ELSE bd"',
|
||||
'ZP="0"',
|
||||
'D1="DW+90"',
|
||||
'KI="90"',
|
||||
'D2="0"',
|
||||
'MI="0"',
|
||||
'<100 \\WerkStck\\',
|
||||
'LA="IF DW=90 OR DW=270 THEN B ELSE L"',
|
||||
'BR="IF DW=90 OR DW=270 THEN L ELSE B"',
|
||||
'DI="D"',
|
||||
'FNX="IF DW=90 OR DW=270 THEN offxB ELSE offxL"',
|
||||
'FNY="IF DW=90 OR DW=270 THEN offyB ELSE offyL"',
|
||||
'AX="IF DW=90 OR DW=270 THEN ZUGVO+ZUGHI ELSE ZUGLI+ZUGRE"',
|
||||
'AY="IF DW=90 OR DW=270 THEN ZUGLI+ZUGRE ELSE ZUGVO+ZUGHI"',
|
||||
'<101 \\Kommentar\\',
|
||||
`KM="AUFTRAG : ${escQuoted(orderReference)}"`,
|
||||
`KM="STUECKLISTE : ${escQuoted(stackListKm)}"`,
|
||||
`KM="POSITION : ${escQuoted(posLabel)}"`,
|
||||
`KM="Bezeichnung: ${escQuoted(slug)}"`,
|
||||
'KM=""',
|
||||
`KM="${escQuoted(angles.turnMsg)}"`,
|
||||
'KM=""',
|
||||
'KM="Teileinfo"',
|
||||
`KM="Groesse: ${fmtMm(L)} x ${fmtMm(B)}"`,
|
||||
`KM="Staerke: ${fmtMm(D)}"`,
|
||||
`KM=""`,
|
||||
`KM="Material: ${matKm}"`,
|
||||
'KM="Belag IN: "',
|
||||
'KM="Belag AU: "',
|
||||
'KM=""',
|
||||
'<139 \\Komponente\\',
|
||||
'MNM="Beschriftung"',
|
||||
'IN="RSO/Makro/RSO_Beschriftung.mpr"',
|
||||
'VA="L L"',
|
||||
'VA="B B"',
|
||||
'VA="KAVODI KAVODI"',
|
||||
'VA="KALIDI KALIDI"',
|
||||
'VA="KAHIDI KAHIDI"',
|
||||
'VA="KAREDI KAREDI"',
|
||||
`VA="GEDREHT ${angles.beschGedreht}"`,
|
||||
'VA="NCSTOPP 0"',
|
||||
'KO="04"',
|
||||
'<139 \\Komponente\\',
|
||||
'MNM="Formatierung"',
|
||||
'IN="RSO/Makro/RSO_Format.mpr"',
|
||||
'VA="L L"',
|
||||
'VA="B B"',
|
||||
'VA="D D"',
|
||||
'VA="WKZ 316"',
|
||||
'VA="KAVODI KAVODI"',
|
||||
'VA="KALIDI KALIDI"',
|
||||
'VA="KAHIDI KAHIDI"',
|
||||
'VA="KAREDI KAREDI"',
|
||||
'VA="DIST 0.0"',
|
||||
'VA="ECKRUND 0.1"',
|
||||
'VA="KAFR 0"',
|
||||
'VA="KAFRMAX 2.0"',
|
||||
'VA="MAT MAT"',
|
||||
'??="FORMAT"',
|
||||
'KO="04"',
|
||||
'<121 \\Block\\',
|
||||
'MNM="Bearbeitungen von oben "',
|
||||
`DP="${dp}"`,
|
||||
'KO="04"'
|
||||
);
|
||||
|
||||
lines.push(...holeLines);
|
||||
lines.push(...grooveSection(piece, L, B));
|
||||
lines.push('!');
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
|
||||
function attachGlobalSeq(pieceFlat, allFlat) {
|
||||
return pieceFlat.map((entry) => {
|
||||
const globalIdx = allFlat.findIndex(
|
||||
(f) =>
|
||||
f.piece.id === entry.piece.id &&
|
||||
f.qtyIndex === entry.qtyIndex &&
|
||||
f.slug === entry.slug
|
||||
);
|
||||
return { ...entry, seqGlobal: globalIdx >= 0 ? globalIdx + 1 : 1 };
|
||||
});
|
||||
}
|
||||
|
||||
function buildMprFileRecords(entries, mprOptions) {
|
||||
const { groupIndex = 1, orderReference = '', stackListKm = '01', defaultMaterialArticle = '' } = mprOptions;
|
||||
return entries.map((entry) => {
|
||||
const fileName = `${groupIndex}_${entry.seqGlobal}_${safeZipFileName(entry.slug)}.mpr`;
|
||||
const content = buildSingleMprText({
|
||||
piece: entry.piece,
|
||||
slug: entry.slug,
|
||||
qtyIndex: entry.qtyIndex,
|
||||
seqGlobal: entry.seqGlobal,
|
||||
groupIndex,
|
||||
orderReference,
|
||||
stackListKm,
|
||||
defaultMaterialArticle
|
||||
});
|
||||
return { fileName, content };
|
||||
});
|
||||
}
|
||||
|
||||
async function packMprRecords(records, zipBaseName) {
|
||||
if (records.length === 1) {
|
||||
const only = records[0];
|
||||
return {
|
||||
buffer: Buffer.from(only.content, 'utf8'),
|
||||
fileName: only.fileName,
|
||||
mimeType: 'application/octet-stream',
|
||||
fileCount: 1
|
||||
};
|
||||
}
|
||||
|
||||
const zip = new JSZip();
|
||||
for (const rec of records) {
|
||||
zip.file(rec.fileName, rec.content);
|
||||
}
|
||||
const safeBase = safeZipFileName(zipBaseName || 'piece-mpr');
|
||||
return {
|
||||
buffer: await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }),
|
||||
fileName: `${safeBase}.zip`,
|
||||
mimeType: 'application/zip',
|
||||
fileCount: records.length
|
||||
};
|
||||
}
|
||||
|
||||
/** Nombre de fichiers MPR générés pour une ligne de débitage (ex. côtés L+R → 2). */
|
||||
export function countMprInstancesForPiece(piece) {
|
||||
return flattenPiecesForMprExport([piece]).length;
|
||||
}
|
||||
|
||||
export async function generateMprZipBuffer({
|
||||
config,
|
||||
modules = [],
|
||||
colors = {},
|
||||
orderReference = '',
|
||||
stackListKm = '01',
|
||||
defaultMaterialArticle = '',
|
||||
/** Index groupe commande dans le nom fichier (premier nombre). */
|
||||
groupIndex = 1
|
||||
}) {
|
||||
const cuttingList = generateCuttingList(config, colors, modules);
|
||||
const flat = flattenPiecesForMprExport(cuttingList.pieces);
|
||||
const withSeq = attachGlobalSeq(flat, flat);
|
||||
const records = buildMprFileRecords(withSeq, {
|
||||
groupIndex,
|
||||
orderReference,
|
||||
stackListKm,
|
||||
defaultMaterialArticle
|
||||
});
|
||||
|
||||
return {
|
||||
...(await packMprRecords(records, `apex-mpr-g${groupIndex}`)),
|
||||
fileCount: records.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export MPR pour une seule ligne de la liste de débitage (identifiée par pieceId).
|
||||
* @param {number|null|undefined} instanceIndex — exemplaire 0-based ; omis = tous les exemplaires de la ligne.
|
||||
*/
|
||||
export async function generateMprPieceDownload({
|
||||
config,
|
||||
modules = [],
|
||||
colors = {},
|
||||
pieceId,
|
||||
instanceIndex,
|
||||
orderReference = '',
|
||||
stackListKm = '01',
|
||||
defaultMaterialArticle = '',
|
||||
groupIndex = 1
|
||||
}) {
|
||||
if (!pieceId || typeof pieceId !== 'string') {
|
||||
throw new Error('pieceId requis.');
|
||||
}
|
||||
|
||||
const cuttingList = generateCuttingList(config, colors, modules);
|
||||
const piece = (cuttingList.pieces || []).find((p) => p.id === pieceId);
|
||||
if (!piece) {
|
||||
throw new Error('Pièce introuvable dans la liste de débitage.');
|
||||
}
|
||||
|
||||
const allFlat = flattenPiecesForMprExport(cuttingList.pieces);
|
||||
let pieceFlat = attachGlobalSeq(flattenPiecesForMprExport([piece]), allFlat);
|
||||
|
||||
if (instanceIndex != null && instanceIndex !== '') {
|
||||
const idx = Math.floor(Number(instanceIndex));
|
||||
if (!Number.isFinite(idx) || idx < 0) {
|
||||
throw new Error('instanceIndex invalide.');
|
||||
}
|
||||
pieceFlat = pieceFlat.filter((entry) => entry.qtyIndex === idx);
|
||||
if (pieceFlat.length === 0) {
|
||||
throw new Error('Exemplaire introuvable pour cette pièce.');
|
||||
}
|
||||
}
|
||||
|
||||
const records = buildMprFileRecords(pieceFlat, {
|
||||
groupIndex,
|
||||
orderReference,
|
||||
stackListKm,
|
||||
defaultMaterialArticle
|
||||
});
|
||||
|
||||
return packMprRecords(records, pieceId);
|
||||
}
|
||||
|
||||
function safeZipFileName(seg) {
|
||||
return String(seg).replace(/[^\w\-]+/g, '_').replace(/_+/g, '_');
|
||||
}
|
||||
|
||||
export default {
|
||||
generateMprZipBuffer,
|
||||
generateMprPieceDownload,
|
||||
countMprInstancesForPiece,
|
||||
flattenPiecesForMprExport
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Packing Engine — APEX 4.0
|
||||
* Layer-based bin packing algorithm (per Dossier Technique Maître §3.1).
|
||||
*
|
||||
* Strategy:
|
||||
* Level 1 — Côtés empilés (side panels stacked flat)
|
||||
* Level 2 — Dessus + Fond côte à côte (top & bottom side by side)
|
||||
* Level 3 — Étagères + Renforts groupés par épaisseur (shelves & reinforcements)
|
||||
*
|
||||
* Within each level, pieces are packed using First-Fit-Decreasing by weight,
|
||||
* respecting a 28 kg max per box.
|
||||
*/
|
||||
|
||||
import { PACKING, PANEL } from '../../shared/manufacturing.js';
|
||||
|
||||
/**
|
||||
* Estimate the weight of a single piece in kg.
|
||||
* Uses approximate melamine panel density by thickness.
|
||||
* @param {number} widthMm
|
||||
* @param {number} heightMm
|
||||
* @param {number} thicknessMm
|
||||
* @returns {number} weight in kg for ONE piece
|
||||
*/
|
||||
function estimatePieceWeight(widthMm, heightMm, thicknessMm) {
|
||||
const volumeM3 = (widthMm / 1000) * (heightMm / 1000) * (thicknessMm / 1000);
|
||||
|
||||
let density;
|
||||
if (thicknessMm >= PANEL.structure) {
|
||||
density = PACKING.densityKgM3.melamine19;
|
||||
} else if (thicknessMm >= PANEL.reinforcement) {
|
||||
density = PACKING.densityKgM3.melamine16;
|
||||
} else {
|
||||
density = PACKING.densityKgM3.melamine8;
|
||||
}
|
||||
|
||||
return volumeM3 * density;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand cutting list pieces into individual packable items.
|
||||
* Each piece with quantity > 1 becomes multiple single items.
|
||||
* @param {Array} pieces - pieces from cutting list
|
||||
* @returns {Array} flat array of single items with weight
|
||||
*/
|
||||
function expandPieces(pieces) {
|
||||
const items = [];
|
||||
|
||||
for (const piece of pieces) {
|
||||
// Skip non-physical items (edge banding, etc.)
|
||||
if (piece.unit === 'ml' || piece.widthMm === 0 || piece.heightMm === 0) continue;
|
||||
|
||||
const weightPerPiece = estimatePieceWeight(piece.widthMm, piece.heightMm, piece.thicknessMm);
|
||||
|
||||
for (let i = 0; i < piece.quantity; i++) {
|
||||
items.push({
|
||||
pieceId: piece.id,
|
||||
elementType: piece.elementType,
|
||||
description: piece.description,
|
||||
widthMm: piece.widthMm,
|
||||
heightMm: piece.heightMm,
|
||||
thicknessMm: piece.thicknessMm,
|
||||
quantity: 1,
|
||||
weightKg: Math.round(weightPerPiece * 100) / 100,
|
||||
index: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify expanded items into packing layers per APEX 4.0 §3.1.
|
||||
*
|
||||
* Level 1: cotes (side panels) — stacked
|
||||
* Level 2: dessus + fond (top & bottom) — side by side
|
||||
* Level 3: everything else grouped by thickness (etageres, renforts, panneauxInterieurs, dos, etc.)
|
||||
*
|
||||
* @param {Array} items - expanded single-piece items
|
||||
* @returns {Object} { level1, level2, level3 } arrays
|
||||
*/
|
||||
function classifyByLayer(items) {
|
||||
const level1 = []; // Côtés
|
||||
const level2 = []; // Dessus + Fond
|
||||
const level3 = []; // Étagères, Renforts, Séparateurs, Dos, Portes, etc.
|
||||
|
||||
for (const item of items) {
|
||||
switch (item.elementType) {
|
||||
case 'cotes':
|
||||
case 'cotesDoublage':
|
||||
level1.push(item);
|
||||
break;
|
||||
case 'dessus':
|
||||
case 'fond':
|
||||
level2.push(item);
|
||||
break;
|
||||
default:
|
||||
level3.push(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort level 3 by thickness (group same thickness together) then by weight desc
|
||||
level3.sort((a, b) => {
|
||||
if (a.thicknessMm !== b.thicknessMm) return b.thicknessMm - a.thicknessMm;
|
||||
return b.weightKg - a.weightKg;
|
||||
});
|
||||
|
||||
return { level1, level2, level3 };
|
||||
}
|
||||
|
||||
/**
|
||||
* First-Fit-Decreasing bin packing for a set of items.
|
||||
* Sort items by weight (heaviest first), then assign each to the
|
||||
* first box that still has capacity.
|
||||
*
|
||||
* @param {Array} items - items to pack
|
||||
* @param {number} startBoxId - starting box ID
|
||||
* @param {string} levelLabel - label for these boxes (e.g. "Niveau 1")
|
||||
* @returns {{ boxes: Array, nextBoxId: number }}
|
||||
*/
|
||||
function firstFitDecreasing(items, startBoxId = 1, levelLabel = '') {
|
||||
if (items.length === 0) return { boxes: [], nextBoxId: startBoxId };
|
||||
|
||||
const sorted = [...items].sort((a, b) => b.weightKg - a.weightKg);
|
||||
const maxWeight = PACKING.maxWeightKg;
|
||||
const boxes = [];
|
||||
|
||||
for (const item of sorted) {
|
||||
// If the single item exceeds max weight, it gets its own box
|
||||
if (item.weightKg > maxWeight) {
|
||||
boxes.push({
|
||||
id: startBoxId + boxes.length,
|
||||
level: levelLabel,
|
||||
pieces: [item],
|
||||
totalWeightKg: item.weightKg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to fit in an existing box
|
||||
let placed = false;
|
||||
for (const box of boxes) {
|
||||
if (box.totalWeightKg + item.weightKg <= maxWeight) {
|
||||
box.pieces.push(item);
|
||||
box.totalWeightKg = Math.round((box.totalWeightKg + item.weightKg) * 100) / 100;
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Open a new box
|
||||
if (!placed) {
|
||||
boxes.push({
|
||||
id: startBoxId + boxes.length,
|
||||
level: levelLabel,
|
||||
pieces: [item],
|
||||
totalWeightKg: item.weightKg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { boxes, nextBoxId: startBoxId + boxes.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate packing list from a cutting list result.
|
||||
* Uses the APEX 4.0 layer-based strategy:
|
||||
* Level 1 → sides stacked
|
||||
* Level 2 → top + bottom side by side
|
||||
* Level 3 → shelves + reinforcements grouped by thickness
|
||||
*
|
||||
* Each level is independently bin-packed with FFD, then all boxes
|
||||
* are merged into a single sequential list.
|
||||
*
|
||||
* @param {Object} cuttingList - output of generateCuttingList()
|
||||
* @returns {Object} packing summary
|
||||
*/
|
||||
export function generatePackingList(cuttingList) {
|
||||
if (!cuttingList || !cuttingList.pieces || cuttingList.pieces.length === 0) {
|
||||
return {
|
||||
boxes: [],
|
||||
totalBoxes: 0,
|
||||
maxWeightKg: PACKING.maxWeightKg,
|
||||
heaviestBoxKg: 0,
|
||||
totalWeightKg: 0,
|
||||
strategy: 'layer-based (APEX 4.0)',
|
||||
};
|
||||
}
|
||||
|
||||
const items = expandPieces(cuttingList.pieces);
|
||||
const { level1, level2, level3 } = classifyByLayer(items);
|
||||
|
||||
// Pack each level independently
|
||||
const pack1 = firstFitDecreasing(level1, 1, 'Niveau 1 — Côtés');
|
||||
const pack2 = firstFitDecreasing(level2, pack1.nextBoxId, 'Niveau 2 — Dessus/Fond');
|
||||
const pack3 = firstFitDecreasing(level3, pack2.nextBoxId, 'Niveau 3 — Étagères/Renforts');
|
||||
|
||||
const allBoxes = [...pack1.boxes, ...pack2.boxes, ...pack3.boxes];
|
||||
|
||||
const heaviestBoxKg = allBoxes.reduce((max, b) => Math.max(max, b.totalWeightKg), 0);
|
||||
const totalWeightKg = allBoxes.reduce((sum, b) => sum + b.totalWeightKg, 0);
|
||||
|
||||
return {
|
||||
boxes: allBoxes,
|
||||
totalBoxes: allBoxes.length,
|
||||
maxWeightKg: PACKING.maxWeightKg,
|
||||
heaviestBoxKg: Math.round(heaviestBoxKg * 10) / 10,
|
||||
totalWeightKg: Math.round(totalWeightKg * 10) / 10,
|
||||
strategy: 'layer-based (APEX 4.0)',
|
||||
layerSummary: {
|
||||
level1: { label: 'Côtés empilés', boxes: pack1.boxes.length, items: level1.length },
|
||||
level2: { label: 'Dessus + Fond', boxes: pack2.boxes.length, items: level2.length },
|
||||
level3: { label: 'Étagères + Renforts', boxes: pack3.boxes.length, items: level3.length },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default { generatePackingList };
|
||||
+550
-150
@@ -1,26 +1,24 @@
|
||||
/**
|
||||
* Plan Generator - Generate 2D assembly and parts plans in SVG format
|
||||
* Also handles PDF export
|
||||
* Includes APEX 4.0 manufacturing details: System 32 drilling, hinge
|
||||
* positions, Ixconnect connectors, back panel groove, and reinforcements.
|
||||
* Also handles PDF export.
|
||||
*/
|
||||
|
||||
import {
|
||||
PANEL, GROOVE, SYSTEM32, SYSTEM32_SEPARATORS, HINGE, CONNECTORS,
|
||||
getHingeCountForDoor, getHingeCupPositions, getDoorHingeSide, HANDLE,
|
||||
EDGE_BANDING, REINFORCEMENT, REINFORCEMENT_MACHINING, PLINTH, SHELF_MACHINING
|
||||
} from '../../shared/manufacturing.js';
|
||||
|
||||
// Constants for drawing
|
||||
const DRAWING_SCALE = 0.1; // 1mm = 0.1px
|
||||
const MARGIN = 50;
|
||||
const LABEL_OFFSET = 15;
|
||||
const ARROW_SIZE = 8;
|
||||
|
||||
/**
|
||||
* Generate SVG for assembly view (front elevation)
|
||||
*/
|
||||
export function generateAssemblyPlan(config, modules = []) {
|
||||
const { widthMm, heightMm, plinthHeightMm, doorType, installationType } = config;
|
||||
const bodyHeight = heightMm - plinthHeightMm;
|
||||
|
||||
const svgWidth = widthMm * DRAWING_SCALE + MARGIN * 2;
|
||||
const svgHeight = heightMm * DRAWING_SCALE + MARGIN * 2;
|
||||
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
|
||||
svg += `<style>
|
||||
// ── Shared SVG styles ────────────────────────────────────────────────
|
||||
const SHARED_STYLES = `
|
||||
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
|
||||
.inner { fill: none; stroke: #666; stroke-width: 0.5; }
|
||||
.dimension { fill: none; stroke: #0066cc; stroke-width: 0.5; }
|
||||
@@ -31,140 +29,85 @@ export function generateAssemblyPlan(config, modules = []) {
|
||||
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
|
||||
.door { fill: #d4c4a4; stroke: #333; stroke-width: 1; opacity: 0.8; }
|
||||
.separator { stroke: #666; stroke-width: 0.5; stroke-dasharray: 4,2; }
|
||||
</style>`;
|
||||
.s32-hole { fill: none; stroke: #e63946; stroke-width: 0.4; }
|
||||
.hinge-marker { fill: #ff6b35; stroke: #333; stroke-width: 0.3; }
|
||||
.groove { fill: none; stroke: #2a9d8f; stroke-width: 1; stroke-dasharray: 2,2; }
|
||||
.connector { fill: #6c5ce7; stroke: #333; stroke-width: 0.3; }
|
||||
.renfort { fill: #c9a96e; stroke: #333; stroke-width: 0.5; opacity: 0.7; }
|
||||
.detail-label { font-family: Arial, sans-serif; font-size: 7px; fill: #666; }
|
||||
.fh-groove { fill: none; stroke: #0d9488; stroke-width: 1; stroke-dasharray: 3, 2; }
|
||||
`;
|
||||
|
||||
// Title
|
||||
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de face - Élévation</text>`;
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const offsetX = MARGIN;
|
||||
const offsetY = MARGIN;
|
||||
/**
|
||||
* Draw System 32 hole column (vertical series of holes)
|
||||
*/
|
||||
function drawSystem32Holes(x, topY, panelHeightMm, scale) {
|
||||
let svg = '';
|
||||
const usable = panelHeightMm - SYSTEM32.firstHoleOffset;
|
||||
const count = Math.max(0, Math.floor(usable / SYSTEM32.pitch) + 1);
|
||||
const r = (SYSTEM32.holeDiameter / 2) * scale;
|
||||
|
||||
// Plinth
|
||||
const plinthY = offsetY + bodyHeight * DRAWING_SCALE;
|
||||
svg += `<rect x="${offsetX}" y="${plinthY}" width="${widthMm * DRAWING_SCALE}" height="${plinthHeightMm * DRAWING_SCALE}" class="plinth" />`;
|
||||
|
||||
// Body
|
||||
svg += `<rect x="${offsetX}" y="${offsetY}" width="${widthMm * DRAWING_SCALE}" height="${bodyHeight * DRAWING_SCALE}" class="body" />`;
|
||||
|
||||
// Module separators
|
||||
if (modules.length > 0) {
|
||||
let currentX = offsetX;
|
||||
for (let i = 0; i < modules.length - 1; i++) {
|
||||
currentX += modules[i].widthMm * DRAWING_SCALE;
|
||||
svg += `<line x1="${currentX}" y1="${offsetY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const yOffset = (SYSTEM32.firstHoleOffset + i * SYSTEM32.pitch) * scale;
|
||||
svg += `<circle cx="${x}" cy="${topY + yOffset}" r="${r}" class="s32-hole" />`;
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
// Doors
|
||||
if (doorType === 'sliding') {
|
||||
const panelCount = config.slidingPanelsCount || 2;
|
||||
const panelWidth = (widthMm / panelCount) * DRAWING_SCALE;
|
||||
for (let i = 0; i < panelCount; i++) {
|
||||
svg += `<rect x="${offsetX + i * panelWidth * 0.95 + 5}" y="${offsetY + 5}" width="${panelWidth * 0.9}" height="${(bodyHeight - 10) * DRAWING_SCALE}" class="door" />`;
|
||||
/**
|
||||
* Draw hinge position markers on a door plan (côté charnières)
|
||||
*/
|
||||
function drawHingeMarkers(doorX, doorY, doorWidthMm, doorHeightMm, scale, hingeSide = 'left') {
|
||||
let svg = '';
|
||||
const markerH = 6;
|
||||
const markerW = 3;
|
||||
const hingeCount = getHingeCountForDoor({ heightMm: doorHeightMm });
|
||||
const positions = getHingeCupPositions(doorHeightMm, hingeCount);
|
||||
const doorW = doorWidthMm * scale;
|
||||
const cupInset = Math.min(22 * scale, doorW * 0.13);
|
||||
const hingeLineX = hingeSide === 'right' ? doorX + doorW - cupInset : doorX + cupInset;
|
||||
|
||||
for (const posMm of positions) {
|
||||
const y = doorY + posMm * scale;
|
||||
svg += `<rect x="${hingeLineX - markerW / 2}" y="${y - markerH / 2}" width="${markerW}" height="${markerH}" class="hinge-marker" />`;
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rainure poignée toute hauteur — côté opposé aux charnières, retrait H/B (HANDLE.fullHeightTopBottomOffset)
|
||||
*/
|
||||
function drawFullHeightHandleGrooveElevation(doorX, doorY, doorWidthMm, doorHeightMm, scale, hingeSide) {
|
||||
const off = HANDLE.fullHeightTopBottomOffset;
|
||||
const gw = HANDLE.fullHeightGroove.width;
|
||||
const doorW = doorWidthMm * scale;
|
||||
const axisX = hingeSide === 'right'
|
||||
? doorX + (gw / 2) * scale
|
||||
: doorX + doorW - (gw / 2) * scale;
|
||||
const y1 = doorY + off * scale;
|
||||
const y2 = doorY + doorHeightMm * scale - off * scale;
|
||||
let svg = `<line x1="${axisX}" y1="${y1}" x2="${axisX}" y2="${y2}" class="fh-groove" />`;
|
||||
const tx = axisX + (hingeSide === 'right' ? 5 : -5);
|
||||
const anchor = hingeSide === 'right' ? 'start' : 'end';
|
||||
svg += `<text x="${tx}" y="${doorY + doorHeightMm * scale * 0.45}" class="detail-label" text-anchor="${anchor}" fill="#0d9488">Rainure poignée TH (${gw} mm) · ${off} mm H/B</text>`;
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw back panel groove indicator (dashed line near panel edge).
|
||||
* Per updated spec: groove at 16mm from panel edge.
|
||||
*/
|
||||
function drawGrooveIndicator(x, y, length, direction, scale) {
|
||||
let svg = '';
|
||||
const grooveOffset = GROOVE.back.axisFromPanelEdge * scale;
|
||||
|
||||
if (direction === 'vertical') {
|
||||
svg += `<line x1="${x + grooveOffset}" y1="${y}" x2="${x + grooveOffset}" y2="${y + length}" class="groove" />`;
|
||||
} else {
|
||||
const blockCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
|
||||
let currentX = offsetX;
|
||||
const items = modules.length > 0 ? modules : (config.blocks || []);
|
||||
for (let i = 0; i < blockCount; i++) {
|
||||
const blockWidth = items[i]?.widthMm || (widthMm / blockCount);
|
||||
svg += `<rect x="${currentX + 3}" y="${offsetY + 3}" width="${blockWidth * DRAWING_SCALE - 6}" height="${(bodyHeight - 6) * DRAWING_SCALE}" class="door" />`;
|
||||
currentX += blockWidth * DRAWING_SCALE;
|
||||
svg += `<line x1="${x}" y1="${y + grooveOffset}" x2="${x + length}" y2="${y + grooveOffset}" class="groove" />`;
|
||||
}
|
||||
}
|
||||
|
||||
// Dimension lines
|
||||
svg += generateDimensionLine(offsetX, plinthY + plinthHeightMm * DRAWING_SCALE + 20, widthMm * DRAWING_SCALE, `${widthMm} mm`, 'horizontal');
|
||||
svg += generateDimensionLine(offsetX - 20, offsetY, bodyHeight * DRAWING_SCALE, `${bodyHeight} mm`, 'vertical');
|
||||
svg += generateDimensionLine(offsetX - 40, offsetY, heightMm * DRAWING_SCALE, `${heightMm} mm (total)`, 'vertical');
|
||||
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SVG for side view
|
||||
*/
|
||||
export function generateSidePlan(config) {
|
||||
const { heightMm, depthMm, plinthHeightMm } = config;
|
||||
const bodyHeight = heightMm - plinthHeightMm;
|
||||
|
||||
const svgWidth = depthMm * DRAWING_SCALE + MARGIN * 2;
|
||||
const svgHeight = heightMm * DRAWING_SCALE + MARGIN * 2;
|
||||
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
|
||||
svg += `<style>
|
||||
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
|
||||
.plinth { fill: #e0d0c0; stroke: #333; stroke-width: 1; }
|
||||
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
|
||||
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
|
||||
.dim-text { font-family: Arial, sans-serif; font-size: 10px; fill: #0066cc; }
|
||||
</style>`;
|
||||
|
||||
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de côté</text>`;
|
||||
|
||||
const offsetX = MARGIN;
|
||||
const offsetY = MARGIN;
|
||||
|
||||
// Plinth
|
||||
const plinthY = offsetY + bodyHeight * DRAWING_SCALE;
|
||||
svg += `<rect x="${offsetX}" y="${plinthY}" width="${depthMm * DRAWING_SCALE}" height="${plinthHeightMm * DRAWING_SCALE}" class="plinth" />`;
|
||||
|
||||
// Body
|
||||
svg += `<rect x="${offsetX}" y="${offsetY}" width="${depthMm * DRAWING_SCALE}" height="${bodyHeight * DRAWING_SCALE}" class="body" />`;
|
||||
|
||||
// Dimension
|
||||
svg += generateDimensionLine(offsetX, plinthY + plinthHeightMm * DRAWING_SCALE + 20, depthMm * DRAWING_SCALE, `${depthMm} mm`, 'horizontal');
|
||||
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SVG for top view
|
||||
*/
|
||||
export function generateTopPlan(config, modules = []) {
|
||||
const { widthMm, depthMm } = config;
|
||||
|
||||
const svgWidth = widthMm * DRAWING_SCALE + MARGIN * 2;
|
||||
const svgHeight = depthMm * DRAWING_SCALE + MARGIN * 2;
|
||||
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
|
||||
svg += `<style>
|
||||
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
|
||||
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
|
||||
.separator { stroke: #666; stroke-width: 0.5; stroke-dasharray: 4,2; }
|
||||
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
|
||||
.label { font-family: Arial, sans-serif; font-size: 8px; fill: #666; }
|
||||
</style>`;
|
||||
|
||||
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de dessus</text>`;
|
||||
|
||||
const offsetX = MARGIN;
|
||||
const offsetY = MARGIN;
|
||||
|
||||
// Body outline
|
||||
svg += `<rect x="${offsetX}" y="${offsetY}" width="${widthMm * DRAWING_SCALE}" height="${depthMm * DRAWING_SCALE}" class="body" />`;
|
||||
|
||||
// Module separators and labels
|
||||
if (modules.length > 0) {
|
||||
let currentX = offsetX;
|
||||
for (let i = 0; i < modules.length; i++) {
|
||||
const module = modules[i];
|
||||
const moduleWidth = module.widthMm * DRAWING_SCALE;
|
||||
|
||||
// Label
|
||||
svg += `<text x="${currentX + moduleWidth / 2}" y="${offsetY + depthMm * DRAWING_SCALE / 2}" class="label" text-anchor="middle">${module.moduleCode || `M${i + 1}`}</text>`;
|
||||
|
||||
// Separator
|
||||
if (i < modules.length - 1) {
|
||||
currentX += moduleWidth;
|
||||
svg += `<line x1="${currentX}" y1="${offsetY}" x2="${currentX}" y2="${offsetY + depthMm * DRAWING_SCALE}" class="separator" />`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
}
|
||||
|
||||
@@ -175,26 +118,367 @@ function generateDimensionLine(x, y, length, label, direction = 'horizontal') {
|
||||
let svg = '';
|
||||
|
||||
if (direction === 'horizontal') {
|
||||
// Line
|
||||
svg += `<line x1="${x}" y1="${y}" x2="${x + length}" y2="${y}" class="dimension" />`;
|
||||
// Arrows
|
||||
svg += `<polygon points="${x},${y} ${x + ARROW_SIZE},${y - 3} ${x + ARROW_SIZE},${y + 3}" fill="#0066cc" />`;
|
||||
svg += `<polygon points="${x + length},${y} ${x + length - ARROW_SIZE},${y - 3} ${x + length - ARROW_SIZE},${y + 3}" fill="#0066cc" />`;
|
||||
// Label
|
||||
svg += `<text x="${x + length / 2}" y="${y + LABEL_OFFSET}" class="dim-text" text-anchor="middle">${label}</text>`;
|
||||
} else {
|
||||
// Line
|
||||
svg += `<line x1="${x}" y1="${y}" x2="${x}" y2="${y + length}" class="dimension" />`;
|
||||
// Arrows
|
||||
svg += `<polygon points="${x},${y} ${x - 3},${y + ARROW_SIZE} ${x + 3},${y + ARROW_SIZE}" fill="#0066cc" />`;
|
||||
svg += `<polygon points="${x},${y + length} ${x - 3},${y + length - ARROW_SIZE} ${x + 3},${y + length - ARROW_SIZE}" fill="#0066cc" />`;
|
||||
// Label (rotated)
|
||||
svg += `<text x="${x - LABEL_OFFSET}" y="${y + length / 2}" class="dim-text" text-anchor="middle" transform="rotate(-90 ${x - LABEL_OFFSET} ${y + length / 2})">${label}</text>`;
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
// ── Plan generators ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate SVG for assembly view (front elevation)
|
||||
*/
|
||||
export function generateAssemblyPlan(config, modules = []) {
|
||||
const { widthMm, heightMm, plinthHeightMm, doorType } = config;
|
||||
const bodyHeight = heightMm - plinthHeightMm;
|
||||
const S = DRAWING_SCALE;
|
||||
|
||||
const svgWidth = widthMm * S + MARGIN * 2;
|
||||
const svgHeight = heightMm * S + MARGIN * 2;
|
||||
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
|
||||
svg += `<style>${SHARED_STYLES}</style>`;
|
||||
|
||||
// Title
|
||||
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de face - Élévation</text>`;
|
||||
|
||||
const oX = MARGIN;
|
||||
const oY = MARGIN;
|
||||
|
||||
// Plinth
|
||||
const plinthY = oY + bodyHeight * S;
|
||||
svg += `<rect x="${oX}" y="${plinthY}" width="${widthMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
|
||||
|
||||
// Body
|
||||
svg += `<rect x="${oX}" y="${oY}" width="${widthMm * S}" height="${bodyHeight * S}" class="body" />`;
|
||||
|
||||
// Module separators
|
||||
if (modules.length > 0) {
|
||||
let currentX = oX;
|
||||
for (let i = 0; i < modules.length - 1; i++) {
|
||||
currentX += modules[i].widthMm * S;
|
||||
svg += `<line x1="${currentX}" y1="${oY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
|
||||
}
|
||||
}
|
||||
|
||||
// System 32 holes on left and right side panels
|
||||
svg += drawSystem32Holes(oX + PANEL.structure * S / 2, oY + PANEL.structure * S, bodyHeight - PANEL.structure * 2, S);
|
||||
svg += drawSystem32Holes(oX + widthMm * S - PANEL.structure * S / 2, oY + PANEL.structure * S, bodyHeight - PANEL.structure * 2, S);
|
||||
|
||||
// Doors
|
||||
if (doorType === 'sliding') {
|
||||
const panelCount = config.slidingPanelsCount || 2;
|
||||
const panelWidth = (widthMm / panelCount) * S;
|
||||
for (let i = 0; i < panelCount; i++) {
|
||||
svg += `<rect x="${oX + i * panelWidth * 0.95 + 5}" y="${oY + 5}" width="${panelWidth * 0.9}" height="${(bodyHeight - 10) * S}" class="door" />`;
|
||||
}
|
||||
} else {
|
||||
const blockCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
|
||||
let currentX = oX;
|
||||
const items = modules.length > 0 ? modules : (config.blocks || []);
|
||||
const hw = config.hardware || {};
|
||||
const fullHeightHandle = hw.handleType === 'full_height';
|
||||
const widthDeductionMm = fullHeightHandle ? HANDLE.fullHeightWidthDeductionMm : 0;
|
||||
const gapMm = 2;
|
||||
const doorHmm = bodyHeight - 4;
|
||||
|
||||
for (let i = 0; i < blockCount; i++) {
|
||||
const bw = items[i]?.widthMm || (widthMm / blockCount);
|
||||
const doorWmm = Math.max(100, Math.floor(widthMm / blockCount) - 4 - widthDeductionMm);
|
||||
const modulePx = bw * S;
|
||||
const doorWPx = doorWmm * S;
|
||||
const doorX = currentX + (modulePx - doorWPx) / 2;
|
||||
const doorY = oY + gapMm * S;
|
||||
|
||||
svg += `<rect x="${doorX}" y="${doorY}" width="${doorWPx}" height="${doorHmm * S}" class="door" />`;
|
||||
|
||||
const hingeSide = getDoorHingeSide(i, blockCount, hw);
|
||||
svg += drawHingeMarkers(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
|
||||
if (fullHeightHandle) {
|
||||
svg += drawFullHeightHandleGrooveElevation(doorX, doorY, doorWmm, doorHmm, S, hingeSide);
|
||||
}
|
||||
|
||||
currentX += modulePx;
|
||||
}
|
||||
}
|
||||
|
||||
// Reinforcements (Forteresse 16 mm):
|
||||
// width = armoire width - 2×19 (entre côtés),
|
||||
// one at top + one at bottom (offset 19 mm),
|
||||
// third one centered if wardrobe height > 1500 mm.
|
||||
const renfortX = oX + PANEL.structure * S;
|
||||
const renfortW = Math.max(0, (widthMm - PANEL.structure * 2) * S);
|
||||
const renfortH = PANEL.reinforcement * S;
|
||||
const topRenfortY = oY + REINFORCEMENT.topBottomOffsetMm * S;
|
||||
const bottomRenfortY = plinthY - REINFORCEMENT.topBottomOffsetMm * S - renfortH;
|
||||
const reinforcementYs = [topRenfortY, bottomRenfortY];
|
||||
if (heightMm > REINFORCEMENT.thirdRequiredAboveHeightMm) {
|
||||
reinforcementYs.push(oY + (bodyHeight * S) / 2 - renfortH / 2);
|
||||
}
|
||||
for (const y of reinforcementYs) {
|
||||
svg += `<rect x="${renfortX}" y="${y}" width="${renfortW}" height="${renfortH}" class="renfort" />`;
|
||||
}
|
||||
svg += `<text x="${oX + (widthMm * S) / 2}" y="${svgHeight - 10}" class="detail-label" text-anchor="middle" fill="#6b5344" font-size="7.5">Renforts Forteresse (16 mm) : fixation par montant — ${REINFORCEMENT_MACHINING.connectorModel} au centre + 2× tourillons Ø${REINFORCEMENT_MACHINING.tourillonDiameter} à ${REINFORCEMENT_MACHINING.tourillonEdgeOffset} mm des bords · axes sur plan pièce « Renforts »</text>`;
|
||||
|
||||
// Dimension lines
|
||||
svg += generateDimensionLine(oX, plinthY + plinthHeightMm * S + 20, widthMm * S, `${widthMm} mm`, 'horizontal');
|
||||
svg += generateDimensionLine(oX - 20, oY, bodyHeight * S, `${bodyHeight} mm`, 'vertical');
|
||||
svg += generateDimensionLine(oX - 40, oY, heightMm * S, `${heightMm} mm (total)`, 'vertical');
|
||||
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SVG for side view — includes groove indicator and System 32 holes
|
||||
*/
|
||||
export function generateSidePlan(config) {
|
||||
const { heightMm, depthMm, plinthHeightMm } = config;
|
||||
const bodyHeight = heightMm - plinthHeightMm;
|
||||
const S = DRAWING_SCALE;
|
||||
|
||||
const svgWidth = depthMm * S + MARGIN * 2;
|
||||
const svgHeight = heightMm * S + MARGIN * 2;
|
||||
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
|
||||
svg += `<style>${SHARED_STYLES}</style>`;
|
||||
|
||||
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de côté</text>`;
|
||||
|
||||
const oX = MARGIN;
|
||||
const oY = MARGIN;
|
||||
|
||||
// Plinth
|
||||
const plinthY = oY + bodyHeight * S;
|
||||
svg += `<rect x="${oX}" y="${plinthY}" width="${depthMm * S}" height="${plinthHeightMm * S}" class="plinth" />`;
|
||||
|
||||
// Body
|
||||
svg += `<rect x="${oX}" y="${oY}" width="${depthMm * S}" height="${bodyHeight * S}" class="body" />`;
|
||||
|
||||
// Back panel (8mm) shown as a thin rectangle at the back
|
||||
const backX = oX + (depthMm - PANEL.back) * S;
|
||||
svg += `<rect x="${backX}" y="${oY + PANEL.structure * S}" width="${PANEL.back * S}" height="${(bodyHeight - PANEL.structure * 2) * S}" fill="#d4c4a4" stroke="#666" stroke-width="0.5" />`;
|
||||
svg += `<text x="${backX - 2}" y="${oY + bodyHeight * S / 2}" class="detail-label" text-anchor="end">Dos ${PANEL.back}mm</text>`;
|
||||
|
||||
// Groove indicator (dashed green line at 16mm from panel edge)
|
||||
const grooveAxisX = oX + (depthMm - GROOVE.back.axisFromPanelEdge) * S;
|
||||
svg += `<line x1="${grooveAxisX}" y1="${oY + PANEL.structure * S}" x2="${grooveAxisX}" y2="${plinthY - PANEL.structure * S}" class="groove" />`;
|
||||
svg += `<text x="${grooveAxisX - 3}" y="${oY + bodyHeight * S * 0.3}" class="detail-label" text-anchor="end" fill="#2a9d8f">Rainure ${GROOVE.back.width}×${GROOVE.back.depth} (16mm du bord)</text>`;
|
||||
|
||||
// System 32 holes along the side panel
|
||||
svg += drawSystem32Holes(oX + 37 * S, oY + PANEL.structure * S, bodyHeight - PANEL.structure * 2, S);
|
||||
svg += `<text x="${oX + 37 * S}" y="${oY - 5}" class="detail-label" text-anchor="middle" fill="#e63946">System 32</text>`;
|
||||
|
||||
// Dimension lines
|
||||
svg += generateDimensionLine(oX, plinthY + plinthHeightMm * S + 20, depthMm * S, `${depthMm} mm`, 'horizontal');
|
||||
svg += generateDimensionLine(oX - 20, oY, bodyHeight * S, `${bodyHeight} mm`, 'vertical');
|
||||
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SVG for top view — includes groove line for back panel
|
||||
*/
|
||||
export function generateTopPlan(config, modules = []) {
|
||||
const { widthMm, depthMm } = config;
|
||||
const S = DRAWING_SCALE;
|
||||
|
||||
const svgWidth = widthMm * S + MARGIN * 2;
|
||||
const svgHeight = depthMm * S + MARGIN * 2;
|
||||
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
|
||||
svg += `<style>${SHARED_STYLES}</style>`;
|
||||
|
||||
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de dessus</text>`;
|
||||
|
||||
const oX = MARGIN;
|
||||
const oY = MARGIN;
|
||||
|
||||
// Body outline
|
||||
svg += `<rect x="${oX}" y="${oY}" width="${widthMm * S}" height="${depthMm * S}" class="body" />`;
|
||||
|
||||
// Back panel groove line (horizontal dashed line near panel edge)
|
||||
const grooveY = oY + (depthMm - PANEL.structure) * S;
|
||||
svg += `<line x1="${oX + PANEL.structure * S}" y1="${grooveY}" x2="${oX + (widthMm - PANEL.structure) * S}" y2="${grooveY}" class="groove" />`;
|
||||
svg += `<text x="${oX + widthMm * S + 5}" y="${grooveY + 3}" class="detail-label" fill="#2a9d8f">Rainure dos</text>`;
|
||||
|
||||
// Module separators and labels
|
||||
if (modules.length > 0) {
|
||||
let currentX = oX;
|
||||
for (let i = 0; i < modules.length; i++) {
|
||||
const module = modules[i];
|
||||
const moduleWidth = module.widthMm * S;
|
||||
|
||||
// Label
|
||||
svg += `<text x="${currentX + moduleWidth / 2}" y="${oY + depthMm * S / 2}" class="label" text-anchor="middle">${module.moduleCode || `M${i + 1}`}</text>`;
|
||||
|
||||
// Separator
|
||||
if (i < modules.length - 1) {
|
||||
currentX += moduleWidth;
|
||||
svg += `<line x1="${currentX}" y1="${oY}" x2="${currentX}" y2="${oY + depthMm * S}" class="separator" />`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SVG detail plan — zoomed view of a side panel showing
|
||||
* System 32 drilling, connector holes, groove, and edge banding
|
||||
*/
|
||||
export function generateDetailPlan(config) {
|
||||
const { depthMm, heightMm, plinthHeightMm } = config;
|
||||
const bodyHeight = heightMm - plinthHeightMm;
|
||||
const innerWidth = config.widthMm - (PANEL.structure * 2);
|
||||
const innerDepth = depthMm - PANEL.structure;
|
||||
|
||||
// Zoomed scale for detail view (3× normal)
|
||||
const DS = 0.3;
|
||||
// Show top 600mm of a side panel (enough for connectors, first S32 holes, groove)
|
||||
const detailHeightMm = Math.min(600, bodyHeight);
|
||||
const detailWidthMm = depthMm;
|
||||
|
||||
// Extra space on the right for shelf drilling inset
|
||||
const svgWidth = detailWidthMm * DS + MARGIN * 2 + 260;
|
||||
const svgHeight = detailHeightMm * DS + MARGIN * 2 + 40;
|
||||
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
|
||||
svg += `<style>${SHARED_STYLES}
|
||||
.edge-band { fill: #ff9f1c; stroke: none; opacity: 0.6; }
|
||||
.callout { font-family: Arial, sans-serif; font-size: 8px; fill: #333; }
|
||||
.callout-line { stroke: #999; stroke-width: 0.3; fill: none; }
|
||||
.shelf-outline { fill: #f9fafb; stroke: #111827; stroke-width: 0.8; }
|
||||
.shelf-face-hole { fill: none; stroke: #ea580c; stroke-width: 0.55; }
|
||||
.shelf-edge-hole { fill: none; stroke: #7c3aed; stroke-width: 0.55; stroke-dasharray: 1.2 1.2; }
|
||||
</style>`;
|
||||
|
||||
svg += `<text x="${svgWidth / 2}" y="20" class="title" text-anchor="middle">Détail — Côté (vue intérieure)</text>`;
|
||||
|
||||
const oX = MARGIN + 40;
|
||||
const oY = MARGIN + 20;
|
||||
|
||||
// Panel outline
|
||||
svg += `<rect x="${oX}" y="${oY}" width="${detailWidthMm * DS}" height="${detailHeightMm * DS}" class="body" stroke-width="2" />`;
|
||||
|
||||
// Edge banding (2mm ABS on all 4 edges)
|
||||
const eb = EDGE_BANDING.abs * DS;
|
||||
svg += `<rect x="${oX}" y="${oY}" width="${detailWidthMm * DS}" height="${eb}" class="edge-band" />`; // top
|
||||
svg += `<rect x="${oX}" y="${oY + detailHeightMm * DS - eb}" width="${detailWidthMm * DS}" height="${eb}" class="edge-band" />`; // bottom
|
||||
svg += `<rect x="${oX}" y="${oY}" width="${eb}" height="${detailHeightMm * DS}" class="edge-band" />`; // left
|
||||
svg += `<rect x="${oX + detailWidthMm * DS - eb}" y="${oY}" width="${eb}" height="${detailHeightMm * DS}" class="edge-band" />`; // right
|
||||
|
||||
// Back panel groove — 16mm from panel edge
|
||||
const grooveAxisOffset = (detailWidthMm - GROOVE.back.axisFromPanelEdge) * DS;
|
||||
const grooveX = oX + grooveAxisOffset - (GROOVE.back.width / 2) * DS;
|
||||
const grooveW = GROOVE.back.width * DS;
|
||||
svg += `<rect x="${grooveX}" y="${oY + 5}" width="${grooveW}" height="${detailHeightMm * DS - 10}" fill="#2a9d8f" opacity="0.3" stroke="#2a9d8f" stroke-width="0.5" />`;
|
||||
// Callout
|
||||
svg += `<line x1="${grooveX + grooveW}" y1="${oY + 30}" x2="${grooveX + grooveW + 25}" y2="${oY + 20}" class="callout-line" />`;
|
||||
svg += `<text x="${grooveX + grooveW + 27}" y="${oY + 22}" class="callout">Rainure ${GROOVE.back.width}×${GROOVE.back.depth} mm</text>`;
|
||||
svg += `<text x="${grooveX + grooveW + 27}" y="${oY + 31}" class="callout">Position à ${GROOVE.back.axisFromPanelEdge} mm du bord</text>`;
|
||||
|
||||
// System 32 holes (front row) — blind holes, depth 12mm
|
||||
const s32X = oX + 37 * DS; // 37mm from front edge
|
||||
const usable = detailHeightMm - SYSTEM32.firstHoleOffset * 2; // first AND last at 51mm
|
||||
const holeCount = Math.max(0, Math.floor(usable / SYSTEM32.pitch) + 1);
|
||||
const r = (SYSTEM32.holeDiameter / 2) * DS;
|
||||
|
||||
for (let i = 0; i < Math.min(holeCount, 15); i++) {
|
||||
const holeY = oY + (SYSTEM32.firstHoleOffset + i * SYSTEM32.pitch) * DS;
|
||||
svg += `<circle cx="${s32X}" cy="${holeY}" r="${r}" class="s32-hole" />`;
|
||||
}
|
||||
// Callout for System 32
|
||||
svg += `<line x1="${s32X}" y1="${oY + SYSTEM32.firstHoleOffset * DS}" x2="${oX - 15}" y2="${oY + SYSTEM32.firstHoleOffset * DS - 10}" class="callout-line" />`;
|
||||
svg += `<text x="${oX - 17}" y="${oY + SYSTEM32.firstHoleOffset * DS - 12}" class="callout" text-anchor="end">System 32 (borgne)</text>`;
|
||||
svg += `<text x="${oX - 17}" y="${oY + SYSTEM32.firstHoleOffset * DS - 3}" class="callout" text-anchor="end">Ø${SYSTEM32.holeDiameter} / prof. ${SYSTEM32.holeDepth} / pas ${SYSTEM32.pitch}</text>`;
|
||||
|
||||
// First hole offset dimension
|
||||
svg += generateDimensionLine(oX + detailWidthMm * DS + 10, oY, SYSTEM32.firstHoleOffset * DS, `${SYSTEM32.firstHoleOffset}`, 'vertical');
|
||||
|
||||
// Ixconnect connector holes (top edge) — extrémité d’arête: T@20 → IX@50 → T@80 (vue détail côté)
|
||||
const conn = CONNECTORS.ixconnect;
|
||||
const pattern = CONNECTORS.jointPattern;
|
||||
// Show 3 holes: tourillon@20, ixconnect@50, tourillon@80 from left edge (arête extrémité)
|
||||
const jT1X = oX + pattern.tourillon1Axis * DS;
|
||||
const jIXX = oX + pattern.ixconnectAxis * DS;
|
||||
const jT2X = oX + pattern.tourillon2Axis * DS;
|
||||
const tourR = (CONNECTORS.tourillonDiameter / 2) * DS;
|
||||
const ixEdgeR = (conn.edgeHole.diameter / 2) * DS;
|
||||
// Edge holes on top edge
|
||||
svg += `<circle cx="${jT1X}" cy="${oY}" r="${tourR}" class="connector" opacity="0.5" />`; // tourillon 1
|
||||
svg += `<circle cx="${jIXX}" cy="${oY}" r="${ixEdgeR}" class="connector" />`; // Ixconnect
|
||||
svg += `<circle cx="${jT2X}" cy="${oY}" r="${tourR}" class="connector" opacity="0.5" />`; // tourillon 2
|
||||
// Face holes (Ixconnect only — Ø8×12mm)
|
||||
svg += `<circle cx="${jIXX}" cy="${oY + conn.faceHole.depth * DS}" r="${(conn.faceHole.diameter / 2) * DS}" class="connector" />`;
|
||||
// Callout
|
||||
svg += `<line x1="${jT2X + 5}" y1="${oY}" x2="${jT2X + 30}" y2="${oY - 15}" class="callout-line" />`;
|
||||
svg += `<text x="${jT2X + 32}" y="${oY - 22}" class="callout">Ixconnect ${conn.model}</text>`;
|
||||
svg += `<text x="${jT2X + 32}" y="${oY - 13}" class="callout">About: Ø${conn.edgeHole.diameter}×${conn.edgeHole.depth} / Face: Ø${conn.faceHole.diameter}×${conn.faceHole.depth}</text>`;
|
||||
svg += `<text x="${jT2X + 32}" y="${oY - 4}" class="callout">Pattern: T@${pattern.tourillon1Axis} → IX@${pattern.ixconnectAxis} → T@${pattern.tourillon2Axis}</text>`;
|
||||
|
||||
// Panel thickness label
|
||||
svg += `<text x="${oX + detailWidthMm * DS / 2}" y="${oY + detailHeightMm * DS + 15}" class="callout" text-anchor="middle">Épaisseur: ${PANEL.structure} mm — Chant ABS ${EDGE_BANDING.abs} mm</text>`;
|
||||
|
||||
// --- Inset: Rayons mobiles (Étagères amovibles) — SP 15/8/5 ---
|
||||
// Dimensions de découpe : L.int - 2 mm / P.int - 5 mm
|
||||
const shelfWmm = Math.max(80, innerWidth - 2);
|
||||
const shelfDmm = Math.max(80, innerDepth - 5);
|
||||
const insetMaxWpx = 170;
|
||||
const insetMaxHpx = 190;
|
||||
const insetScale = Math.min(insetMaxWpx / shelfWmm, insetMaxHpx / shelfDmm);
|
||||
const insetX = oX + detailWidthMm * DS + 60;
|
||||
const insetY = oY + 40;
|
||||
const insetW = shelfWmm * insetScale;
|
||||
const insetH = shelfDmm * insetScale;
|
||||
|
||||
svg += `<text x="${insetX + insetW / 2}" y="${insetY - 16}" class="callout" text-anchor="middle">Rayons mobiles — SP 15/8/5 (dessous)</text>`;
|
||||
svg += `<rect x="${insetX}" y="${insetY}" width="${insetW}" height="${insetH}" class="shelf-outline" />`;
|
||||
|
||||
// Hole positions: from front 32mm; from back 37mm
|
||||
const yFrontMm = 32;
|
||||
const yBackMm = shelfDmm - 37;
|
||||
const yPosMm = [yFrontMm, yBackMm].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
|
||||
|
||||
// Face holes: Ø20 axis 9.5mm from left/right
|
||||
const xSideMm = 9.5;
|
||||
const xPosMm = [xSideMm, shelfWmm - xSideMm].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
|
||||
const rFace = (20 / 2) * insetScale;
|
||||
const rEdge = (8 / 2) * insetScale;
|
||||
|
||||
for (const yMm of yPosMm) {
|
||||
const cy = insetY + yMm * insetScale;
|
||||
// Face holes (underside) — left/right
|
||||
for (const xMm of xPosMm) {
|
||||
const cx = insetX + xMm * insetScale;
|
||||
svg += `<circle cx="${cx}" cy="${cy}" r="${rFace}" class="shelf-face-hole" />`;
|
||||
}
|
||||
// Edge holes (against side panels) — left/right edge
|
||||
svg += `<circle cx="${insetX}" cy="${cy}" r="${rEdge}" class="shelf-edge-hole" />`;
|
||||
svg += `<circle cx="${insetX + insetW}" cy="${cy}" r="${rEdge}" class="shelf-edge-hole" />`;
|
||||
}
|
||||
|
||||
// Callouts
|
||||
svg += `<line x1="${insetX + insetW}" y1="${insetY + 10}" x2="${insetX + insetW + 22}" y2="${insetY}" class="callout-line" />`;
|
||||
svg += `<text x="${insetX + insetW + 24}" y="${insetY + 2}" class="callout">Face: Ø20×15 — axe 9.5 mm</text>`;
|
||||
svg += `<text x="${insetX + insetW + 24}" y="${insetY + 12}" class="callout">Chant: Ø8×37 — à 6 mm du dessous</text>`;
|
||||
svg += `<text x="${insetX + insetW + 24}" y="${insetY + 22}" class="callout">Avant: 32 mm · Arrière: 37 mm</text>`;
|
||||
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all plans for a configuration
|
||||
*/
|
||||
@@ -203,8 +487,10 @@ export function generateAllPlans(config, modules = [], colors = {}) {
|
||||
assembly: generateAssemblyPlan(config, modules),
|
||||
side: generateSidePlan(config),
|
||||
top: generateTopPlan(config, modules),
|
||||
detail: generateDetailPlan(config),
|
||||
metadata: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
specs: 'APEX 4.0',
|
||||
config: {
|
||||
widthMm: config.widthMm,
|
||||
heightMm: config.heightMm,
|
||||
@@ -220,14 +506,14 @@ export function generateAllPlans(config, modules = [], colors = {}) {
|
||||
/**
|
||||
* Generate HTML page with all plans for PDF export
|
||||
*/
|
||||
export function generatePDFHtml(config, modules = [], cuttingList = null) {
|
||||
export function generatePDFHtml(config, modules = [], cuttingList = null, packingList = null) {
|
||||
const plans = generateAllPlans(config, modules);
|
||||
|
||||
let html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Plans Armoire</title>
|
||||
<title>Plans Armoire — APEX 4.0</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 15mm; }
|
||||
body { font-family: Arial, sans-serif; font-size: 11px; }
|
||||
@@ -244,13 +530,17 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
|
||||
.summary h3 { margin: 0 0 10px; }
|
||||
.specs { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.spec-item { display: flex; justify-content: space-between; }
|
||||
.mfg-specs { background: #fff8e7; padding: 12px; border-radius: 6px; margin: 15px 0; border-left: 4px solid #c9a96e; }
|
||||
.mfg-specs h4 { margin: 0 0 8px; color: #8b7355; }
|
||||
.mfg-specs ul { margin: 0; padding-left: 18px; }
|
||||
.mfg-specs li { margin: 2px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="header">
|
||||
<h1 class="title">Plans de fabrication - Armoire sur mesure</h1>
|
||||
<p class="subtitle">Généré le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}</p>
|
||||
<p class="subtitle">APEX 4.0 — Généré le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}</p>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
@@ -265,6 +555,27 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mfg-specs">
|
||||
<h4>Normes de fabrication APEX 4.0</h4>
|
||||
<ul>
|
||||
<li>Structure ${PANEL.structure} mm / Renforts ${PANEL.reinforcement} mm / Dos ${PANEL.back} mm</li>
|
||||
<li>Chants ABS ${EDGE_BANDING.abs} mm visible — Dos: ABS sur ${EDGE_BANDING.backPanelSides} chants</li>
|
||||
<li>Rainure dos ${GROOVE.back.width}×${GROOVE.back.depth} mm — position à ${GROOVE.back.axisFromPanelEdge} mm du bord</li>
|
||||
<li>System 32 côtés : Ø${SYSTEM32.holeDiameter} mm borgne, prof. ${SYSTEM32.holeDepth} mm, pas ${SYSTEM32.pitch} mm, offset ${SYSTEM32.firstHoleOffset} mm</li>
|
||||
<li>System 32 séparateurs : Ø${SYSTEM32_SEPARATORS.holeDiameter} mm traversant, offset ${SYSTEM32_SEPARATORS.firstHoleOffset} mm</li>
|
||||
<li>Connecteurs ${CONNECTORS.ixconnect.model} (structure) : about Ø${CONNECTORS.ixconnect.edgeHole.diameter}×${CONNECTORS.ixconnect.edgeHole.depth} / face Ø${CONNECTORS.ixconnect.faceHole.diameter}×${CONNECTORS.ixconnect.faceHole.depth}</li>
|
||||
<li>Connecteurs ${CONNECTORS.ixconnectFinish.model} (finitions) : face Ø${CONNECTORS.ixconnectFinish.faceHole.diameter}×${CONNECTORS.ixconnectFinish.faceHole.depth}, axes ${CONNECTORS.ixconnectFinish.axes.back}/${CONNECTORS.ixconnectFinish.axes.front} mm</li>
|
||||
<li>Ixconnect: extrémités d’arête T@${CONNECTORS.jointPattern.tourillon1Axis} → IX@${CONNECTORS.jointPattern.ixconnectAxis} → T@${CONNECTORS.jointPattern.tourillon2Axis} mm (depuis bord avant) · jonctions internes dessus/fond: IX au milieu profondeur + T ±${CONNECTORS.jointPatternMiddleTourillonOffsetFromIxMm} mm — faces séparateurs verticaux sans Ixconnect</li>
|
||||
<li>Renforts : 1× ${REINFORCEMENT_MACHINING.connectorModel} au centre + 2× tourillons Ø${REINFORCEMENT_MACHINING.tourillonDiameter} à ${REINFORCEMENT_MACHINING.tourillonEdgeOffset} mm des bords (axes selon largeur renfort)</li>
|
||||
<li>Charnières ${HINGE.model} : axe à ${HINGE.axisOffset} mm du haut et du bas (réversible)</li>
|
||||
<li>Matrice charnières: ≤900mm=2, 901-1600=3, 1601-2000=4, 2001-2400=5, 2401-2600=6</li>
|
||||
<li>Étagères : ${SHELF_MACHINING.tourillonCount}× tourillons Ø${SHELF_MACHINING.tourillonDiameter} — ${SHELF_MACHINING.shelvesPerStratum} rayons/strate · Rayons mobiles SP 15/8/5 : face Ø20×15 (axe 9.5) + chant Ø8×37 (à 6 du dessous) · avant 32 / arrière 37</li>
|
||||
<li>Plinthe : ${PLINTH.model} (pieds ${PLINTH.footHeight} mm + clips)</li>
|
||||
<li>Dessus/Fond : ABS ${EDGE_BANDING.abs} mm avant ET arrière</li>
|
||||
${config.hardware?.handleType === 'full_height' ? `<li>Poignée toute hauteur : déduction ${HANDLE.fullHeightWidthDeductionMm} mm en largeur porte ; rainure ${HANDLE.fullHeightGroove.width}×${HANDLE.fullHeightGroove.depth} mm côté opposé aux charnières ; retrait mécanique ${HANDLE.fullHeightTopBottomOffset} mm haut et bas</li>` : ''}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="plan-container">
|
||||
<div class="plan-title">Vue de face (Élévation)</div>
|
||||
${plans.assembly}
|
||||
@@ -281,6 +592,13 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
|
||||
<div class="plan-title">Vue de dessus</div>
|
||||
${plans.top}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<div class="plan-container">
|
||||
<div class="plan-title">Détail constructif — Côté</div>
|
||||
${plans.detail}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Cutting list page
|
||||
@@ -293,7 +611,7 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
|
||||
<div class="specs">
|
||||
<div class="spec-item"><span>Nombre de pièces:</span><strong>${cuttingList.summary.totalPieces}</strong></div>
|
||||
<div class="spec-item"><span>Surface totale:</span><strong>${cuttingList.summary.totalSurfaceM2.toFixed(2)} m²</strong></div>
|
||||
<div class="spec-item"><span>Chant plaqué:</span><strong>${cuttingList.summary.edgeBandingMl} ml</strong></div>
|
||||
<div class="spec-item"><span>Chant ABS 2mm:</span><strong>${cuttingList.summary.edgeBandingMl} ml</strong></div>
|
||||
<div class="spec-item"><span>Finitions:</span><strong>${cuttingList.summary.finishCount}</strong></div>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -310,11 +628,15 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
|
||||
<th>Ép.</th>
|
||||
<th>Qté</th>
|
||||
<th>Surface</th>
|
||||
<th>Usinage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
|
||||
for (const piece of group.pieces) {
|
||||
const machStr = piece.machining
|
||||
? Object.keys(piece.machining).join(', ')
|
||||
: '-';
|
||||
html += `
|
||||
<tr>
|
||||
<td>${piece.description}</td>
|
||||
@@ -323,6 +645,7 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
|
||||
<td>${piece.thicknessMm} mm</td>
|
||||
<td>${piece.quantity}</td>
|
||||
<td>${piece.surfaceM2.toFixed(3)} m²</td>
|
||||
<td>${machStr}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
@@ -330,8 +653,8 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th colspan="4">Sous-total</th>
|
||||
<th>${group.pieceCount}</th>
|
||||
<th colspan="5">Sous-total</th>
|
||||
<th>${group.pieceCount} pcs</th>
|
||||
<th>${group.totalSurfaceM2.toFixed(2)} m²</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -340,6 +663,82 @@ export function generatePDFHtml(config, modules = [], cuttingList = null) {
|
||||
|
||||
html += `
|
||||
</div>`;
|
||||
|
||||
// Piece-by-piece machining plan pages
|
||||
for (const piece of (cuttingList.pieces || [])) {
|
||||
if (!piece.piecePlanSvg) continue;
|
||||
html += `
|
||||
<div class="page">
|
||||
<h2>Plan pièce - ${piece.description}</h2>
|
||||
<p style="margin:0 0 8px;color:#555;">
|
||||
Fini: ${piece.widthMm} x ${piece.heightMm} x ${piece.thicknessMm} mm
|
||||
|
|
||||
Brut CNC: ${piece.rawCutWidthMm} x ${piece.rawCutHeightMm} mm
|
||||
|
|
||||
Qté: ${piece.quantity}
|
||||
</p>
|
||||
<div class="plan-container">
|
||||
${piece.piecePlanSvg}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Packing list page (layer-based per APEX 4.0)
|
||||
if (packingList && packingList.boxes && packingList.boxes.length > 0) {
|
||||
html += `
|
||||
<div class="page">
|
||||
<h2>Liste de colisage — Stratégie par niveaux (APEX 4.0)</h2>
|
||||
<div class="summary">
|
||||
<h3>Résumé</h3>
|
||||
<div class="specs">
|
||||
<div class="spec-item"><span>Nombre de colis:</span><strong>${packingList.totalBoxes}</strong></div>
|
||||
<div class="spec-item"><span>Poids max/colis:</span><strong>${packingList.maxWeightKg} kg</strong></div>
|
||||
<div class="spec-item"><span>Colis le plus lourd:</span><strong>${packingList.heaviestBoxKg.toFixed(1)} kg</strong></div>
|
||||
<div class="spec-item"><span>Poids total:</span><strong>${packingList.totalWeightKg.toFixed(1)} kg</strong></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Layer summary table
|
||||
if (packingList.layerSummary) {
|
||||
const ls = packingList.layerSummary;
|
||||
html += `
|
||||
<table>
|
||||
<thead><tr><th>Niveau</th><th>Contenu</th><th>Colis</th><th>Pièces</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>1</td><td>${ls.level1.label}</td><td>${ls.level1.boxes}</td><td>${ls.level1.items}</td></tr>
|
||||
<tr><td>2</td><td>${ls.level2.label}</td><td>${ls.level2.boxes}</td><td>${ls.level2.items}</td></tr>
|
||||
<tr><td>3</td><td>${ls.level3.label}</td><td>${ls.level3.boxes}</td><td>${ls.level3.items}</td></tr>
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
for (const box of packingList.boxes) {
|
||||
html += `
|
||||
<h3>Colis ${box.id}${box.level ? ` — ${box.level}` : ''} — ${box.totalWeightKg.toFixed(1)} kg</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Pièce</th><th>Dim. (L×H×Ép.)</th><th>Qté</th><th>Poids</th></tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
|
||||
for (const item of box.pieces) {
|
||||
html += `
|
||||
<tr>
|
||||
<td>${item.description}</td>
|
||||
<td>${item.widthMm}×${item.heightMm}×${item.thicknessMm} mm</td>
|
||||
<td>${item.quantity}</td>
|
||||
<td>${item.weightKg.toFixed(1)} kg</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
@@ -353,6 +752,7 @@ export default {
|
||||
generateAssemblyPlan,
|
||||
generateSidePlan,
|
||||
generateTopPlan,
|
||||
generateDetailPlan,
|
||||
generateAllPlans,
|
||||
generatePDFHtml
|
||||
};
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
/**
|
||||
* Moteur de calcul de prix — configurateur armoires sur mesure.
|
||||
* Règles : panneaux entiers (chute intégrée), blocs, kit, options.
|
||||
* Specs APEX 4.0 importées de shared/manufacturing.js
|
||||
*/
|
||||
|
||||
import { PANEL, DRAWERS, NICHE, getHingeCountForDoor } from '../../shared/manufacturing.js';
|
||||
|
||||
function numberOr(value, fallback) {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la surface utile des éléments visibles (portes, fileurs, bandeau, plinthe, côtés doublage).
|
||||
* Épaisseur panneaux 19 mm (référence).
|
||||
* Épaisseur panneaux 19 mm (référence APEX 4.0).
|
||||
* @param {Object} config - configuration (installation, width, height, depth, plinthHeightMm, blocks, doorType, slidingPanels)
|
||||
* @param {number} panelThicknessMm - épaisseur panneaux (19)
|
||||
*/
|
||||
function calculateVisibleSurface(config, panelThicknessMm = 19) {
|
||||
function calculateVisibleSurface(config, panelThicknessMm = PANEL.structure) {
|
||||
const { installationType, widthMm, heightMm, depthMm, plinthHeightMm, blocks = [], doorType, slidingPanelsCount } = config;
|
||||
const isNiche = installationType === 'niche';
|
||||
let surface = 0;
|
||||
@@ -204,12 +212,13 @@ function calculateHardwareCosts(hardware, hardwareData, config) {
|
||||
}
|
||||
}
|
||||
|
||||
// Hinge costs - typically 3 per hinged door
|
||||
// Hinge costs - dynamic quantity from APEX 4.0 matrix
|
||||
if (hardware.hingeVariantId && config.doorType !== 'sliding') {
|
||||
const hinge = hinges.find(h => h.id === hardware.hingeVariantId);
|
||||
if (hinge) {
|
||||
const doorCount = config.modules?.length || config.blocks?.length || 2;
|
||||
const hingesPerDoor = 3;
|
||||
const doorHeightMm = (config.heightMm || 2400) - (config.plinthHeightMm || 100) - 4;
|
||||
const hingesPerDoor = getHingeCountForDoor({ heightMm: doorHeightMm });
|
||||
const quantity = doorCount * hingesPerDoor;
|
||||
const cost = hinge.price_chf * quantity;
|
||||
totalChf += cost;
|
||||
@@ -263,16 +272,22 @@ export function calculatePrice(config, data) {
|
||||
if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v)) v = Number(v);
|
||||
settings[r.key] = v;
|
||||
}
|
||||
const kitFeeChf = Number(settings.kit_preparation_fee_chf) ?? 350;
|
||||
const plinthMin = Number(settings.plinth_height_min_mm) ?? 60;
|
||||
const plinthMax = Number(settings.plinth_height_max_mm) ?? 160;
|
||||
const slidingMinWidth = Number(settings.sliding_doors_min_width_mm) ?? 1200;
|
||||
const kitFeeChf = numberOr(settings.kit_preparation_fee_chf, 350);
|
||||
const plinthMin = numberOr(settings.plinth_height_min_mm, 60);
|
||||
const plinthMax = numberOr(settings.plinth_height_max_mm, 160);
|
||||
const slidingMinWidth = numberOr(settings.sliding_doors_min_width_mm, 1200);
|
||||
const allowSlidingDoors = String(settings.allow_sliding_doors ?? '0') === '1';
|
||||
const standardHandleAxisMm = numberOr(settings.standard_handle_axis_mm, 1000);
|
||||
const standardHandleToleranceMm = numberOr(settings.standard_handle_tolerance_mm, 200);
|
||||
const sanitaryGapMm = numberOr(settings.sanitary_gap_mm, NICHE.sanitaryGapAround);
|
||||
|
||||
const {
|
||||
installationType, widthMm, heightMm, depthMm, plinthHeightMm,
|
||||
blocks = [], modules = [], hardware = {},
|
||||
doorType, slidingPanelsCount, finishId, serviceOptionIds = []
|
||||
doorType, slidingPanelsCount, finishId, serviceOptionIds = [],
|
||||
columnMode: columnModeRaw
|
||||
} = config;
|
||||
const columnMode = columnModeRaw === 'block' ? 'block' : 'separations';
|
||||
|
||||
// Determine if using modules (V2) or blocks (legacy)
|
||||
const useModules = modules && modules.length > 0;
|
||||
@@ -286,11 +301,32 @@ export function calculatePrice(config, data) {
|
||||
if (doorType === 'sliding' && widthMm < slidingMinWidth) {
|
||||
errors.push({ field: 'doorType', message: `Les portes coulissantes ne sont possibles qu'à partir de ${slidingMinWidth} mm de largeur.` });
|
||||
}
|
||||
if (doorType === 'sliding' && !allowSlidingDoors) {
|
||||
errors.push({ field: 'doorType', message: 'Les portes coulissantes sont désactivées pour le moment.' });
|
||||
}
|
||||
|
||||
if (doorType === 'sliding' && (slidingPanelsCount < 2 || slidingPanelsCount > 3)) {
|
||||
errors.push({ field: 'slidingPanelsCount', message: 'Choisissez 2 ou 3 vantaux coulissants.' });
|
||||
}
|
||||
|
||||
if (installationType === 'niche') {
|
||||
const nm = config.nicheMeasurements || {};
|
||||
const minW = Math.min(nm.widthTopMm || widthMm, nm.widthMidMm || widthMm, nm.widthBottomMm || widthMm);
|
||||
const minD = Math.min(nm.depthTopMm || depthMm, nm.depthMidMm || depthMm, nm.depthBottomMm || depthMm);
|
||||
const minH = Math.min(nm.heightLeftMm || heightMm, nm.heightRightMm || heightMm);
|
||||
const expectedW = Math.max(400, minW - sanitaryGapMm * 2);
|
||||
const expectedD = Math.max(400, minD - sanitaryGapMm * 2);
|
||||
const expectedH = Math.max(1800, minH - sanitaryGapMm * 2);
|
||||
if (Math.abs(widthMm - expectedW) > 2 || Math.abs(depthMm - expectedD) > 2 || Math.abs(heightMm - expectedH) > 2) {
|
||||
errors.push({ field: 'nicheMeasurements', message: 'Les dimensions retenues ne correspondent pas aux plus petites mesures niche avec vide sanitaire de 3 cm.' });
|
||||
}
|
||||
}
|
||||
|
||||
const handleAxis = Number(config.hardware?.handleBoreHeightMm || standardHandleAxisMm);
|
||||
const minStandard = standardHandleAxisMm - standardHandleToleranceMm;
|
||||
const maxStandard = standardHandleAxisMm + standardHandleToleranceMm;
|
||||
const isHandleAxisNonStandard = handleAxis < minStandard || handleAxis > maxStandard;
|
||||
|
||||
// Calculate block/module costs
|
||||
let blocksChf = 0;
|
||||
let modulesBreakdown = null;
|
||||
@@ -307,6 +343,13 @@ export function calculatePrice(config, data) {
|
||||
if (Math.abs(totalModuleWidth - widthMm) > 10) {
|
||||
errors.push({ field: 'modules', message: `La somme des largeurs des modules (${totalModuleWidth}mm) doit correspondre à la largeur totale (${widthMm}mm).` });
|
||||
}
|
||||
const hasDrawerColumnTooWide = modules.some((m) => ((m.drawers || 0) > 0 || m.type === 'tiroirs') && (m.widthMm || 0) > DRAWERS.maxColumnWidth);
|
||||
if (hasDrawerColumnTooWide) {
|
||||
errors.push({ field: 'modules', message: `Une colonne tiroirs dépasse ${DRAWERS.maxColumnWidth} mm (non autorisé).` });
|
||||
}
|
||||
if (modules.some((m) => m.hasRod) && heightMm < 1200) {
|
||||
errors.push({ field: 'modules', message: 'Rayon avec tringle: hauteur minimale 1200 mm.' });
|
||||
}
|
||||
} else {
|
||||
// Legacy: Use block-based pricing
|
||||
const totalWidth = blocks.reduce((sum, b) => sum + (b.widthMm || 0), 0);
|
||||
@@ -386,6 +429,34 @@ export function calculatePrice(config, data) {
|
||||
}
|
||||
}
|
||||
|
||||
// Renforts Forteresse — one pair (haut+bas) per panneau vertical de structure
|
||||
// Séparations: 2 côtés + (n−1) séparateurs = n+1 panneaux verticaux
|
||||
// Block (x caisses): chaque caisse a 2 montants → 2n panneaux verticaux (plus de bois qu’en séparations)
|
||||
const columnCount = useModules
|
||||
? Math.max(1, modules.length)
|
||||
: Math.max(1, blocks.length || 1);
|
||||
const verticalStructurePanelCount = columnMode === 'block'
|
||||
? 2 * columnCount
|
||||
: 2 + Math.max(0, columnCount - 1);
|
||||
const reinforcementCount = verticalStructurePanelCount * 2;
|
||||
|
||||
// Surcoût panneaux 19 mm (structure): en mode block, (n−1) montants de plus qu’en séparations
|
||||
const bodyHeightMm = (heightMm || 0) - (plinthHeightMm || 100);
|
||||
const extraBlockVerticalPanels = columnMode === 'block' && columnCount > 1 ? columnCount - 1 : 0;
|
||||
const extraBlockStructureSurfaceM2 = extraBlockVerticalPanels > 0
|
||||
? (extraBlockVerticalPanels * depthMm * bodyHeightMm) / 1_000_000
|
||||
: 0;
|
||||
const structureUnitPriceM2 = finish
|
||||
? (Number(finish.price_chf) || 0) / ((finish.width_mm || 2800) * (finish.height_mm || 2070) / 1_000_000)
|
||||
: 0;
|
||||
const blockExtraStructureChf = Math.round(extraBlockStructureSurfaceM2 * structureUnitPriceM2 * 100) / 100;
|
||||
|
||||
// Each reinforcement is 100mm × innerDepth × 16mm — price as fraction of a panel
|
||||
const reinforcementSurfaceM2 = (PANEL.reinforcementWidth * (depthMm - PANEL.structure - 20)) / 1_000_000 * reinforcementCount;
|
||||
// Use 50% of panel price per m² for 16mm material (thinner, cheaper)
|
||||
const reinforcementUnitPriceM2 = finish ? (Number(finish.price_chf) || 0) * 0.5 / ((finish.width_mm || 2800) * (finish.height_mm || 2070) / 1_000_000) : 0;
|
||||
const reinforcementChf = Math.round(reinforcementSurfaceM2 * reinforcementUnitPriceM2 * 100) / 100;
|
||||
|
||||
let optionsChf = 0;
|
||||
const services = data.serviceOptions || [];
|
||||
for (const optId of serviceOptionIds) {
|
||||
@@ -393,7 +464,7 @@ export function calculatePrice(config, data) {
|
||||
if (opt) optionsChf += Number(opt.price_chf) || 0;
|
||||
}
|
||||
|
||||
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf;
|
||||
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf + reinforcementChf + blockExtraStructureChf;
|
||||
|
||||
return {
|
||||
totalChf: Math.round(totalChf * 100) / 100,
|
||||
@@ -403,11 +474,19 @@ export function calculatePrice(config, data) {
|
||||
modulesBreakdown,
|
||||
kitChf: kitFeeChf,
|
||||
panelsChf,
|
||||
reinforcementChf,
|
||||
reinforcementCount,
|
||||
columnMode,
|
||||
verticalStructurePanelCount,
|
||||
blockExtraStructureChf,
|
||||
optionsChf,
|
||||
hardwareChf,
|
||||
hardwareBreakdown,
|
||||
panelOptimization
|
||||
},
|
||||
warnings: isHandleAxisNonStandard
|
||||
? [{ field: 'handleBoreHeightMm', message: `Hauteur perçage poignée hors plage standard (${minStandard}-${maxStandard} mm).` }]
|
||||
: undefined,
|
||||
errors: errors.length ? errors : undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Supplier voucher service.
|
||||
* After an order is placed, splits order items by supplier and sends each
|
||||
* supplier an HTML email voucher listing the items they need to provide.
|
||||
*/
|
||||
|
||||
import pool from '../db/index.js';
|
||||
import { sendEmail } from './mailer.js';
|
||||
import { getHingeCountForDoor } from '../../shared/manufacturing.js';
|
||||
|
||||
/**
|
||||
* Resolve which supplier (if any) provides the finish/panel material.
|
||||
*/
|
||||
async function resolveFinishSupplier(finishId) {
|
||||
if (!finishId) return null;
|
||||
|
||||
// Try formex_materials first (linked via finishes → formex_materials)
|
||||
const { rows } = await pool.query(
|
||||
`SELECT fm.id, fm.formex_code AS code, fm.name, fm.price_chf,
|
||||
s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code
|
||||
FROM formex_materials fm
|
||||
JOIN suppliers s ON s.id = fm.supplier_id
|
||||
WHERE fm.id = $1 AND fm.active = 1 AND s.active = 1`,
|
||||
[finishId]
|
||||
);
|
||||
if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: 'panel' };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve drawer system supplier.
|
||||
*/
|
||||
async function resolveBlumHardware(hardwareId, type) {
|
||||
if (!hardwareId) return null;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT bh.id, bh.code, bh.name, bh.price_chf, bh.type,
|
||||
s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code
|
||||
FROM blum_hardware bh
|
||||
JOIN suppliers s ON s.id = bh.supplier_id
|
||||
WHERE bh.id = $1 AND bh.active = 1 AND s.active = 1`,
|
||||
[hardwareId]
|
||||
);
|
||||
if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: type };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve handle supplier.
|
||||
*/
|
||||
async function resolveHandle(handleId) {
|
||||
if (!handleId) return null;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT hv.id, hv.code, hv.name, hv.price_chf,
|
||||
s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code
|
||||
FROM handle_variants hv
|
||||
JOIN suppliers s ON s.id = hv.supplier_id
|
||||
WHERE hv.id = $1 AND hv.active = 1 AND s.active = 1`,
|
||||
[handleId]
|
||||
);
|
||||
if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: 'handle' };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve hinge supplier.
|
||||
*/
|
||||
async function resolveHinge(hingeId) {
|
||||
if (!hingeId) return null;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT hv.id, hv.code, hv.name, hv.price_chf,
|
||||
s.id AS supplier_id, s.name AS supplier_name, s.email AS supplier_email, s.code AS supplier_code
|
||||
FROM hinge_variants hv
|
||||
JOIN suppliers s ON s.id = hv.supplier_id
|
||||
WHERE hv.id = $1 AND hv.active = 1 AND s.active = 1`,
|
||||
[hingeId]
|
||||
);
|
||||
if (rows.length > 0) return { supplier: rows[0], item: rows[0], category: 'hinge' };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate quantity for each item based on the order config.
|
||||
*/
|
||||
function estimateQuantity(category, config) {
|
||||
const { modules = [], blocks = [], doorType, slidingPanelsCount } = config;
|
||||
const itemCount = modules.length || blocks.length || 1;
|
||||
|
||||
switch (category) {
|
||||
case 'panel':
|
||||
return 1; // quantity expressed in panels by the price engine; we report 1 "lot"
|
||||
case 'drawer_system':
|
||||
return modules.reduce((sum, m) => sum + (m.drawers || 0), 0) || 1;
|
||||
case 'hinge':
|
||||
if (doorType === 'sliding') return 0;
|
||||
return itemCount * getHingeCountForDoor({
|
||||
heightMm: (config.heightMm || 2400) - (config.plinthHeightMm || 100) - 4
|
||||
});
|
||||
case 'handle':
|
||||
return doorType === 'sliding'
|
||||
? (slidingPanelsCount || 2)
|
||||
: itemCount;
|
||||
case 'runner':
|
||||
case 'lift_system':
|
||||
return 1;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an HTML voucher email body for a single supplier.
|
||||
*/
|
||||
function buildVoucherHtml({ orderId, orderDate, customerName, supplierName, items }) {
|
||||
const formattedDate = new Date(orderDate).toLocaleDateString('fr-CH', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric'
|
||||
});
|
||||
|
||||
const subtotal = items.reduce((sum, it) => sum + (it.unitPrice * it.qty), 0);
|
||||
|
||||
const rows = items.map(it => `
|
||||
<tr>
|
||||
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;">${it.code || '—'}</td>
|
||||
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;">${it.name}</td>
|
||||
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:center;">${it.qty}</td>
|
||||
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:right;">${it.unitPrice != null ? it.unitPrice.toFixed(2) + ' CHF' : '—'}</td>
|
||||
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:right;">${it.unitPrice != null ? (it.unitPrice * it.qty).toFixed(2) + ' CHF' : '—'}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head><meta charset="utf-8"><title>Bon de commande #${orderId}</title></head>
|
||||
<body style="margin:0;padding:0;background:#f9fafb;font-family:Arial,sans-serif;color:#111827;">
|
||||
<div style="max-width:680px;margin:32px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.1);">
|
||||
|
||||
<div style="background:#1e293b;color:#fff;padding:28px 32px;">
|
||||
<h1 style="margin:0;font-size:20px;font-weight:700;">Bon de commande — APEX Configurateur</h1>
|
||||
<p style="margin:6px 0 0;opacity:.75;font-size:14px;">Référence commande : <strong>#${orderId}</strong> | ${formattedDate}</p>
|
||||
</div>
|
||||
|
||||
<div style="padding:24px 32px;">
|
||||
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
|
||||
<tr>
|
||||
<td style="padding:4px 0;color:#6b7280;font-size:13px;width:140px;">Fournisseur</td>
|
||||
<td style="padding:4px 0;font-weight:600;">${supplierName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 0;color:#6b7280;font-size:13px;">Client</td>
|
||||
<td style="padding:4px 0;">${customerName || '—'}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2 style="font-size:15px;font-weight:600;margin:0 0 12px;color:#1e293b;">Articles à fournir</h2>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:14px;">
|
||||
<thead>
|
||||
<tr style="background:#f1f5f9;">
|
||||
<th style="padding:8px 12px;text-align:left;font-weight:600;color:#374151;">Code</th>
|
||||
<th style="padding:8px 12px;text-align:left;font-weight:600;color:#374151;">Désignation</th>
|
||||
<th style="padding:8px 12px;text-align:center;font-weight:600;color:#374151;">Qté</th>
|
||||
<th style="padding:8px 12px;text-align:right;font-weight:600;color:#374151;">PU (CHF)</th>
|
||||
<th style="padding:8px 12px;text-align:right;font-weight:600;color:#374151;">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="4" style="padding:10px 12px;text-align:right;font-weight:700;border-top:2px solid #e5e7eb;">Sous-total</td>
|
||||
<td style="padding:10px 12px;text-align:right;font-weight:700;border-top:2px solid #e5e7eb;">${subtotal.toFixed(2)} CHF</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="padding:16px 32px 24px;font-size:12px;color:#9ca3af;border-top:1px solid #e5e7eb;">
|
||||
Ce document est généré automatiquement par le configurateur APEX. Pour toute question : configurateur@apex.ch
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point — called from orders route after insert.
|
||||
* Splits the order by supplier and sends each supplier their voucher.
|
||||
*
|
||||
* @param {number} orderId
|
||||
* @param {Object} config - the order config JSON (finishId, hardware, modules, blocks, doorType, …)
|
||||
* @param {string} customerName
|
||||
* @param {string} orderDate - ISO date string (defaults to now)
|
||||
*/
|
||||
export async function sendSupplierVouchers(orderId, config, customerName, orderDate = new Date().toISOString()) {
|
||||
const suppliers = await buildSupplierVoucherPayload(config);
|
||||
|
||||
if (suppliers.length === 0) {
|
||||
console.log(`[supplier-voucher] Commande #${orderId} : aucun fournisseur résolu, aucun email envoyé.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send one email per supplier
|
||||
const sends = [];
|
||||
for (const supplier of suppliers) {
|
||||
const html = buildVoucherHtml({
|
||||
orderId,
|
||||
orderDate,
|
||||
customerName,
|
||||
supplierName: supplier.supplierName,
|
||||
items: supplier.items
|
||||
});
|
||||
sends.push(
|
||||
sendEmail({
|
||||
to: supplier.supplierEmail,
|
||||
subject: `Bon de commande #${orderId} — APEX Configurateur`,
|
||||
html
|
||||
}).then(() => {
|
||||
console.log(`[supplier-voucher] Email envoyé à ${supplier.supplierName} <${supplier.supplierEmail}> pour commande #${orderId}`);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(sends);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build supplier payload from order config.
|
||||
* Used by email sender and by admin API preview.
|
||||
*
|
||||
* @param {Object} config
|
||||
* @returns {Promise<Array<{supplierId:number, supplierCode:string|null, supplierName:string, supplierEmail:string, items:Array, subtotal:number}>>}
|
||||
*/
|
||||
export async function buildSupplierVoucherPayload(config) {
|
||||
const { hardware = {} } = config;
|
||||
const { handleVariantId, hingeVariantId, drawerSystemId } = hardware;
|
||||
|
||||
// Resolve all items in parallel
|
||||
const [finishResult, drawerResult, handleResult, hingeResult] = await Promise.all([
|
||||
resolveFinishSupplier(config.finishId),
|
||||
resolveBlumHardware(drawerSystemId, 'drawer_system'),
|
||||
resolveHandle(handleVariantId),
|
||||
resolveHinge(hingeVariantId)
|
||||
]);
|
||||
|
||||
// Group items by supplier_id
|
||||
const supplierMap = new Map(); // supplier_id → { supplier info, items[] }
|
||||
|
||||
for (const result of [finishResult, drawerResult, handleResult, hingeResult]) {
|
||||
if (!result) continue;
|
||||
const { supplier, item, category } = result;
|
||||
const sid = supplier.supplier_id;
|
||||
|
||||
if (!supplierMap.has(sid)) {
|
||||
supplierMap.set(sid, {
|
||||
code: supplier.supplier_code || null,
|
||||
name: supplier.supplier_name,
|
||||
email: supplier.supplier_email,
|
||||
items: []
|
||||
});
|
||||
}
|
||||
|
||||
const qty = estimateQuantity(category, config);
|
||||
if (qty > 0) {
|
||||
supplierMap.get(sid).items.push({
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
unitPrice: item.price_chf,
|
||||
qty,
|
||||
category
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(supplierMap, ([supplierId, supplierData]) => {
|
||||
const subtotal = supplierData.items.reduce((sum, item) => sum + ((item.unitPrice || 0) * item.qty), 0);
|
||||
return {
|
||||
supplierId,
|
||||
supplierCode: supplierData.code || null,
|
||||
supplierName: supplierData.name,
|
||||
supplierEmail: supplierData.email,
|
||||
items: supplierData.items,
|
||||
subtotal
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { validateConfig } from '../services/configValidation.js';
|
||||
import { generateCuttingList } from '../services/cuttingEngine.js';
|
||||
|
||||
function run() {
|
||||
const baseConfig = {
|
||||
installationType: 'free',
|
||||
widthMm: 2400,
|
||||
heightMm: 2400,
|
||||
depthMm: 610,
|
||||
plinthHeightMm: 100,
|
||||
modules: [{ type: 'tiroirs', widthMm: 900, drawers: 3, hasRod: false }],
|
||||
doorType: 'hinged',
|
||||
hardware: {
|
||||
handleBoreHeightMm: 1000,
|
||||
drawerType: 'visible'
|
||||
}
|
||||
};
|
||||
|
||||
const okValidation = validateConfig(baseConfig, { allow_sliding_doors: '0' });
|
||||
assert.equal(okValidation.errors.length, 0, 'Base config should be valid');
|
||||
|
||||
const invalidDrawerConfig = {
|
||||
...baseConfig,
|
||||
modules: [{ type: 'tiroirs', widthMm: 1201, drawers: 2, hasRod: false }]
|
||||
};
|
||||
const invalidDrawerValidation = validateConfig(invalidDrawerConfig, {});
|
||||
assert.ok(
|
||||
invalidDrawerValidation.errors.some((e) => String(e.message).includes('1000')),
|
||||
'Drawer width limit should trigger validation error'
|
||||
);
|
||||
|
||||
const nonStandardHandle = {
|
||||
...baseConfig,
|
||||
hardware: { ...baseConfig.hardware, handleBoreHeightMm: 1450 }
|
||||
};
|
||||
const nonStandardHandleValidation = validateConfig(nonStandardHandle, {});
|
||||
assert.ok(nonStandardHandleValidation.warnings.length > 0, 'Handle warning should be emitted');
|
||||
|
||||
const cuttingList = generateCuttingList(baseConfig, {}, baseConfig.modules);
|
||||
const sidePiece = cuttingList.pieces.find((p) => p.elementType === 'cotes');
|
||||
assert.ok(sidePiece?.machining?.groove, 'Side piece should include groove machining');
|
||||
assert.equal(sidePiece.machining.groove.width, 9, 'Groove width should be 9mm');
|
||||
assert.equal(sidePiece.machining.groove.depth, 10.5, 'Groove depth should be 10.5mm');
|
||||
assert.equal(sidePiece.machining.groove.axisFromPanelEdge, 16, 'Groove position should be 16mm from edge');
|
||||
}
|
||||
|
||||
run();
|
||||
console.log('rules.spec.mjs: all assertions passed');
|
||||
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* Perçages face panneaux dessus/fond — alignés sur la vue A des plans (cuttingEngine)
|
||||
* et sur les commentaires HOLE du G-code (cncGenerator).
|
||||
*/
|
||||
import {
|
||||
CONNECTORS,
|
||||
PANEL,
|
||||
CARCASS_FASTENERS,
|
||||
getCarcassFerrureDowelDrillings,
|
||||
getHingeCountForDoor,
|
||||
getHingeCupPositions,
|
||||
HINGE,
|
||||
tourillonFaceDrillDepthMm,
|
||||
DRAWER_REINFORCEMENT,
|
||||
DRAWER_RENFORT_PANEL_IXCONNECT
|
||||
} from './manufacturing.js';
|
||||
|
||||
/** Perçage ferrure en face — Ø8 mm (même diamètre que tourillon / Ixconnect face) */
|
||||
export const CARCASS_FERRURE_FACE_DIAMETER_MM = 8;
|
||||
|
||||
/** Montants / séparateurs : rangées Ixconnect jonction dessus·fond — retrait depuis bords haut/bas (mm, origine Y = bas pièce en G-code). */
|
||||
export const IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM = 23;
|
||||
|
||||
/** Séparateurs verticaux : second jeu T–IX–T symétrique depuis le bord arrière (profondeur), si largeur brut le permet. */
|
||||
export const IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM = 200;
|
||||
|
||||
export function mapJunctionXAcrossWidthMm(widthMm, xmm) {
|
||||
const edgeInset = PANEL.structure * 0.5;
|
||||
const clamped = Math.max(0, Math.min(widthMm, Number(xmm) || 0));
|
||||
if (clamped <= 0) return edgeInset;
|
||||
if (clamped >= widthMm) return Math.max(0, widthMm - edgeInset);
|
||||
return clamped;
|
||||
}
|
||||
|
||||
/** Même logique que `getCarcassDowelStationsForPlan` dans cuttingEngine.js */
|
||||
export function getCarcassDowelStationsForPlan(cf, edgeLengthMm) {
|
||||
const L = Math.max(1, Number(edgeLengthMm) || 0);
|
||||
const off = cf.dowelSideOffset ?? CARCASS_FASTENERS.dowel.sideOffset;
|
||||
let positions = cf.positionsAlongJointMm;
|
||||
if (!positions?.length && cf.dowelStations?.length) {
|
||||
positions = cf.dowelStations.map((s) => s.centerMm).filter((v) => Number.isFinite(Number(v)));
|
||||
}
|
||||
return getCarcassFerrureDowelDrillings(positions || [], L, off);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string }>}
|
||||
*/
|
||||
export function buildTopBottomPlanHoleSpecsMm(piece, widthMm, heightMm) {
|
||||
const out = [];
|
||||
const m = piece?.machining || {};
|
||||
const thicknessMm = Math.max(1, Number(piece?.thicknessMm) || 19);
|
||||
if (!['dessus', 'fond'].includes(piece.elementType || '')) return out;
|
||||
|
||||
const clampX = (v) => Math.max(0, Math.min(widthMm, Number(v) || 0));
|
||||
const clampY = (v) => Math.max(0, Math.min(heightMm, Number(v) || 0));
|
||||
/** Largeur « caisse » (finie) pour repères jonctions — peut être < widthMm (brut CNC). */
|
||||
const refW =
|
||||
Number(piece?.widthMm) > 0 ? Math.min(widthMm, Number(piece.widthMm)) : widthMm;
|
||||
const edgeTolMm = 0.5;
|
||||
|
||||
const cf = m.carcassFasteners;
|
||||
if (cf && (cf.dowelStations?.length || cf.positionsAlongJointMm?.length)) {
|
||||
const edgeL = Math.max(1, Number(cf.edgeLengthMm) || heightMm);
|
||||
const stations = getCarcassDowelStationsForPlan(cf, edgeL);
|
||||
const jax = cf.junctionAxesAcrossWidthMm?.length ? cf.junctionAxesAcrossWidthMm : [0, refW];
|
||||
const dTour = Number(cf.dowelDiameter || CARCASS_FASTENERS.dowel.diameter);
|
||||
const zTour = tourillonFaceDrillDepthMm(thicknessMm);
|
||||
const zFerr = Number(cf.ferrureDepthInThicknessMm ?? CARCASS_FASTENERS.ferrureDepthInThicknessMm);
|
||||
const zFerrEdge = Number(cf.ferrureDepthAlongLengthMm ?? 46);
|
||||
|
||||
for (const xmm of jax) {
|
||||
const rawX = clampX(xmm);
|
||||
const isEnd = rawX <= edgeTolMm || rawX >= refW - edgeTolMm;
|
||||
const endSide = rawX <= refW / 2 ? 'left' : 'right';
|
||||
const x = clampX(mapJunctionXAcrossWidthMm(widthMm, xmm));
|
||||
for (const st of stations) {
|
||||
const along = st.holesAlongMm || [];
|
||||
for (let hi = 0; hi < along.length; hi++) {
|
||||
const alongMm = along[hi];
|
||||
const yMm = clampY(alongMm);
|
||||
const isTourillon = hi !== 1;
|
||||
if (isTourillon) {
|
||||
out.push({
|
||||
xMm: x,
|
||||
yMm,
|
||||
diameterMm: dTour,
|
||||
depthMm: zTour,
|
||||
type: 'tourillon',
|
||||
face: isEnd ? 'end' : 'face',
|
||||
...(isEnd ? { end: endSide } : {})
|
||||
});
|
||||
} else {
|
||||
/* Même perçage ferrure (Ø/prof) ; type ixconnect pour G-code / 3D : violet + long sur chant montant, comme renfort. */
|
||||
out.push({
|
||||
xMm: x,
|
||||
yMm,
|
||||
diameterMm: CARCASS_FERRURE_FACE_DIAMETER_MM,
|
||||
depthMm: isEnd ? zFerrEdge : zFerr,
|
||||
type: 'ixconnect',
|
||||
face: isEnd ? 'end' : 'face',
|
||||
...(isEnd ? { end: endSide } : {})
|
||||
});
|
||||
/* Trou Inbus : même règle que connecteur T–IX–T (un par Ixconnect ferrure). Sans ceci, les jeux côté rainure
|
||||
* (souvent issus de ferrures caisse seules) n’ont pas de trou violet Inbus en plan / G-code / 3D. */
|
||||
{
|
||||
const lock = CONNECTORS.ixconnect.lockScrewInbus;
|
||||
const dLock = Number(lock?.diameter) || 6;
|
||||
const zLock = Number(lock?.depth) || 9;
|
||||
const epsMm = 0.1;
|
||||
let xInbus = clampX(x);
|
||||
if (isEnd) {
|
||||
const depthAlongMm = Math.min(zFerrEdge, widthMm * 0.48, 80);
|
||||
xInbus =
|
||||
endSide === 'left'
|
||||
? depthAlongMm / 2 + epsMm
|
||||
: widthMm - depthAlongMm / 2 - epsMm;
|
||||
xInbus = clampX(xInbus);
|
||||
}
|
||||
out.push({
|
||||
xMm: xInbus,
|
||||
yMm,
|
||||
diameterMm: dLock,
|
||||
depthMm: zLock,
|
||||
type: 'ixconnect_inbus',
|
||||
face: 'face'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ch = m.connectorHoles;
|
||||
const joints = ch?.ixconnectJointsAlongDepth;
|
||||
if (joints?.length) {
|
||||
const dTour = Number(ch.tourillonDiameter || CONNECTORS.tourillonDiameter || 8);
|
||||
const zTour = tourillonFaceDrillDepthMm(thicknessMm);
|
||||
const dFace = Number(ch.faceHole?.diameter || CONNECTORS.ixconnect.faceHole.diameter || 8);
|
||||
const zFace = Number(ch.faceHole?.depth || CONNECTORS.ixconnect.faceHole.depth || 12);
|
||||
const dEdge = Number(ch.edgeHole?.diameter || CONNECTORS.ixconnect.edgeHole.diameter || 8);
|
||||
const zEdge = Number(ch.edgeHole?.depth || CONNECTORS.ixconnect.edgeHole.depth || 45);
|
||||
const zTourEdge = Math.min(zTour, 16);
|
||||
|
||||
for (const j of joints) {
|
||||
/* isEnd doit utiliser l’axe nominal (repère caisse), pas clampX avant : si brut CNC < largeur finie,
|
||||
* clampX(axe droit) recule sous refW−ε et la jonction droite n’est plus « extrémité » → un seul Inbus. */
|
||||
const axisNominal = Number(j.axisAcrossWidthMm) || 0;
|
||||
const rawAcross = clampX(axisNominal);
|
||||
const isEnd =
|
||||
axisNominal <= edgeTolMm || axisNominal >= refW - edgeTolMm;
|
||||
const endSide = rawAcross <= refW / 2 ? 'left' : 'right';
|
||||
const x = isEnd
|
||||
? rawAcross <= edgeTolMm
|
||||
? 0
|
||||
: refW
|
||||
: clampX(mapJunctionXAcrossWidthMm(widthMm, j.axisAcrossWidthMm));
|
||||
const seq = [
|
||||
{ axis: j.tourillon1Axis, d: dTour, z: isEnd ? zTourEdge : zTour, type: 'tourillon' },
|
||||
{ axis: j.ixconnectAxis, d: isEnd ? dEdge : dFace, z: isEnd ? zEdge : zFace, type: 'ixconnect' },
|
||||
{ axis: j.tourillon2Axis, d: dTour, z: isEnd ? zTourEdge : zTour, type: 'tourillon' }
|
||||
];
|
||||
for (const h of seq) {
|
||||
const pos = Number(h.axis);
|
||||
const yMm = clampY(pos);
|
||||
out.push({
|
||||
xMm: x,
|
||||
yMm,
|
||||
diameterMm: h.d,
|
||||
depthMm: h.z,
|
||||
type: h.type,
|
||||
face: isEnd ? 'end' : 'face',
|
||||
...(isEnd ? { end: endSide } : {})
|
||||
});
|
||||
}
|
||||
|
||||
/* Trou de serrage Inbus : un par Ixconnect — même (Y) que l’axe Ixconnect ; X aligné sur le centre du
|
||||
* perçage tel qu’affiché en 3D (même formule que Cnc3DViewer pour FACE=end : min(prof chant, 48 % largeur, 80 mm) / 2).
|
||||
* Sur jonction face : X = x du trio. Sur extrémité (montant) : entrée toujours face grande (perpendiculaire),
|
||||
* mais X ne peut pas rester 0/refW sinon le cylindre est sur le bord au lieu du centre du violet sur chant. */
|
||||
{
|
||||
const lock = CONNECTORS.ixconnect.lockScrewInbus;
|
||||
const dLock = Number(lock?.diameter) || 6;
|
||||
const zLock = Number(lock?.depth) || 9;
|
||||
const yIx = clampY(Number(j.ixconnectAxis));
|
||||
const epsMm = 0.1;
|
||||
let xInbus = clampX(x);
|
||||
if (isEnd) {
|
||||
const depthAlongMm = Math.min(zEdge, widthMm * 0.48, 80);
|
||||
xInbus =
|
||||
endSide === 'left'
|
||||
? depthAlongMm / 2 + epsMm
|
||||
: widthMm - depthAlongMm / 2 - epsMm;
|
||||
xInbus = clampX(xInbus);
|
||||
}
|
||||
out.push({
|
||||
xMm: xInbus,
|
||||
yMm: yIx,
|
||||
diameterMm: dLock,
|
||||
depthMm: zLock,
|
||||
type: 'ixconnect_inbus',
|
||||
face: 'face'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rayons mobiles (Étagères amovibles) — système SP 15/8/5
|
||||
* - Perçage face (dessous non visible): Ø20 × 15, axe à 9.5 mm du bord (gauche/droite)
|
||||
* - Perçage sur chant (épaisseur, contre côtés): Ø8 × 37, positionné à 6 mm de la face inférieure
|
||||
* - Positionnement en profondeur (Système 32):
|
||||
* - Depuis bord avant: 32 mm (37 axe caisse - 5 mm retrait étagère)
|
||||
* - Depuis bord arrière: 37 mm
|
||||
*
|
||||
* Note: le plan 2D (X,Y) ne porte pas l'offset en épaisseur (6 mm) pour le perçage chant ; on l’encode
|
||||
* comme perçage "end" pour la visualisation et l’annotation G-code.
|
||||
*
|
||||
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string, end?: string }>}
|
||||
*/
|
||||
export function buildShelfMobileHoleSpecsMm(piece, widthMm, heightMm) {
|
||||
const out = [];
|
||||
const et = (piece?.elementType || '');
|
||||
if (!['etageres', 'rayonFixe'].includes(et)) return out;
|
||||
const m = piece?.machining || {};
|
||||
const sp = m?.mobileShelfSystem;
|
||||
const isFixed = et === 'rayonFixe';
|
||||
if (!sp || (sp.model !== 'sp_15_8_5' && sp.model !== 'sp_15_8_5_face_only')) return out;
|
||||
|
||||
const W = Math.max(1, Number(widthMm) || 1);
|
||||
const D = Math.max(1, Number(heightMm) || 1);
|
||||
const clampX = (v) => Math.max(0, Math.min(W, Number(v) || 0));
|
||||
const clampY = (v) => Math.max(0, Math.min(D, Number(v) || 0));
|
||||
|
||||
const axisFromSide = Number(sp.faceHole?.axisFromSideMm ?? 9.5);
|
||||
const dFace = Number(sp.faceHole?.diameterMm ?? 20);
|
||||
const zFace = Number(sp.faceHole?.depthMm ?? 15);
|
||||
|
||||
const dEdge = Number(sp.edgeHole?.diameterMm ?? 8);
|
||||
const zEdge = Number(sp.edgeHole?.depthMm ?? 37);
|
||||
|
||||
const yFront = clampY(Number(sp.positions?.fromFrontMm ?? 32));
|
||||
const yBack = clampY(D - Number(sp.positions?.fromBackMm ?? 37));
|
||||
const yPos = [yFront, yBack].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
|
||||
|
||||
const xLeft = clampX(axisFromSide);
|
||||
const xRight = clampX(W - axisFromSide);
|
||||
const xSides = [xLeft, xRight].filter((v, idx, arr) => arr.findIndex((u) => Math.abs(u - v) < 1e-6) === idx);
|
||||
|
||||
for (const yMm of yPos) {
|
||||
// Perçage face dessous (non visible): 2 côtés (gauche/droite)
|
||||
for (const xMm of xSides) {
|
||||
out.push({
|
||||
xMm,
|
||||
yMm,
|
||||
diameterMm: dFace,
|
||||
depthMm: zFace,
|
||||
type: 'shelf_mobile_face',
|
||||
face: 'face'
|
||||
});
|
||||
}
|
||||
|
||||
// Perçage chant côté gauche/droit (rayons mobiles uniquement)
|
||||
if (!isFixed) {
|
||||
out.push({
|
||||
xMm: 0,
|
||||
yMm,
|
||||
diameterMm: dEdge,
|
||||
depthMm: zEdge,
|
||||
type: 'shelf_mobile_edge',
|
||||
face: 'end',
|
||||
end: 'left'
|
||||
});
|
||||
out.push({
|
||||
xMm: W,
|
||||
yMm,
|
||||
diameterMm: dEdge,
|
||||
depthMm: zEdge,
|
||||
type: 'shelf_mobile_edge',
|
||||
face: 'end',
|
||||
end: 'right'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renforts tiroir 25 mm — 4× Ixconnect SC 8/25 aux coins (50 mm des bords), repère Y depuis bord haut.
|
||||
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string }>}
|
||||
*/
|
||||
function coteInnerFaceHasRenfortHoles(faceBlock) {
|
||||
return Array.isArray(faceBlock?.holes) && faceBlock.holes.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pièce « côté » seul (gauche ou droit) : une seule face intérieure est présente dans `machining`.
|
||||
* Pièce qty 2 : gauche = partIndex 0, droit = 1 lorsque les deux faces existent.
|
||||
*/
|
||||
function resolveCoteDrawerRenfortInnerFace(cotes, partIndex) {
|
||||
const L = cotes?.leftInnerFace;
|
||||
const R = cotes?.rightInnerFace;
|
||||
const hasL = coteInnerFaceHasRenfortHoles(L);
|
||||
const hasR = coteInnerFaceHasRenfortHoles(R);
|
||||
if (hasL && hasR) {
|
||||
return partIndex === 0 ? L : R;
|
||||
}
|
||||
if (hasL) return L;
|
||||
if (hasR) return R;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Montants / séparateurs : Ixconnect SC 8/25 zone renfort tiroirs cachés (repère X avant, Y bas).
|
||||
* @param {number} [partIndex] — pour cotes: 0 = montant gauche, 1 = droit lorsque les deux sont sur la même ligne ; pour séparateurs: index du séparateur
|
||||
*/
|
||||
export function buildDrawerRenfortPanelIxconnectSpecsMm(piece, widthMm, heightMm, partIndex = 0) {
|
||||
const et = piece?.elementType || '';
|
||||
const m = piece?.machining?.drawerRenfortPanelIxconnect;
|
||||
if (!m) return [];
|
||||
const d = Number(m.faceHole?.diameter ?? DRAWER_RENFORT_PANEL_IXCONNECT.faceHoleDiameterMm) || 8;
|
||||
const z = Number(m.faceHole?.depth ?? DRAWER_RENFORT_PANEL_IXCONNECT.faceHoleDepthMm) || 12;
|
||||
const out = [];
|
||||
|
||||
if (et === 'cotes' && m.cotes) {
|
||||
const face = resolveCoteDrawerRenfortInnerFace(m.cotes, partIndex);
|
||||
const holes = face?.holes;
|
||||
if (!holes?.length) return [];
|
||||
for (const h of holes) {
|
||||
out.push({
|
||||
xMm: Number(h.xMm) || 0,
|
||||
yMm: Number(h.yMm) || 0,
|
||||
diameterMm: d,
|
||||
depthMm: z,
|
||||
type: 'ixconnect_drawer_renfort_panel',
|
||||
face: 'face'
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (et === 'panneauxInterieurs' && Array.isArray(m.separators)) {
|
||||
const sep = m.separators[partIndex];
|
||||
if (!sep) return [];
|
||||
/** Vers module d’indice bas : une grande face du panneau ; vers module haut : l’autre (épaisseur). */
|
||||
const pushFace = (block, tag, faceKey) => {
|
||||
if (!block?.holes?.length) return;
|
||||
for (const h of block.holes) {
|
||||
out.push({
|
||||
xMm: Number(h.xMm) || 0,
|
||||
yMm: Number(h.yMm) || 0,
|
||||
diameterMm: d,
|
||||
depthMm: z,
|
||||
type: `ixconnect_drawer_renfort_${tag}`,
|
||||
face: faceKey
|
||||
});
|
||||
}
|
||||
};
|
||||
pushFace(sep.faceTowardLowerModule, 'sep_lower', 'face');
|
||||
pushFace(sep.faceTowardHigherModule, 'sep_higher', 'back');
|
||||
return out;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildDrawerReinforcementHoleSpecsMm(piece, widthMm, heightMm) {
|
||||
if ((piece?.elementType || '') !== 'renfortsTiroir') return [];
|
||||
const ix = piece?.machining?.drawerReinforcement?.ixconnectFixing;
|
||||
if (!ix?.holes?.length) return [];
|
||||
const d = Number(ix.holeDiameterMm ?? DRAWER_REINFORCEMENT.ixconnectHoleDiameterMm) || 8;
|
||||
const z = Number(ix.depthInReinforcementMm ?? DRAWER_REINFORCEMENT.ixconnectDepthInReinforcementMm) || 13;
|
||||
return ix.holes.map((h) => ({
|
||||
xMm: Number(h.xMm) || 0,
|
||||
yMm: Number(h.yMm) || 0,
|
||||
diameterMm: d,
|
||||
depthMm: z,
|
||||
type: 'ixconnect_drawer_reinforcement',
|
||||
face: 'face'
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Montants : perçages face (axe épaisseur) Ixconnect + tourillons, 2 rangées haut/bas.
|
||||
* Séparateurs verticaux : perçages en about sur les petits côtés d’extrémité (faces w×ép. aux deux bouts de la longueur),
|
||||
* axe le long de Z (hauteur brut), pas depuis les grands chants profondeur (X).
|
||||
* X = centres depuis bord avant profondeur ; Y G-code = 0 (bord bas) ou H (bord haut) — repère contour brut dans le plan pièce.
|
||||
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string, end?: string }>}
|
||||
*/
|
||||
export function buildVerticalPanelIxconnectFaceHoleSpecsMm(piece, widthMm, heightMm) {
|
||||
const out = [];
|
||||
const et = piece?.elementType || '';
|
||||
if (!['cotes', 'panneauxInterieurs'].includes(et)) return out;
|
||||
const ch = piece?.machining?.connectorHoles;
|
||||
const p = ch?.jointPattern;
|
||||
if (!p) return out;
|
||||
|
||||
const H = Math.max(1, Number(heightMm) || 1);
|
||||
const W = Math.max(1, Number(widthMm) || 1);
|
||||
const inset = IXCONNECT_VERTICAL_JOINT_ROW_INSET_MM;
|
||||
if (H < 2 * inset + 5) return out;
|
||||
|
||||
const thicknessMm = Math.max(1, Number(piece?.thicknessMm) || 19);
|
||||
const clampY = (v) => Math.max(0, Math.min(H, Number(v) || 0));
|
||||
const dTour = Number(ch.tourillonDiameter || CONNECTORS.tourillonDiameter || 8);
|
||||
const zTour = tourillonFaceDrillDepthMm(thicknessMm);
|
||||
const dFace = Number(ch.faceHole?.diameter || CONNECTORS.ixconnect.faceHole.diameter || 8);
|
||||
const zFace = Number(ch.faceHole?.depth || CONNECTORS.ixconnect.faceHole.depth || 12);
|
||||
const dEdge = Number(ch.edgeHole?.diameter || CONNECTORS.ixconnect.edgeHole.diameter || 8);
|
||||
const zIxEdge = Number(ch.edgeHole?.depth || CONNECTORS.ixconnect.edgeHole.depth || 45);
|
||||
const zTourEdge = Math.min(tourillonFaceDrillDepthMm(thicknessMm), 15);
|
||||
|
||||
const yRows = [inset, H - inset];
|
||||
const t1 = Number(p.tourillon1Axis);
|
||||
const t2 = Number(p.tourillon2Axis);
|
||||
const ix = Number(p.ixconnectAxis);
|
||||
const mirrorBack =
|
||||
W >= IXCONNECT_SEPARATOR_BACK_ROW_MIN_WIDTH_MM && t2 > t1;
|
||||
|
||||
if (et === 'panneauxInterieurs') {
|
||||
const seqFront = [
|
||||
{ axis: t1, d: dTour, z: zTourEdge, type: 'tourillon' },
|
||||
{ axis: ix, d: dEdge, z: zIxEdge, type: 'ixconnect' },
|
||||
{ axis: t2, d: dTour, z: zTourEdge, type: 'tourillon' }
|
||||
];
|
||||
const seqBack = mirrorBack
|
||||
? [
|
||||
{ axis: W - t2, d: dTour, z: zTourEdge, type: 'tourillon' },
|
||||
{ axis: W - ix, d: dEdge, z: zIxEdge, type: 'ixconnect' },
|
||||
{ axis: W - t1, d: dTour, z: zTourEdge, type: 'tourillon' }
|
||||
]
|
||||
: null;
|
||||
|
||||
const pushEndRow = (yMm, seq, endSide) => {
|
||||
const cy = clampY(yMm);
|
||||
for (const h of seq) {
|
||||
const xMm = mapJunctionXAcrossWidthMm(W, Number(h.axis));
|
||||
out.push({
|
||||
xMm,
|
||||
yMm: cy,
|
||||
diameterMm: h.d,
|
||||
depthMm: h.z,
|
||||
type: h.type,
|
||||
face: 'end',
|
||||
end: endSide
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/* Y = 0 / H : extrémités longueur du brut (petits côtés w×ép.), pas un inset sur la grande face. */
|
||||
pushEndRow(0, seqFront, 'bottom');
|
||||
if (seqBack) pushEndRow(0, seqBack, 'bottom');
|
||||
pushEndRow(H, seqFront, 'top');
|
||||
if (seqBack) pushEndRow(H, seqBack, 'top');
|
||||
return out;
|
||||
}
|
||||
|
||||
/* cotes : face */
|
||||
const seqFront = [
|
||||
{ axis: t1, d: dTour, z: zTour, type: 'tourillon' },
|
||||
{ axis: ix, d: dFace, z: zFace, type: 'ixconnect' },
|
||||
{ axis: t2, d: dTour, z: zTour, type: 'tourillon' }
|
||||
];
|
||||
const seqBack = mirrorBack
|
||||
? [
|
||||
{ axis: W - t2, d: dTour, z: zTour, type: 'tourillon' },
|
||||
{ axis: W - ix, d: dFace, z: zFace, type: 'ixconnect' },
|
||||
{ axis: W - t1, d: dTour, z: zTour, type: 'tourillon' }
|
||||
]
|
||||
: null;
|
||||
const pushFaceRow = (yMm, seq) => {
|
||||
const cy = clampY(yMm);
|
||||
for (const h of seq) {
|
||||
const xMm = mapJunctionXAcrossWidthMm(W, Number(h.axis));
|
||||
out.push({
|
||||
xMm,
|
||||
yMm: cy,
|
||||
diameterMm: h.d,
|
||||
depthMm: h.z,
|
||||
type: h.type,
|
||||
face: 'face'
|
||||
});
|
||||
}
|
||||
};
|
||||
for (const yMm of yRows) {
|
||||
pushFaceRow(yMm, seqFront);
|
||||
if (seqBack) pushFaceRow(yMm, seqBack);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Habillages de finition Premium — perçages SC 8/25 (Ixconnect) stockés dans `piece.machining.finishHabillage.holes`.
|
||||
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string, end?: string }>}
|
||||
*/
|
||||
export function buildFinishHabillageHoleSpecsMm(piece, widthMm, heightMm) {
|
||||
const out = [];
|
||||
const fh = piece?.machining?.finishHabillage;
|
||||
const holes = fh?.holes;
|
||||
if (!Array.isArray(holes) || holes.length === 0) return out;
|
||||
const W = Math.max(1, Number(widthMm) || 1);
|
||||
const H = Math.max(1, Number(heightMm) || 1);
|
||||
const clampX = (v) => Math.max(0, Math.min(W, Number(v) || 0));
|
||||
const clampY = (v) => Math.max(0, Math.min(H, Number(v) || 0));
|
||||
for (const h of holes) {
|
||||
if (!h) continue;
|
||||
out.push({
|
||||
xMm: clampX(h.xMm),
|
||||
yMm: clampY(h.yMm),
|
||||
diameterMm: Number(h.diameterMm) || Number(fh?.faceHole?.diameter) || 8,
|
||||
depthMm: Number(h.depthMm) || Number(fh?.faceHole?.depth) || 12.5,
|
||||
type: h.type || 'ixconnect',
|
||||
face: h.face || 'face',
|
||||
...(h.end ? { end: h.end } : {})
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Portes : cuvettes charnières (Blum) + 2 perçages Ø8 par cuvette (45 mm c/c, centres à 9,5 mm du centre cuvette vers l’intérieur).
|
||||
* Positions (depuis haut) : axe 103 mm et grille System 32 (voir getHingeCupPositions).
|
||||
* Repère plan: x = largeur gauche→droite ; y = hauteur depuis bas.
|
||||
*
|
||||
* @returns {Array<{ xMm: number, yMm: number, diameterMm: number, depthMm: number, type: string, face: string }>}
|
||||
*/
|
||||
export function buildDoorHingeCupHoleSpecsMm(piece, widthMm, heightMm) {
|
||||
const out = [];
|
||||
if ((piece?.elementType || '') !== 'portes') return out;
|
||||
|
||||
const W = Math.max(1, Number(widthMm) || 1);
|
||||
const H = Math.max(1, Number(heightMm) || 1);
|
||||
const clampX = (v) => Math.max(0, Math.min(W, Number(v) || 0));
|
||||
const clampY = (v) => Math.max(0, Math.min(H, Number(v) || 0));
|
||||
|
||||
const hc = piece?.machining?.hingeCups;
|
||||
if (!hc) return out;
|
||||
|
||||
const diameterMm = Number(hc.diameter) || 35;
|
||||
const depthMm = Number(hc.cupDepth) || 12;
|
||||
|
||||
const screwDiameterMm = Number(hc.screwDiameterMm ?? HINGE.cupScrewDiameter) || 8;
|
||||
const screwDepthMm = Number(hc.screwDepthMm ?? HINGE.cupScrewDepth) || 13;
|
||||
const screwSpacingMm = Number(hc.screwSpacingMm ?? HINGE.cupScrewSpacing) || 45;
|
||||
const screwOffsetMm = Number(hc.screwOffsetFromCupCenterMm ?? HINGE.cupScrewOffsetFromCupCenter) || 9.5;
|
||||
const halfScrewSpacing = screwSpacingMm / 2;
|
||||
|
||||
// Default Blum inset for cup center (~22mm from edge) unless explicitly set upstream.
|
||||
const axisFromEdgeMm = Number(hc.axisFromEdgeMm ?? hc.axisFromSideMm ?? hc.axisFromEdge ?? 22);
|
||||
|
||||
// Hinge side hint (viewer-only). If absent, keep a consistent default.
|
||||
const hingeSide = piece?.doorPlanHints?.hingeSide === 'right' ? 'right' : 'left';
|
||||
const xMm = hingeSide === 'right' ? clampX(W - axisFromEdgeMm) : clampX(axisFromEdgeMm);
|
||||
const dxInterior = hingeSide === 'right' ? -screwOffsetMm : screwOffsetMm;
|
||||
|
||||
const count = Math.max(2, Number(hc.count) || getHingeCountForDoor({ heightMm: H }));
|
||||
const positionsFromTop =
|
||||
Array.isArray(hc.positionsFromTop) && hc.positionsFromTop.length
|
||||
? hc.positionsFromTop.map((v) => Number(v)).filter((v) => Number.isFinite(v))
|
||||
: getHingeCupPositions(H, count);
|
||||
|
||||
for (const fromTop of positionsFromTop) {
|
||||
const yMm = clampY(H - fromTop); // convert "from top" to plan Y from bottom
|
||||
out.push({
|
||||
xMm,
|
||||
yMm,
|
||||
diameterMm,
|
||||
depthMm,
|
||||
type: 'hinge_cup',
|
||||
face: 'face'
|
||||
});
|
||||
const xScrew = clampX(xMm + dxInterior);
|
||||
for (const dy of [-halfScrewSpacing, halfScrewSpacing]) {
|
||||
out.push({
|
||||
xMm: xScrew,
|
||||
yMm: clampY(yMm + dy),
|
||||
diameterMm: screwDiameterMm,
|
||||
depthMm: screwDepthMm,
|
||||
type: 'hinge_screw',
|
||||
face: 'face'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
/**
|
||||
* APEX 4.0 — Dossier Technique Maître
|
||||
* Single source of truth for all manufacturing specifications.
|
||||
* Every dimension in millimetres unless noted otherwise.
|
||||
*/
|
||||
|
||||
// ── Panel thicknesses ────────────────────────────────────────────────
|
||||
export const PANEL = {
|
||||
structure: 19, // Côtés, dessus, fond, séparateurs, étagères
|
||||
reinforcement: 16, // Renforts Forteresse
|
||||
back: 8, // Dos (panneau arrière)
|
||||
reinforcementWidth: 100, // Largeur standard d'un renfort
|
||||
};
|
||||
|
||||
// ── Reinforcement layout rules (Renforts Forteresse) ────────────────
|
||||
export const REINFORCEMENT = {
|
||||
topBottomOffsetMm: 19, // Renfort haut/bas à 19 mm
|
||||
thirdRequiredAboveHeightMm: 1500, // 3e renfort si armoire > 1.5 m
|
||||
};
|
||||
|
||||
// ── Edge banding (Chants ABS) ────────────────────────────────────────
|
||||
export const EDGE_BANDING = {
|
||||
abs: 2, // Chant ABS visible (2 mm)
|
||||
hidden: 0.5, // Chant fin caché (0.5 mm)
|
||||
backPanelSides: 2, // Dos: ABS sur 2 chants longs (haut + bas)
|
||||
topBottomSides: 2, // Dessus/Fond: ABS faces avant ET arrière
|
||||
shelfSides: 1, // Étagères: ABS face avant uniquement
|
||||
};
|
||||
|
||||
// ── Groove for back panel (Rainure dos) ──────────────────────────────
|
||||
export const GROOVE = {
|
||||
back: {
|
||||
width: 9, // mm — largeur rainure
|
||||
depth: 10.5, // mm — profondeur rainure
|
||||
axisFromPanelEdge: 16, // mm — rainure à 16 mm du bord des côtés/fond/dessus
|
||||
},
|
||||
};
|
||||
|
||||
/** Rainure dos — plans / CNC : même coupe que montant (vertical) vs dessus/fond/bandeau (horizontal). */
|
||||
export const BACK_GROOVE_PLAN_VERTICAL_TYPES = ['cotes', 'fileur', 'cotesDoublage'];
|
||||
export const BACK_GROOVE_PLAN_HORIZONTAL_TYPES = ['dessus', 'fond', 'bandeauHaut'];
|
||||
|
||||
/** @returns {'vertical' | 'horizontal' | null} */
|
||||
export function getBackGroovePlanOrientation(elementType) {
|
||||
if (BACK_GROOVE_PLAN_VERTICAL_TYPES.includes(elementType)) return 'vertical';
|
||||
if (BACK_GROOVE_PLAN_HORIZONTAL_TYPES.includes(elementType)) return 'horizontal';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Séparateurs : plaqués contre le dos (sans rainure). Largeur = même entraxe profondeur que le montant,
|
||||
* diminuée du décalage d’axe et de la demi-largeur de rainure (côtés).
|
||||
* @param {number} carcassDepthMm — profondeur caisse / largeur en plan du panneau côté (mm)
|
||||
*/
|
||||
export function getSeparatorDepthMm(carcassDepthMm) {
|
||||
const d = Number(carcassDepthMm) || 0;
|
||||
return Math.round(d - GROOVE.back.axisFromPanelEdge - GROOVE.back.width / 2);
|
||||
}
|
||||
|
||||
// ── Connectors (Häfele Ixconnect) ────────────────────────────────────
|
||||
export const CONNECTORS = {
|
||||
// SC 8/60 — structural assembly (caisse: côtés ↔ dessus/fond)
|
||||
ixconnect: {
|
||||
model: 'SC 8/60',
|
||||
edgeHole: { diameter: 8, depth: 45 }, // mm — perçage en about
|
||||
faceHole: { diameter: 8, depth: 12 }, // mm — perçage en face
|
||||
/** Trou de serrage Inbus — face intérieure panneaux horizontaux (dessus/fond) uniquement */
|
||||
lockScrewInbus: {
|
||||
diameter: 6,
|
||||
depth: 9,
|
||||
/** mm — depuis le chant d’assemblage (axe du perçage Ø45), vers l’intérieur du panneau */
|
||||
offsetFromAssemblyEdgeMm: 25
|
||||
}
|
||||
},
|
||||
// SC 8/25 — finish panels (portes, finitions)
|
||||
ixconnectFinish: {
|
||||
model: 'SC 8/25',
|
||||
faceHole: { diameter: 8, depth: 12.5 }, // mm — face interne Ø8 × 12.5mm
|
||||
axes: { back: 30, front: 43 }, // mm — axes: 30mm (arrière) / 43mm (avant)
|
||||
},
|
||||
// Positioning per structural joint (axes mm depuis le bord avant de l’arête, le long de la profondeur)
|
||||
// — Arêtes d’extrémité (montants extérieurs): T → IX → T
|
||||
// — Jonctions internes (séparateurs): Ixconnect à l’axe médian profondeur, tourillons ±30 mm de cet axe
|
||||
jointPatternEdge: {
|
||||
tourillon1Axis: 20,
|
||||
ixconnectAxis: 50,
|
||||
tourillon2Axis: 80,
|
||||
},
|
||||
/** @deprecated use jointPatternEdge — alias pour compat. */
|
||||
jointPattern: {
|
||||
tourillon1Axis: 20,
|
||||
ixconnectAxis: 50,
|
||||
tourillon2Axis: 80,
|
||||
},
|
||||
/** Tourillons par rapport à l’axe Ixconnect sur jonction milieu (mm). */
|
||||
jointPatternMiddleTourillonOffsetFromIxMm: 30,
|
||||
tourillon: {
|
||||
diameter: 8, // mm — Ø tourillon
|
||||
length: 30, // mm — tourillon Ø8×30
|
||||
},
|
||||
tourillonDiameter: 8, // mm — backward compat
|
||||
};
|
||||
|
||||
/**
|
||||
* mm — depuis le bord physique panneau côté dos : axe rainure + largeur rainure (= fin utile côté dos, repère Ixconnect).
|
||||
* Évite chevauchement perçages / rainure dos sur dessus·fond.
|
||||
*/
|
||||
export function getBackGrooveInnerEndInsetFromBackEdgeMm() {
|
||||
return GROOVE.back.axisFromPanelEdge + GROOVE.back.width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Axes Ixconnect / tourillons le long de l’arête (profondeur caisse pour dessus·fond, largeur = profondeur pour côtés),
|
||||
* mesurés depuis le bord avant (face avant).
|
||||
* @param {'edge'|'middle'} variant — extrémité de caisse vs jonction entre modules (séparateur)
|
||||
* @param {{ horizontalPanel?: boolean }} [options] — si `horizontalPanel`, la profondeur utile exclut la zone rainure dos (16+9 mm depuis le bord arrière).
|
||||
*/
|
||||
export function getIxconnectJointPatternAlongDepthMm(depthMm, variant, options = {}) {
|
||||
const horizontalPanel = Boolean(options.horizontalPanel);
|
||||
const inset = horizontalPanel ? getBackGrooveInnerEndInsetFromBackEdgeMm() : 0;
|
||||
const d = Math.max(0, Number(depthMm) || 0);
|
||||
const dEff = horizontalPanel ? Math.max(0, d - inset) : d;
|
||||
const round1 = (v) => Math.round(v * 10) / 10;
|
||||
if (variant === 'edge') {
|
||||
const t1 = CONNECTORS.jointPatternEdge.tourillon1Axis;
|
||||
const ix = CONNECTORS.jointPatternEdge.ixconnectAxis;
|
||||
const t2 = CONNECTORS.jointPatternEdge.tourillon2Axis;
|
||||
if (!horizontalPanel || dEff <= 0) {
|
||||
return {
|
||||
role: 'edge',
|
||||
tourillon1Axis: t1,
|
||||
ixconnectAxis: ix,
|
||||
tourillon2Axis: t2
|
||||
};
|
||||
}
|
||||
if (dEff >= t2) {
|
||||
return {
|
||||
role: 'edge',
|
||||
tourillon1Axis: t1,
|
||||
ixconnectAxis: ix,
|
||||
tourillon2Axis: t2
|
||||
};
|
||||
}
|
||||
const s = dEff / t2;
|
||||
return {
|
||||
role: 'edge',
|
||||
tourillon1Axis: round1(t1 * s),
|
||||
ixconnectAxis: round1(ix * s),
|
||||
tourillon2Axis: round1(t2 * s)
|
||||
};
|
||||
}
|
||||
if (dEff <= 0) {
|
||||
return {
|
||||
role: 'middle',
|
||||
tourillon1Axis: 0,
|
||||
ixconnectAxis: 0,
|
||||
tourillon2Axis: 0
|
||||
};
|
||||
}
|
||||
const ixMid = round1(dEff / 2);
|
||||
const off = CONNECTORS.jointPatternMiddleTourillonOffsetFromIxMm;
|
||||
return {
|
||||
role: 'middle',
|
||||
tourillon1Axis: round1(Math.max(0, ixMid - off)),
|
||||
ixconnectAxis: ixMid,
|
||||
tourillon2Axis: round1(Math.min(dEff, ixMid + off))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Largeurs de colonnes (mm) pour placer les jonctions — ordre: modules API, puis blocks, puis config.modules.
|
||||
* Évite le cas « modules = [] » alors que `config.modules` contient les largeurs (une seule jonction / mauvais comptage).
|
||||
*/
|
||||
function getModuleWidthsForJunctions(modules = [], config = {}) {
|
||||
const fromArr = (arr) =>
|
||||
(Array.isArray(arr) ? arr : [])
|
||||
.map((m) => Number(m?.widthMm) || 0)
|
||||
.filter((v) => v > 0);
|
||||
let w = fromArr(modules);
|
||||
if (w.length === 0) w = fromArr(config.blocks);
|
||||
if (w.length === 0) w = fromArr(config.modules);
|
||||
return w;
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions X des jonctions verticales (côtés + séparateurs) sur un panneau horizontal — mm depuis le bord gauche.
|
||||
*/
|
||||
export function getJunctionAxesAcrossWidthMm(dimensionsWidthMm, modules = [], config = {}) {
|
||||
const maxW = Math.max(0, Number(dimensionsWidthMm) || 0);
|
||||
const mWidths = getModuleWidthsForJunctions(modules, config);
|
||||
if (mWidths.length > 0) {
|
||||
let acc = 0;
|
||||
const axes = [0];
|
||||
for (let i = 0; i < mWidths.length - 1; i++) {
|
||||
acc += mWidths[i];
|
||||
axes.push(Math.max(0, Math.min(maxW, acc)));
|
||||
}
|
||||
axes.push(maxW);
|
||||
const sorted = [...axes].sort((a, b) => a - b);
|
||||
const dedup = [];
|
||||
const eps = 0.5;
|
||||
for (const v of sorted) {
|
||||
if (dedup.length === 0 || Math.abs(v - dedup[dedup.length - 1]) > eps) {
|
||||
dedup.push(Math.round(v * 100) / 100);
|
||||
}
|
||||
}
|
||||
return dedup;
|
||||
}
|
||||
return [0, maxW];
|
||||
}
|
||||
|
||||
// ── System 32 drilling ───────────────────────────────────────────────
|
||||
export const SYSTEM32 = {
|
||||
firstHoleOffset: 51, // mm — distance from panel top/bottom edge to first/last hole
|
||||
pitch: 32, // mm — distance between consecutive holes
|
||||
holeDiameter: 5, // mm — Ø 5 mm
|
||||
holeDepth: 12, // mm — profondeur de perçage (borgne, côtés de caisse)
|
||||
throughHole: false, // Côtés de caisse: perçages borgnes (non traversants)
|
||||
};
|
||||
|
||||
// ── System 32 for vertical separators ────────────────────────────────
|
||||
export const SYSTEM32_SEPARATORS = {
|
||||
firstHoleOffset: 32, // mm — 51mm - 19mm (fond) = 32mm from bottom edge
|
||||
pitch: 32, // mm — same pitch
|
||||
holeDiameter: 5, // mm — Ø 5 mm
|
||||
throughHole: true, // Séparations verticales: Ø5mm TRAVERSANTS
|
||||
};
|
||||
|
||||
// ── Hinge positioning (Blum CLIP top) ────────────────────────────────
|
||||
export const HINGE = {
|
||||
axisOffset: 103, // mm — per APEX 4.0 reversibility rule
|
||||
axisFrom: 'top_and_bottom',
|
||||
perDoor: 3, // default, real count uses matrix below
|
||||
reversible: true, // Panels are reversible (symmetric S32 drilling)
|
||||
model: 'Blum CLIP top',
|
||||
/** Per hinge cup: two Ø8 bores (Blum drilling pattern), depth mm, spacing mm (center–center), offset mm from cup center toward door interior (⟂ hinge edge). */
|
||||
cupScrewDiameter: 8,
|
||||
cupScrewDepth: 13,
|
||||
cupScrewSpacing: 45,
|
||||
cupScrewOffsetFromCupCenter: 9.5,
|
||||
};
|
||||
|
||||
// Height/weight matrix from APEX 4.0 section 6.B
|
||||
export const HINGE_MATRIX = [
|
||||
{ maxHeightMm: 900, maxWeightKg: 6, hinges: 2 },
|
||||
{ maxHeightMm: 1600, maxWeightKg: 17, hinges: 3 },
|
||||
{ maxHeightMm: 2000, maxWeightKg: 20, hinges: 4 },
|
||||
{ maxHeightMm: 2400, maxWeightKg: 22, hinges: 5 },
|
||||
{ maxHeightMm: 2600, maxWeightKg: Infinity, hinges: 6 }
|
||||
];
|
||||
|
||||
export function getHingeCountForDoor({ heightMm, weightKg = null }) {
|
||||
const h = Number(heightMm) || 0;
|
||||
const hasWeight = weightKg !== null && weightKg !== undefined && weightKg !== '';
|
||||
const w = hasWeight && Number.isFinite(Number(weightKg)) ? Number(weightKg) : null;
|
||||
for (const row of HINGE_MATRIX) {
|
||||
const byHeight = h <= row.maxHeightMm;
|
||||
const byWeight = w == null ? false : w <= row.maxWeightKg;
|
||||
if (byHeight || byWeight) return row.hinges;
|
||||
}
|
||||
return HINGE_MATRIX[HINGE_MATRIX.length - 1].hinges;
|
||||
}
|
||||
|
||||
export function getHingeCupPositions(doorHeightMm, hingeCount = null) {
|
||||
const height = Number(doorHeightMm) || 0;
|
||||
const count = Math.max(2, Number(hingeCount) || getHingeCountForDoor({ heightMm: height }));
|
||||
const first = HINGE.axisOffset;
|
||||
const last = Math.max(first, height - HINGE.axisOffset);
|
||||
|
||||
if (count <= 2) return [first, last];
|
||||
|
||||
const spaces = count - 1;
|
||||
const rawStep = (last - first) / spaces;
|
||||
const positions = [first];
|
||||
|
||||
for (let i = 1; i < spaces; i++) {
|
||||
const rawPos = first + rawStep * i;
|
||||
// Rule: align intermediary hinges on System 32 grid.
|
||||
const snapped = Math.round(rawPos / 32) * 32;
|
||||
const minPos = positions[positions.length - 1] + 32;
|
||||
const maxPos = last - (spaces - i) * 32;
|
||||
positions.push(Math.min(Math.max(snapped, minPos), maxPos));
|
||||
}
|
||||
|
||||
positions.push(last);
|
||||
return positions;
|
||||
}
|
||||
|
||||
// ── Plinth system (AXILO™ 78) ───────────────────────────────────────
|
||||
export const PLINTH = {
|
||||
model: 'AXILO 78',
|
||||
footHeight: 78, // mm — hauteur pied réglable
|
||||
clipsPerMeter: 3, // clips de plinthe par mètre linéaire
|
||||
// Reserved packing space for hardware kit
|
||||
packingReserved: true,
|
||||
};
|
||||
|
||||
// ── Reinforcement machining (Renforts Forteresse) ────────────────────
|
||||
export const REINFORCEMENT_MACHINING = {
|
||||
// Sur chaque petit chant d'extrémité: 1× Ixconnect SC 8/60 au centre + 2× tourillons à 20 mm des bords
|
||||
connectorModel: 'SC 8/60',
|
||||
connector: {
|
||||
model: 'SC 8/60',
|
||||
edgeHole: { diameter: 8, depth: 45 },
|
||||
faceHole: { diameter: 8, depth: 12 },
|
||||
},
|
||||
ixconnectCenter: true,
|
||||
tourillonCount: 2,
|
||||
tourillonEdgeOffset: 20, // mm — tourillons à 20 mm des bords (axe mesuré depuis le même repère que l’Ixconnect)
|
||||
tourillonDiameter: 8,
|
||||
};
|
||||
|
||||
/**
|
||||
* Axes de perçage sur le petit chant d'extrémité (mm depuis le bord haut du renfort).
|
||||
* @param {number} reinforcementFaceHeightMm — hauteur de la face de renfort (généralement 100 mm)
|
||||
*/
|
||||
export function getReinforcementEndFixingAxesFromTopMm(reinforcementFaceHeightMm) {
|
||||
const h = Math.max(0, Number(reinforcementFaceHeightMm) || 0);
|
||||
const off = REINFORCEMENT_MACHINING.tourillonEdgeOffset;
|
||||
const round1 = (v) => Math.round(v * 10) / 10;
|
||||
return {
|
||||
// Deux tourillons proches des bords haut/bas de la face 100mm.
|
||||
tourillonAxesFromTopMm: [round1(off), round1(Math.max(off, h - off))],
|
||||
ixconnectAxisFromTopMm: round1(h / 2),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Niche and sanitary gaps ─────────────────────────────────────────────
|
||||
export const NICHE = {
|
||||
sanitaryGapAround: 30, // mm — vide sanitaire de 3 cm tout autour
|
||||
drawerBackGap: 10, // mm — vide sanitaire derrière tiroir
|
||||
};
|
||||
|
||||
// ── Drawers (manufactured) ──────────────────────────────────────────────
|
||||
export const DRAWERS = {
|
||||
maxColumnWidth: 1000, // mm
|
||||
minDepth: 400, // mm
|
||||
maxDepth: 650, // mm
|
||||
depthStep: 50, // mm
|
||||
hiddenHandleDepthReduction: 40, // mm (assumption until final handle specs)
|
||||
standardHeights: [60, 90, 128, 177, 240],
|
||||
};
|
||||
|
||||
/**
|
||||
* Renforts latéraux caisse (25 mm) — uniquement si tiroirs cachés derrière porte ;
|
||||
* un montant gauche + un droit par colonne à tiroirs, même profondeur (min. des deux parois).
|
||||
* Fixation sur la face intérieure des montants / séparateurs.
|
||||
*/
|
||||
export const DRAWER_REINFORCEMENT = {
|
||||
thicknessMm: 25,
|
||||
/** mm depuis le fond caisse (repère rainure côté ou fond séparateur) — début utile du renfort */
|
||||
backInsetFromRearMm: 25,
|
||||
/** mm réservés vers l’avant quand les tiroirs sont derrière une porte à charnières */
|
||||
hingeFrontClearanceMm: 77,
|
||||
/** mm — déduit de la hauteur pile : partie droite au-dessus du rayon façade tiroir du haut */
|
||||
topFrontRadiusMm: 3,
|
||||
/** Ixconnect « petit » — même famille que finitions SC 8/25 */
|
||||
ixconnectModel: 'SC 8/25',
|
||||
/** mm — retrait des axes depuis chaque bord (chaque coin : 50 mm des deux côtés) */
|
||||
ixconnectCornerInsetMm: 50,
|
||||
ixconnectHoleDiameterMm: 8,
|
||||
/** Perçage face côté renfort (vers montant / séparateur) */
|
||||
ixconnectDepthInReinforcementMm: 13,
|
||||
/** Perçage face intérieure montant ou séparateur (vis-à-vis) */
|
||||
ixconnectDepthInVerticalPanelMm: 12,
|
||||
};
|
||||
|
||||
/**
|
||||
* Ixconnect SC 8/25 sur montants / séparateurs — zone tiroirs cachés + renfort.
|
||||
* Repère face intérieure : X depuis bord avant (profondeur), Y depuis bas du panneau (corps).
|
||||
*/
|
||||
export const DRAWER_RENFORT_PANEL_IXCONNECT = {
|
||||
model: 'SC 8/25',
|
||||
/** 77 + 50 mm depuis le devant (axe avant) */
|
||||
frontAxesFromFrontMm: 77 + 50,
|
||||
/** Côté caisse : 19 + 9 + 50 mm depuis le fond */
|
||||
backAxesFromRearCoteMm: 19 + 9 + 50,
|
||||
/** Séparateur : 50 mm depuis le fond */
|
||||
backAxesFromRearSeparatorMm: 50,
|
||||
/** 50 mm du haut / bas de la zone renfort */
|
||||
verticalInsetMm: 50,
|
||||
faceHoleDiameterMm: 8,
|
||||
faceHoleDepthMm: 12
|
||||
};
|
||||
|
||||
/**
|
||||
* 4 trous (rectangle) sur une face intérieure montant/séparateur pour renfort tiroir caché.
|
||||
* @param {number} panelDepthMm — largeur du panneau en plan (= profondeur caisse ou prof. séparateur)
|
||||
* @param {boolean} isCote — true = montant extérieur, false = séparateur
|
||||
* @param {number} zoneBottomFromPanelBottomMm — bas de la zone tiroirs depuis bas du panneau
|
||||
* @param {number} renfortHeightMm — hauteur utile renfort (= pile tiroirs − R)
|
||||
*/
|
||||
export function buildDrawerRenfortPanelIxconnectCornersMm(
|
||||
panelDepthMm,
|
||||
isCote,
|
||||
zoneBottomFromPanelBottomMm,
|
||||
renfortHeightMm
|
||||
) {
|
||||
const P = DRAWER_RENFORT_PANEL_IXCONNECT;
|
||||
const D = Math.max(0, Number(panelDepthMm) || 0);
|
||||
const z0 = Math.max(0, Number(zoneBottomFromPanelBottomMm) || 0);
|
||||
const h = Math.max(0, Number(renfortHeightMm) || 0);
|
||||
const backInset = isCote ? P.backAxesFromRearCoteMm : P.backAxesFromRearSeparatorMm;
|
||||
const xf = P.frontAxesFromFrontMm;
|
||||
const xb = Math.max(xf + 1, D - backInset);
|
||||
const y1 = z0 + P.verticalInsetMm;
|
||||
const y2 = z0 + h - P.verticalInsetMm;
|
||||
if (h < 2 * P.verticalInsetMm + 5 || D < xf + backInset + 5 || y2 <= y1) return [];
|
||||
return [
|
||||
{ xMm: xf, yMm: y1, role: 'avant_bas' },
|
||||
{ xMm: xf, yMm: y2, role: 'avant_haut' },
|
||||
{ xMm: xb, yMm: y1, role: 'fond_bas' },
|
||||
{ xMm: xb, yMm: y2, role: 'fond_haut' }
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 4 trous d’angle pour Ixconnect SC 8/25 — repère plan face : X depuis bord gauche, Y depuis bord haut (mm).
|
||||
* @returns {{ xMm: number, yMm: number, role: string }[]}
|
||||
*/
|
||||
export function getDrawerReinforcementIxconnectHolePositionsMm(widthMm, heightMm, insetMm = DRAWER_REINFORCEMENT.ixconnectCornerInsetMm) {
|
||||
const w = Math.max(0, Number(widthMm) || 0);
|
||||
const h = Math.max(0, Number(heightMm) || 0);
|
||||
const ins = Math.max(8, Math.min(Number(insetMm) || 50, Math.min(w, h) / 2 - 0.5));
|
||||
if (w < 2 * ins || h < 2 * ins) return [];
|
||||
return [
|
||||
{ xMm: ins, yMm: ins, role: 'nw' },
|
||||
{ xMm: w - ins, yMm: ins, role: 'ne' },
|
||||
{ xMm: ins, yMm: h - ins, role: 'sw' },
|
||||
{ xMm: w - ins, yMm: h - ins, role: 'se' }
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hauteur totale du bloc tiroirs (façades + jeux), mm.
|
||||
* @param {number} drawerCount
|
||||
* @param {number[]} drawerHeightsMm
|
||||
* @param {number} [drawerGapMm]
|
||||
*/
|
||||
export function drawerStackHeightMm(drawerCount, drawerHeightsMm, drawerGapMm = 8) {
|
||||
const n = Math.max(0, Number(drawerCount) || 0);
|
||||
if (n <= 0) return 0;
|
||||
const heights = Array.isArray(drawerHeightsMm) && drawerHeightsMm.length ? drawerHeightsMm : DRAWERS.standardHeights;
|
||||
const gap = Number.isFinite(Number(drawerGapMm)) ? Number(drawerGapMm) : 8;
|
||||
const sum = heights
|
||||
.slice(0, Math.min(n, heights.length))
|
||||
.reduce((s, v) => s + (Number(v) || 0), 0);
|
||||
return Math.max(0, Math.round(sum + Math.max(0, n - 1) * gap));
|
||||
}
|
||||
|
||||
// ── Full-height handle assumptions ──────────────────────────────────────
|
||||
export const HANDLE = {
|
||||
/** Retrait mécanique rainure poignée (haut + bas) */
|
||||
fullHeightTopBottomOffset: 2, // mm from top and bottom
|
||||
/** Déduction largeur panneau porte (vs poignée standard) — même valeur liste de débitage / plans */
|
||||
fullHeightWidthDeductionMm: 18,
|
||||
/** Rainure usinée sur le montant côté poignée (profil type dos, à confirmer au profilé) */
|
||||
fullHeightGroove: {
|
||||
width: GROOVE.back.width,
|
||||
depth: GROOVE.back.depth,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Côté charnières en élévation pour la porte d’index `doorIndex` :
|
||||
* vantail extérieur gauche → charnières gauche ; droit → droite ; portes centrales → réglage utilisateur.
|
||||
*/
|
||||
export function getDoorHingeSide(doorIndex, doorCount, hardware = {}) {
|
||||
const hw = hardware || {};
|
||||
const n = Math.max(1, Number(doorCount) || 1);
|
||||
const i = Math.max(0, Number(doorIndex) || 0);
|
||||
if (n <= 1) return hw.doorHingeSide === 'right' ? 'right' : 'left';
|
||||
if (i === 0) return 'left';
|
||||
if (i === n - 1) return 'right';
|
||||
return hw.centerDoorHingeSide === 'right' ? 'right' : 'left';
|
||||
}
|
||||
|
||||
// ── Block assembly drilling ─────────────────────────────────────────────
|
||||
export const BLOCK_ASSEMBLY = {
|
||||
sideOffsetFromFace: 30, // mm
|
||||
sideOffsetFromBack: 30, // mm
|
||||
topBottomOffset: 100, // mm
|
||||
middleCount: 3, // per joint
|
||||
};
|
||||
|
||||
// ── Carcass assembly ferrures ───────────────────────────────────────────
|
||||
export const CARCASS_FASTENERS = {
|
||||
/** Ferrure d'assemblage utilisée pour les jonctions de caisse (côtés/séparateurs ↔ dessus/fond). */
|
||||
model: 'Häfele Rasant-Tab',
|
||||
/** Référence produit (Ferrures d’assemblage Rasant-Tab, Ø trou 3 ou 5 mm). */
|
||||
productUrl: 'https://www.haefele.ch/fr/produkt/ferrures-d-assemblage-rasant-tab-visser-dans-trou-de-per-age-3-ou-5-mm/26236520/?MasterSKU=P-00861456',
|
||||
piecesPerJoint: 3,
|
||||
/** Retrait des ferrures d’extrémité (mm) */
|
||||
endInsetMm: 50,
|
||||
edgeOffsets: [50, 'middle', 50],
|
||||
/** Perçage ferrure: profondeur sur longueur de la pièce (mm) */
|
||||
ferrureDepthAlongLengthMm: 46,
|
||||
/** Perçage ferrure: profondeur sur l’épaisseur de la pièce (mm) */
|
||||
ferrureDepthInThicknessMm: 12,
|
||||
dowel: {
|
||||
diameter: 8,
|
||||
/** Longueur nominale tourillon (réf. atelier) */
|
||||
depth: 40,
|
||||
/** Perçage borgne en face (dans l’épaisseur du panneau), mm — toujours 12 pour dessus/fond */
|
||||
faceDrillDepthMm: 12,
|
||||
/** 2× tourillons par ferrure : axes à ±sideOffset du centre de chaque ferrure (mm) */
|
||||
sideOffset: 30,
|
||||
},
|
||||
};
|
||||
|
||||
/** Profondeur effective perçage tourillon en face (ne dépasse pas l’épaisseur). */
|
||||
export function tourillonFaceDrillDepthMm(thicknessMm) {
|
||||
const cap = CARCASS_FASTENERS.dowel.faceDrillDepthMm;
|
||||
const t = Math.max(1, Number(thicknessMm) || PANEL.structure);
|
||||
return Math.min(cap, Math.max(6, t - 2));
|
||||
}
|
||||
|
||||
/** Longueur mini. d’arête (mm) pour placer une ferrure au milieu (en plus des 50 mm d’extrémité). */
|
||||
export const CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM = 500;
|
||||
|
||||
/**
|
||||
* Centres des ferrures le long d’une arête de jonction.
|
||||
* Règle: 50 mm des extrémités ; ferrure centrale seulement si l’arête dépasse {@link CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM}.
|
||||
* @param {number} edgeLengthMm — longueur de l’arête (mm), ici hauteur utile de la jonction
|
||||
* @returns {number[]} distances depuis le début de l’arête (mm)
|
||||
*/
|
||||
export function getCarcassJointPositions(edgeLengthMm) {
|
||||
const L = Math.max(0, Number(edgeLengthMm) || 0);
|
||||
if (L <= 0) return [];
|
||||
const inset = Math.min(CARCASS_FASTENERS.endInsetMm, Math.max(20, L * 0.1));
|
||||
const tail = L - inset;
|
||||
const minMid = CARCASS_MIDDLE_FERRURE_MIN_EDGE_MM;
|
||||
|
||||
if (tail <= inset + 20) {
|
||||
if (L > minMid) {
|
||||
const u = L / 4;
|
||||
return [u, L / 2, L - u].map((v) => Math.round(Math.min(L, Math.max(0, v)) * 10) / 10);
|
||||
}
|
||||
const u = L / 4;
|
||||
return [u, L - u].map((v) => Math.round(Math.min(L, Math.max(0, v)) * 10) / 10);
|
||||
}
|
||||
|
||||
if (L > minMid) {
|
||||
return [inset, L / 2, tail].map((v) => Math.round(v * 10) / 10);
|
||||
}
|
||||
return [inset, tail].map((v) => Math.round(v * 10) / 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chaque ferrure: 3 perçages alignés sur la longueur:
|
||||
* - 1 perçage ferrure au centre
|
||||
* - 2 perçages tourillons à ±sideOffset du centre
|
||||
* @returns {{ centerMm: number, holesAlongMm: [number, number, number] }[]}
|
||||
*/
|
||||
export function getCarcassFerrureDowelDrillings(centerPositionsMm, edgeLengthMm, sideOffsetMm = CARCASS_FASTENERS.dowel.sideOffset) {
|
||||
const L = Math.max(0, Number(edgeLengthMm) || 0);
|
||||
const off = Math.max(0, Number(sideOffsetMm) || 0);
|
||||
const centers = centerPositionsMm || [];
|
||||
const clamp = (v) => Math.round(Math.min(L, Math.max(0, v)) * 10) / 10;
|
||||
return centers.map((c0) => {
|
||||
const c = Number(c0);
|
||||
return { centerMm: c, holesAlongMm: [clamp(c - off), clamp(c), clamp(c + off)] };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Shelf machining ─────────────────────────────────────────────────
|
||||
export const SHELF_MACHINING = {
|
||||
// Tourillons Ø8 front + back for shelf pin alignment
|
||||
tourillonDiameter: 8, // mm
|
||||
tourillonCount: 4, // 2 front + 2 back per shelf
|
||||
// "2 rayons sur une même strate de 19mm" — 2 shelves can share same S32 row
|
||||
shelvesPerStratum: 2,
|
||||
};
|
||||
|
||||
// ── Packing ──────────────────────────────────────────────────────────
|
||||
export const PACKING = {
|
||||
maxWeightKg: 28,
|
||||
// Approximate density for weight estimation (kg per m³)
|
||||
densityKgM3: {
|
||||
melamine19: 680, // Panneau mélaminé 19 mm
|
||||
melamine16: 660, // Panneau mélaminé 16 mm (renforts)
|
||||
melamine25: 670, // Panneau mélaminé 25 mm (renforts tiroir)
|
||||
melamine8: 640, // Panneau mélaminé 8 mm (dos)
|
||||
},
|
||||
// Layer-based packing strategy (per APEX 4.0 spec):
|
||||
layers: {
|
||||
level1: 'sides', // Niveau 1: Côtés empilés
|
||||
level2: 'topAndBottom', // Niveau 2: Dessus + fond côte à côte
|
||||
level3: 'shelvesAndRenforts', // Niveau 3: Étagères + renforts groupés par épaisseur
|
||||
},
|
||||
// Hardware kit reservation (AXILO™ pieds + clips de plinthe)
|
||||
hardwareKitReserved: true, // Espace réservé dans le colisage
|
||||
};
|
||||
|
||||
// ── Convenience: convert mm constants to metres (for Three.js) ──────
|
||||
export const PANEL_M = {
|
||||
structure: PANEL.structure / 1000,
|
||||
reinforcement: PANEL.reinforcement / 1000,
|
||||
back: PANEL.back / 1000,
|
||||
reinforcementWidth: PANEL.reinforcementWidth / 1000,
|
||||
};
|
||||
Reference in New Issue
Block a user