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,42 +87,69 @@ export default function ColorPickerModal({
|
||||
setSelectedCollection('all');
|
||||
setHoveredFinish(null);
|
||||
setAiRecs([]);
|
||||
// Fetch AI recommendations
|
||||
if (config && finishes.length > 0) {
|
||||
setAiLoading(true);
|
||||
const controller = new AbortController();
|
||||
fetch(`${API_BASE}/api/ai/recommend-materials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
config: {
|
||||
width: config.totalWidth,
|
||||
height: config.height,
|
||||
depth: config.depth,
|
||||
columns: config.modules?.map(m => ({
|
||||
type: m.type || m.moduleCode,
|
||||
width: m.width
|
||||
})),
|
||||
materialName: config.finishLabel || null,
|
||||
doorType: config.doorType
|
||||
},
|
||||
finishes: finishes.map(f => ({ id: f.id, name: f.name || f.label, collection: f.collection, code: f.code }))
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data?.recommendations) {
|
||||
setAiRecs(data.recommendations);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setAiLoading(false));
|
||||
return () => controller.abort();
|
||||
}
|
||||
}
|
||||
}, [isOpen, currentFinish]);
|
||||
|
||||
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
|
||||
})),
|
||||
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,
|
||||
price_chf: f.price_chf != null ? Number(f.price_chf) : undefined
|
||||
}))
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then(data => {
|
||||
if (data?.recommendations) {
|
||||
setAiRecs(data.recommendations);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.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))];
|
||||
return cols.sort();
|
||||
@@ -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 (
|
||||
<button
|
||||
key={`ai-${finish.id}`}
|
||||
type="button"
|
||||
className={`${styles.swatch} ${isSelected ? styles.swatchSelected : ''} ${styles.aiSwatch}`}
|
||||
onClick={() => setSelectedFinish(finish)}
|
||||
onMouseEnter={() => setHoveredFinish({ ...finish, aiReason: rec.reason })}
|
||||
onMouseLeave={() => setHoveredFinish(null)}
|
||||
title={rec.reason}
|
||||
>
|
||||
{finish.image_url ? (
|
||||
<img src={finish.image_url} alt="" className={styles.swatchImg} loading="lazy" />
|
||||
) : (
|
||||
<div className={styles.swatchColor} style={{ backgroundColor: finish.hexColor || '#ccc' }} />
|
||||
)}
|
||||
{isSelected && <span className={styles.swatchCheck}>✓</span>}
|
||||
<span className={styles.aiSwatchBadge}>★</span>
|
||||
</button>
|
||||
<div key={`ai-${finish.id}`} className={styles.aiSwatchCard}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.swatch} ${isSelected ? styles.swatchSelected : ''} ${styles.aiSwatch}`}
|
||||
onClick={() => setSelectedFinish(finish)}
|
||||
onMouseEnter={() => setHoveredFinish({ ...finish, aiDetail: detail })}
|
||||
onMouseLeave={() => setHoveredFinish(null)}
|
||||
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>
|
||||
</button>
|
||||
{summary ? (
|
||||
<p className={styles.aiCardSummary}>{summary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -253,17 +354,55 @@ export default function ColorPickerModal({
|
||||
{/* Hover detail strip -- shows info of hovered swatch */}
|
||||
{hoveredFinish && (
|
||||
<div className={styles.hoverDetail} ref={tooltipRef}>
|
||||
{hoveredFinish.image_url && (
|
||||
<img src={hoveredFinish.image_url} alt="" className={styles.hoverImg} />
|
||||
)}
|
||||
<div className={styles.hoverInfo}>
|
||||
<strong>{hoveredFinish.name || hoveredFinish.label}</strong>
|
||||
{hoveredFinish.code && <span className={styles.hoverCode}>{hoveredFinish.code}</span>}
|
||||
{hoveredFinish.collection && <span className={styles.hoverColl}>{hoveredFinish.collection}</span>}
|
||||
{hoveredFinish.price_chf != null && (
|
||||
<span className={styles.hoverPrice}>{hoveredFinish.price_chf.toFixed(2)} CHF</span>
|
||||
<div className={styles.hoverDetailTop}>
|
||||
{hoveredFinish.image_url && (
|
||||
<img src={hoveredFinish.image_url} alt="" className={styles.hoverImg} />
|
||||
)}
|
||||
<div className={styles.hoverInfo}>
|
||||
<strong>{hoveredFinish.name || hoveredFinish.label}</strong>
|
||||
{hoveredFinish.code && <span className={styles.hoverCode}>{hoveredFinish.code}</span>}
|
||||
{hoveredFinish.collection && <span className={styles.hoverColl}>{hoveredFinish.collection}</span>}
|
||||
{hoveredFinish.price_chf != null && (
|
||||
<span className={styles.hoverPrice}>{hoveredFinish.price_chf.toFixed(2)} CHF</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{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>
|
||||
|
||||
@@ -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}>
|
||||
@@ -50,25 +374,44 @@ export default function CuttingListTable({ cuttingList, onExport }) {
|
||||
{group.pieceCount} pièces • {group.totalSurfaceM2.toFixed(2)} m²
|
||||
</span>
|
||||
</h4>
|
||||
|
||||
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Largeur</th>
|
||||
<th>Hauteur</th>
|
||||
<th 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>
|
||||
|
||||
{/* 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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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,45 +580,27 @@
|
||||
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="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 Formex</label>
|
||||
<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();
|
||||
|
||||
if (plansData.success) {
|
||||
let plansData = {};
|
||||
try {
|
||||
plansData = await plansRes.json();
|
||||
} catch {
|
||||
plansData = {};
|
||||
}
|
||||
|
||||
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,19 +152,34 @@ 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;
|
||||
a.download = 'liste-debitage.txt';
|
||||
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`, {
|
||||
@@ -80,27 +187,123 @@ export default function Plans2DView({ config, modules = [], colors = {}, onClose
|
||||
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');
|
||||
printWindow.document.write(html);
|
||||
printWindow.document.close();
|
||||
printWindow.onload = () => {
|
||||
printWindow.print();
|
||||
};
|
||||
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 && (
|
||||
<div
|
||||
className={styles.planContainer}
|
||||
dangerouslySetInnerHTML={{ __html: 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 && (
|
||||
<div
|
||||
className={styles.planContainer}
|
||||
dangerouslySetInnerHTML={{ __html: 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 && (
|
||||
<div
|
||||
className={styles.planContainer}
|
||||
dangerouslySetInnerHTML={{ __html: 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
|
||||
<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.specs}>
|
||||
<span>{config.widthMm} × {config.heightMm} × {config.depthMm} mm</span>
|
||||
<span>•</span>
|
||||
<span>{config.installationType === 'niche' ? 'Encastrée' : 'Libre'}</span>
|
||||
<span>•</span>
|
||||
<span>{config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</span>
|
||||
<div className={styles.footerInfo}>
|
||||
<div className={styles.specs}>
|
||||
<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 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,
|
||||
};
|
||||
}
|
||||
+720
-34
@@ -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) {
|
||||
setSyncResult({ success: false, error: 'Connexion perdue' });
|
||||
setSyncing(false);
|
||||
}
|
||||
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}%` }}
|
||||
<div
|
||||
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,27 +622,299 @@ 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) => (
|
||||
<tr key={o.id}>
|
||||
<td>{o.id}</td>
|
||||
<td>{o.created_at ? new Date(o.created_at).toLocaleString('fr-CH') : '—'}</td>
|
||||
<td>{o.total_chf != null ? Number(o.total_chf).toLocaleString('fr-CH') : '—'}</td>
|
||||
<td>{o.name || '—'}</td>
|
||||
<td>{o.email || '—'}</td>
|
||||
<td>{o.phone || '—'}</td>
|
||||
<td>{o.notes || '—'}</td>
|
||||
</tr>
|
||||
))
|
||||
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>
|
||||
<td>{o.total_chf != null ? Number(o.total_chf).toLocaleString('fr-CH') : '—'}</td>
|
||||
<td>{o.name || '—'}</td>
|
||||
<td>{o.email || '—'}</td>
|
||||
<td>{o.phone || '—'}</td>
|
||||
<td>{o.notes || '—'}</td>
|
||||
<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);
|
||||
}
|
||||
|
||||
+136
-1071
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.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: {
|
||||
|
||||
Reference in New Issue
Block a user