import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; // ─── SUPABASE CONFIG ───────────────────────────────────────────── // Replace with your Supabase credentials const SUPABASE_URL = "https://ynpqayssjwtlocncexgg.supabase.co"; const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlucHFheXNzand0bG9jbmNleGdnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzM4NTEyNzMsImV4cCI6MjA4OTQyNzI3M30.IIz-QZ8i2bwrD4Ade-YcgI_hNAbp5XuMzQPLqnrS-cs"; const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); // ─── DATA CONFIG ───────────────────────────────────────────────── const WALL_TABS = [ { id: "main_wall", label: "Главная стена", icon: "🧱" }, { id: "facade_wall", label: "Фасадная стена", icon: "🏗" }, { id: "bl_wall", label: "БЛ стена", icon: "◧" }, { id: "bp_wall", label: "БП стена", icon: "◨" }, ]; const ALL_TABS = [ { id: "arrival", label: "На заезд", icon: "🚛" }, { id: "glazing", label: "Остекление", icon: "🪟" }, ...WALL_TABS, { id: "ceiling", label: "Потолок", icon: "⬆" }, { id: "floor", label: "Полы", icon: "⬇" }, { id: "electric", label: "Электрика", icon: "⚡" }, { id: "furniture", label: "Мебель", icon: "🪑" }, { id: "extras", label: "Доп. параметр", icon: "➕" }, ]; const CATEGORIES = { arrival: ["Список на заезд", "Крепеж", "Плиточные работы", "Доп. параметр"], glazing: ["Тип остекления", "Основная рама", "Наружная отделка", "Балконный блок", "Окно", "Откосы", "Подоконники", "Крыша", "Доп. параметр"], main_wall: ["Вид отделки", "Покраска", "Утепление", "Доп. параметр"], facade_wall: ["Вид отделки", "Покраска", "Утепление", "Доп. параметр"], bl_wall: ["Вид отделки", "Покраска", "Утепление", "Доп. параметр"], bp_wall: ["Вид отделки", "Покраска", "Утепление", "Доп. параметр"], ceiling: ["Вид отделки", "Покраска", "Утепление", "Доп. параметр"], floor: ["Вид отделки", "Утепление", "Доп. параметр"], electric: ["Кабель", "Выключатель", "Розетка", "Спот", "Тёплый пол", "Терморегулятор", "Доп. параметр"], furniture: ["Тип мебели", "Материал мебели", "Покраска", "Полки верх", "Полки низ", "Столешница", "Доп. параметр"], extras: ["Доп. параметр"], }; const UNITS = ["шт.", "п.м.", "м²", "л.", "уп.", "кг"]; // ─── SUPABASE HELPERS ──────────────────────────────────────────── // These functions work with the Supabase tables: // - materials: id, name, color, dimensions, price, quantity, unit, created_at // - material_categories: id, material_id (FK), tab_id, subcategory // - orders: id, order_number, address, phone, data (jsonb), results (jsonb), total_cost, created_at async function fetchMaterials() { const { data, error } = await supabase .from("materials") .select("*, material_categories(*)") .order("name"); if (error) throw error; return data || []; } async function addMaterial(material, categories) { const { data, error } = await supabase .from("materials") .insert({ name: material.name, color: material.color, dimensions: material.dimensions, price: material.price, quantity: material.quantity, unit: material.unit }) .select() .single(); if (error) throw error; if (categories.length > 0) { const cats = categories.map(c => ({ material_id: data.id, tab_id: c.tab_id, subcategory: c.subcategory })); const { error: catError } = await supabase.from("material_categories").insert(cats); if (catError) throw catError; } return data; } async function updateMaterial(id, material, categories) { const { error } = await supabase .from("materials") .update({ name: material.name, color: material.color, dimensions: material.dimensions, price: material.price, quantity: material.quantity, unit: material.unit }) .eq("id", id); if (error) throw error; await supabase.from("material_categories").delete().eq("material_id", id); if (categories.length > 0) { const cats = categories.map(c => ({ material_id: id, tab_id: c.tab_id, subcategory: c.subcategory })); const { error: catError } = await supabase.from("material_categories").insert(cats); if (catError) throw catError; } } async function deleteMaterialById(id) { await supabase.from("material_categories").delete().eq("material_id", id); const { error } = await supabase.from("materials").delete().eq("id", id); if (error) throw error; } async function saveOrder(orderData) { const { data, error } = await supabase.from("orders").insert(orderData).select().single(); if (error) throw error; return data; } async function fetchOrders() { const { data, error } = await supabase.from("orders").select("*").order("created_at", { ascending: false }).limit(50); if (error) throw error; return data || []; } // ─── CALCULATION ENGINE ────────────────────────────────────────── function calculateWallMaterials(wallData, materials) { const results = []; const length = (wallData.length || 0) / 1000; // mm -> m const height = (wallData.height || 0) / 1000; const area = length * height; if (area <= 0) return results; // Отделка if (wallData.finish_material) { const mat = materials.find(m => m.id === wallData.finish_material); if (mat) { const dims = parseDimensions(mat.dimensions); let qty = 0; if (dims.width && dims.height) { const matArea = (dims.width / 1000) * (dims.height / 1000); qty = matArea > 0 ? Math.ceil(area / matArea * 1.1) : 0; // +10% запас } else if (dims.width) { // Вагонка — считаем по ширине доски const boardWidth = dims.width / 1000; if (wallData.direction === "Вертикально") { qty = Math.ceil(length / boardWidth * 1.1); } else { qty = Math.ceil(height / boardWidth * 1.1); } } else { qty = Math.ceil(area * 1.1); } results.push({ material: mat.name, quantity: qty, unit: mat.unit, price: mat.price, cost: qty * (mat.price || 0) }); } } // Утепление if (wallData.insulation_material) { const mat = materials.find(m => m.id === wallData.insulation_material); if (mat) { const dims = parseDimensions(mat.dimensions); let qty = 0; if (dims.width && dims.height) { const matArea = (dims.width / 1000) * (dims.height / 1000); qty = matArea > 0 ? Math.ceil(area / matArea) : 0; } else { qty = Math.ceil(area); } results.push({ material: mat.name, quantity: qty, unit: mat.unit, price: mat.price, cost: qty * (mat.price || 0) }); } } // Покраска if (wallData.painting === "Да" && wallData.paint_material) { const mat = materials.find(m => m.id === wallData.paint_material); if (mat) { const qty = Math.ceil(area * 0.15); // ~0.15 л/м² results.push({ material: mat.name, quantity: Math.max(qty, 1), unit: "л.", price: mat.price, cost: Math.max(qty, 1) * (mat.price || 0) }); } } // Брусок для обрешётки (автоматически если есть отделка) if (wallData.finish_material) { const step = 0.5; // шаг обрешётки 500мм let totalLength = 0; if (wallData.direction === "Вертикально") { const rows = Math.ceil(height / step) + 1; totalLength = rows * length; } else { const cols = Math.ceil(length / step) + 1; totalLength = cols * height; } results.push({ material: "Брусок 20x40 (обрешётка)", quantity: Math.ceil(totalLength * 1.1), unit: "п.м.", price: 0, cost: 0, auto: true }); } // Доп. материалы if (wallData.extras) { wallData.extras.forEach(ext => { if (ext.material_id && ext.quantity > 0) { const mat = materials.find(m => m.id === ext.material_id); if (mat) { results.push({ material: mat.name, quantity: ext.quantity, unit: mat.unit, price: mat.price, cost: ext.quantity * (mat.price || 0) }); } } }); } return results; } function calculateElectricMaterials(electricData, materials) { const results = []; const fields = ["cable", "switch_type", "socket", "spot", "warm_floor", "thermostat"]; fields.forEach(field => { const item = electricData[field]; if (item && item.material_id && item.quantity > 0) { const mat = materials.find(m => m.id === item.material_id); if (mat) { results.push({ material: mat.name, quantity: item.quantity, unit: mat.unit, price: mat.price, cost: item.quantity * (mat.price || 0) }); } } }); if (electricData.extras) { electricData.extras.forEach(ext => { if (ext.material_id && ext.quantity > 0) { const mat = materials.find(m => m.id === ext.material_id); if (mat) { results.push({ material: mat.name, quantity: ext.quantity, unit: mat.unit, price: mat.price, cost: ext.quantity * (mat.price || 0) }); } } }); } return results; } function parseDimensions(dimStr) { if (!dimStr) return {}; const parts = dimStr.replace(/\s/g, "").split(/[xXхХ*×]/); const nums = parts.map(Number).filter(n => !isNaN(n) && n > 0); if (nums.length >= 3) return { width: nums[0], height: nums[1], depth: nums[2] }; if (nums.length === 2) return { width: nums[0], height: nums[1] }; if (nums.length === 1) return { width: nums[0] }; return {}; } // ─── COMPONENTS ────────────────────────────────────────────────── // Notification function Notification({ message, type, onClose }) { useEffect(() => { if (message) { const t = setTimeout(onClose, 3500); return () => clearTimeout(t); } }, [message]); if (!message) return null; return (
{message}
); } // Material Select function MaterialSelect({ materials, tabId, subcategory, value, onChange, placeholder }) { const filtered = useMemo(() => { return materials.filter(m => m.material_categories?.some(c => c.tab_id === tabId && c.subcategory === subcategory) ); }, [materials, tabId, subcategory]); return ( ); } // Extra Materials Row function ExtraRows({ extras, onChange, materials, tabId }) { const addRow = () => onChange([...extras, { material_id: null, quantity: 0 }]); const updateRow = (i, field, val) => { const next = [...extras]; next[i] = { ...next[i], [field]: val }; onChange(next); }; const removeRow = (i) => onChange(extras.filter((_, idx) => idx !== i)); return (
{extras.map((ext, i) => (
updateRow(i, "material_id", v)} placeholder="Доп. материал" /> updateRow(i, "quantity", Number(e.target.value) || 0)} />
))}
); } // Wall Tab (reusable for all 4 walls + ceiling + floor) function SurfaceTab({ tabId, label, data, onChange, materials, showDirection = true, showPainting = true, paintLabel = "Покраска:" }) { const update = (field, val) => onChange({ ...data, [field]: val }); return (
update("length", Number(e.target.value) || 0)} />
update("height", Number(e.target.value) || 0)} />
{data.length > 0 && data.height > 0 && (
{((data.length / 1000) * (data.height / 1000)).toFixed(2)} м²
)}
update("finish_material", v)} /> {showDirection && ( <> )} update("insulation_material", v)} /> {showPainting && ( <> {data.painting === "Да" && ( update("paint_material", v)} placeholder="Выберите краску" /> )} )} update("extras", v)} materials={materials} tabId={tabId} />
); } // Arrival Tab function ArrivalTab({ data, onChange, materials }) { const update = (field, val) => onChange({ ...data, [field]: val }); return (
update("arrival_list", v)} /> update("fasteners", v)} /> update("tile_work", v)} /> update("extras", v)} materials={materials} tabId="arrival" />
); } // Glazing Tab function GlazingTab({ data, onChange, materials }) { const update = (field, val) => onChange({ ...data, [field]: val }); return (
update("glazing_type", v)} /> update("frame_type", v)} /> update("exterior_finish", v)} /> update("balcony_block", v)} />
update("window_type", v)} /> update("window_qty", Number(e.target.value) || 0)} />
update("slopes", v)} /> update("sills", v)} /> update("roof", v)} /> update("extras", v)} materials={materials} tabId="glazing" />
); } // Electric Tab function ElectricTab({ data, onChange, materials }) { const update = (field, val) => onChange({ ...data, [field]: val }); const updateItem = (field, subfield, val) => { const item = data[field] || { material_id: null, quantity: 0 }; update(field, { ...item, [subfield]: val }); }; const fields = [ { key: "cable", label: "Кабель", subcat: "Кабель", qtyLabel: "п.м." }, { key: "switch_type", label: "Выключатель", subcat: "Выключатель", qtyLabel: "шт." }, { key: "socket", label: "Розетка", subcat: "Розетка", qtyLabel: "шт." }, { key: "spot", label: "Спот", subcat: "Спот", qtyLabel: "шт." }, { key: "warm_floor", label: "Тёплый пол", subcat: "Тёплый пол", qtyLabel: "" }, { key: "thermostat", label: "Терморегулятор", subcat: "Терморегулятор", qtyLabel: "шт." }, ]; return (
{fields.map(f => (
updateItem(f.key, "material_id", v)} /> updateItem(f.key, "quantity", Number(e.target.value) || 0)} />
))} update("extras", v)} materials={materials} tabId="electric" />
); } // Furniture Tab function FurnitureTab({ data, onChange, materials }) { const update = (field, val) => onChange({ ...data, [field]: val }); return (
update("furniture_type", v)} /> update("furniture_material", v)} />
update("shelf_top", { ...data.shelf_top, material_id: v })} /> update("shelf_top", { ...data.shelf_top, quantity: Number(e.target.value) || 0 })} />
update("shelf_bottom", { ...data.shelf_bottom, material_id: v })} /> update("shelf_bottom", { ...data.shelf_bottom, quantity: Number(e.target.value) || 0 })} />
update("countertop", v)} /> update("extras", v)} materials={materials} tabId="furniture" />
); } // Extras Tab function ExtrasTab({ data, onChange, materials }) { const update = (field, val) => onChange({ ...data, [field]: val }); return (
update("extras", v)} materials={materials} tabId="extras" />
); } // Material Manager Tab function MaterialManager({ materials, onRefresh, notify }) { const [form, setForm] = useState({ name: "", color: "", dimensions: "", price: "", quantity: "", unit: "шт." }); const [selectedCats, setSelectedCats] = useState([]); const [editingId, setEditingId] = useState(null); const [search, setSearch] = useState(""); const [loading, setLoading] = useState(false); const toggleCat = (tabId, subcat) => { const key = `${tabId}:${subcat}`; setSelectedCats(prev => prev.includes(key) ? prev.filter(c => c !== key) : [...prev, key]); }; const handleSave = async () => { if (!form.name.trim()) return notify("Введите название материала", "error"); if (selectedCats.length === 0) return notify("Выберите хотя бы одну категорию", "error"); setLoading(true); try { const cats = selectedCats.map(c => { const [tab_id, subcategory] = c.split(":"); return { tab_id, subcategory }; }); const matData = { name: form.name.trim(), color: form.color.trim(), dimensions: form.dimensions.trim(), price: parseFloat(form.price) || 0, quantity: parseInt(form.quantity) || 0, unit: form.unit }; if (editingId) { await updateMaterial(editingId, matData, cats); notify("Материал обновлён"); setEditingId(null); } else { await addMaterial(matData, cats); notify("Материал добавлен"); } setForm({ name: "", color: "", dimensions: "", price: "", quantity: "", unit: "шт." }); setSelectedCats([]); onRefresh(); } catch (e) { notify(`Ошибка: ${e.message}`, "error"); } setLoading(false); }; const handleEdit = (mat) => { setEditingId(mat.id); setForm({ name: mat.name, color: mat.color || "", dimensions: mat.dimensions || "", price: mat.price || "", quantity: mat.quantity || "", unit: mat.unit || "шт." }); setSelectedCats((mat.material_categories || []).map(c => `${c.tab_id}:${c.subcategory}`)); }; const handleDelete = async (id, name) => { if (!confirm(`Удалить "${name}"?`)) return; try { await deleteMaterialById(id); notify(`"${name}" удалён`); onRefresh(); } catch (e) { notify(`Ошибка удаления: ${e.message}`, "error"); } }; const filtered = materials.filter(m => m.name.toLowerCase().includes(search.toLowerCase())); return (

{editingId ? "✏️ Редактирование" : "➕ Новый материал"}

setForm({ ...form, name: e.target.value })} placeholder="Вагонка сосна АВ 3м" />
setForm({ ...form, color: e.target.value })} placeholder="Натуральный" />
setForm({ ...form, dimensions: e.target.value })} placeholder="3000x96x12.5" />
setForm({ ...form, price: e.target.value })} placeholder="0" min="0" step="0.01" />
setForm({ ...form, quantity: e.target.value })} placeholder="0" min="0" />
{ALL_TABS.map(tab => (
{tab.icon} {tab.label}
{(CATEGORIES[tab.id] || []).map(sub => { const key = `${tab.id}:${sub}`; return ( ); })}
))}
{editingId && ( )}

📦 Материалы ({materials.length})

setSearch(e.target.value)} />
{filtered.map(m => ( ))} {filtered.length === 0 && ( )}
Название Цвет Размеры Цена Кол-во Ед. Категории
{m.name} {m.color || "—"} {m.dimensions || "—"} {m.price ? `${m.price}₽` : "—"} {m.quantity ?? "—"} {m.unit} {(m.material_categories || []).map(c => ( {c.subcategory} ))}
Материалы не найдены
); } // Results Component function Results({ results, orderInfo }) { if (!results) return null; const totalCost = Object.values(results).flat().reduce((sum, r) => sum + (r.cost || 0), 0); return (

📋 Результаты расчёта

Заказ: {orderInfo.order_number || "—"} Адрес: {orderInfo.address || "—"} Тел.: {orderInfo.phone || "—"}
{Object.entries(results).map(([tabId, items]) => { if (!items || items.length === 0) return null; const tabLabel = ALL_TABS.find(t => t.id === tabId)?.label || tabId; const tabCost = items.reduce((s, i) => s + (i.cost || 0), 0); return (

{tabLabel} {tabCost > 0 && {tabCost.toLocaleString("ru")} ₽}

{items.map((item, i) => (
{item.material} {item.quantity} {item.unit} {item.cost > 0 && {item.cost.toLocaleString("ru")} ₽}
))}
); })}
Итого: {totalCost.toLocaleString("ru")} ₽
); } // ─── MAIN APP ──────────────────────────────────────────────────── export default function App() { const [activeTab, setActiveTab] = useState("arrival"); const [materials, setMaterials] = useState([]); const [loading, setLoading] = useState(true); const [notification, setNotification] = useState({ message: "", type: "" }); const [results, setResults] = useState(null); const [showResults, setShowResults] = useState(false); // Order info const [orderInfo, setOrderInfo] = useState({ order_number: "", address: "", phone: "" }); // Tab data const [tabData, setTabData] = useState({ arrival: {}, glazing: {}, main_wall: { direction: "Вертикально", extras: [] }, facade_wall: { direction: "Вертикально", extras: [] }, bl_wall: { direction: "Вертикально", extras: [] }, bp_wall: { direction: "Вертикально", extras: [] }, ceiling: { direction: "Вертикально", extras: [] }, floor: { extras: [] }, electric: { extras: [] }, furniture: { extras: [] }, extras: { extras: [{ material_id: null, quantity: 0 }] }, }); const notify = useCallback((message, type = "ok") => { setNotification({ message, type }); }, []); const loadMaterials = useCallback(async () => { try { const data = await fetchMaterials(); setMaterials(data); } catch (e) { notify(`Ошибка загрузки: ${e.message}`, "error"); } setLoading(false); }, []); useEffect(() => { loadMaterials(); }, [loadMaterials]); const updateTab = (tabId, data) => { setTabData(prev => ({ ...prev, [tabId]: data })); }; // Calculate const handleCalculate = () => { const res = {}; // Walls ["main_wall", "facade_wall", "bl_wall", "bp_wall", "ceiling", "floor"].forEach(tabId => { res[tabId] = calculateWallMaterials(tabData[tabId], materials); }); // Electric res.electric = calculateElectricMaterials(tabData.electric, materials); // Simple tabs — just extras ["arrival", "glazing", "furniture", "extras"].forEach(tabId => { const items = []; const d = tabData[tabId]; // Collect material selects Object.entries(d).forEach(([key, val]) => { if (key === "extras") return; if (typeof val === "number" && val) { const mat = materials.find(m => m.id === val); if (mat) items.push({ material: mat.name, quantity: 1, unit: mat.unit, price: mat.price, cost: mat.price || 0 }); } if (typeof val === "object" && val?.material_id) { const mat = materials.find(m => m.id === val.material_id); if (mat) { const qty = val.quantity || 1; items.push({ material: mat.name, quantity: qty, unit: mat.unit, price: mat.price, cost: qty * (mat.price || 0) }); } } }); if (d.extras) { d.extras.forEach(ext => { if (ext.material_id && ext.quantity > 0) { const mat = materials.find(m => m.id === ext.material_id); if (mat) items.push({ material: mat.name, quantity: ext.quantity, unit: mat.unit, price: mat.price, cost: ext.quantity * (mat.price || 0) }); } }); } res[tabId] = [...(res[tabId] || []), ...items]; }); setResults(res); setShowResults(true); notify("Расчёт выполнен!"); }; // Save order const handleSaveOrder = async () => { if (!results) return notify("Сначала выполните расчёт", "error"); try { const totalCost = Object.values(results).flat().reduce((s, r) => s + (r.cost || 0), 0); await saveOrder({ order_number: orderInfo.order_number, address: orderInfo.address, phone: orderInfo.phone, data: tabData, results, total_cost: totalCost }); notify("Заказ сохранён в базу!"); } catch (e) { notify(`Ошибка сохранения: ${e.message}`, "error"); } }; const isManagerTab = activeTab === "materials"; return (
setNotification({ message: "", type: "" })} />

К2 Балкон

Расчёт материалов для отделки

{/* Order Info */}
setOrderInfo({ ...orderInfo, order_number: e.target.value })} /> setOrderInfo({ ...orderInfo, address: e.target.value })} /> setOrderInfo({ ...orderInfo, phone: e.target.value })} />
{/* Tabs */} {/* Content */}
{loading ? (

Загрузка материалов...

) : ( <> {!showResults && activeTab === "arrival" && updateTab("arrival", d)} materials={materials} />} {!showResults && activeTab === "glazing" && updateTab("glazing", d)} materials={materials} />} {!showResults && WALL_TABS.some(w => w.id === activeTab) && ( w.id === activeTab)?.label} data={tabData[activeTab]} onChange={d => updateTab(activeTab, d)} materials={materials} /> )} {!showResults && activeTab === "ceiling" && ( updateTab("ceiling", d)} materials={materials} paintLabel="Покраска потолка:" /> )} {!showResults && activeTab === "floor" && ( updateTab("floor", d)} materials={materials} showDirection={false} showPainting={false} /> )} {!showResults && activeTab === "electric" && updateTab("electric", d)} materials={materials} />} {!showResults && activeTab === "furniture" && updateTab("furniture", d)} materials={materials} />} {!showResults && activeTab === "extras" && updateTab("extras", d)} materials={materials} />} {!showResults && activeTab === "materials" && } {showResults && } )}
{/* Bottom Bar */} {!isManagerTab && (
{showResults && ( )} {showResults && ( )}
)}
); }
Made on
Tilda