// Version: 2.02.00 // --- GLOBAL EDITOR COMPONENT --- const { useState, useEffect, useRef, useMemo } = React; // Import helpers from window scope const { urlToBase64, cleanNum, formatPrice, calcFinal, cleanNumberInput } = window; // Import Icons from window scope const { Layout, Plus, Search, ExternalLink, ChevronUp, Copy, ArrowDown, ChevronDown, Trash2, ImageIcon, Clipboard, Ruler, Disk, UploadIcon, Cloud, Loader, FileText, Download } = window; window.HeaderRow = ({ hasGlobalDiscount, debugClass, gridContainerClass }) => ( ); window.Editor = ({ user, initialProject, useAquaPlaza, onBack }) => { // ... [Logic remains same as v5.147, just Version bump] const getAutoNumber = () => { const today = new Date(); const dd = String(today.getDate()).padStart(2, '0'); const mm = String(today.getMonth() + 1).padStart(2, '0'); return `КП-${dd}/${mm}-`; }; // ... [Rest of the code] // NOTE: Copy the FULL logic from previous version 5.147 here, // I am ensuring the structure is modular. // Since I cannot output 1000 lines every time, I assume you have the content. // If not, I can output the full file again. // For this response, I will output the FULL file to be safe. const defaults = { docInfo: { number: getAutoNumber(), date: new Date().toISOString().split('T')[0], recipient: '' }, items: [{ id: Date.now(), type: 'item', title: '', price: 0, quantity: 1, finalPrice: 0 }] }; const initData = initialProject?.data || defaults; const [docInfo, setDocInfo] = useState(initData.docInfo); const [items, setItems] = useState(initData.items); const [currentProjectId, setCurrentProjectId] = useState(initialProject?.id || null); const [isPdf, setIsPdf] = useState(false); const [saving, setSaving] = useState(false); const [loadingItems, setLoadingItems] = useState({}); const [fileColumns, setFileColumns] = useState([]); const [showMappingModal, setShowMappingModal] = useState(false); const [mapping, setMapping] = useState({ article: '', title: '', price: '', link: '' }); const [globalDiscount, setGlobalDiscount] = useState(initData.globalDiscount || 0); const [showGlobalDiscountInput, setShowGlobalDiscountInput] = useState(initData.globalDiscount > 0); const [showDebugGrid, setShowDebugGrid] = useState(false); const [showPasteModal, setShowPasteModal] = useState(false); const [pasteText, setPasteText] = useState(""); const [previewItems, setPreviewItems] = useState([]); const [statusMessage, setStatusMessage] = useState(null); const debugClass = showDebugGrid ? "border-r border-dashed border-red-400 bg-red-50/30" : ""; const textareaRefs = useRef({}); useEffect(() => { Object.values(textareaRefs.current).forEach(t => t && adjustHeight(t)); }, [items]); const adjustHeight = (el) => { if (!el) return; el.style.height = 'auto'; el.style.height = el.scrollHeight + 'px'; }; const handleDateChange = (e) => { setDocInfo({ ...docInfo, date: e.target.value }); }; const getFormattedDateForPrint = () => { if (!docInfo.date) return ''; const parts = docInfo.date.split('-'); if (parts.length === 3) return `${parts[2]}.${parts[1]}.${parts[0]}`; return docInfo.date; }; const handleGlobalDiscountChange = (val) => { setGlobalDiscount(val); const numVal = parseFloat(val); setItems(prev => prev.map(item => { if (item.type === 'item') { const isDiscounted = numVal > 0; const final = calcFinal(item.price, item.quantity, isDiscounted, 'percent', val); return { ...item, isDiscounted, discountType: 'percent', discountValue: val, finalPrice: final }; } return item; })); }; const toggleGlobalDiscount = () => { if (showGlobalDiscountInput) { setShowGlobalDiscountInput(false); handleGlobalDiscountChange(0); } else { setShowGlobalDiscountInput(true); } }; const fetchProductByArticle = async (id, article) => { if (!article) return; setLoadingItems(prev => ({ ...prev, [id]: true })); try { let data = { found: false }; if (useAquaPlaza) { const res = await fetch(`/api/proxy/search?article=${encodeURIComponent(article)}`); const apiData = await res.json(); if (apiData.found) { data = apiData; if (data.link) { const sep = data.link.includes('?') ? '&' : '?'; data.link = `${data.link}${sep}utm_source=kp-gen`; } } } if (!data.found) { const res = await fetch(`/api/search?article=${encodeURIComponent(article)}`); const localData = await res.json(); if (localData.found) data = localData; } if (data.found) { const imgUrl = data.image ? (data.image.startsWith('http') ? data.image : 'https://' + data.image) : null; const base64Img = imgUrl ? await urlToBase64(imgUrl) : null; setItems(prev => prev.map(item => { if (item.id === id) { const updated = { ...item, title: data.title, price: data.price, link: data.link || item.link, image: base64Img || imgUrl }; updated.finalPrice = calcFinal(parseFloat(updated.price) || 0, parseFloat(updated.quantity) || 0, updated.isDiscounted, updated.discountType, updated.discountValue); return updated; } return item; })); } else { alert('Товар не найден (ни в прайсе, ни на сайте)'); } } catch (error) { alert("Ошибка поиска"); } finally { setLoadingItems(prev => ({ ...prev, [id]: false })); } }; const enrichItemWithImage = async (id, article) => { if (!article) return; try { const res = await fetch(`/api/proxy/search?article=${encodeURIComponent(article)}`); const data = await res.json(); if (data.found) { let link = data.link; if (link) { const sep = link.includes('?') ? '&' : '?'; link = `${link}${sep}utm_source=kp-gen`; } const imgUrl = data.image ? (data.image.startsWith('http') ? data.image : 'https://' + data.image) : null; const base64Img = imgUrl ? await urlToBase64(imgUrl) : null; setItems(prev => prev.map(it => { if (it.id === id) { return { ...it, image: base64Img || imgUrl, link: link || it.link }; } return it; })); } } catch (e) { } }; const smartParse = (rawHTML, rawText) => { const newItems = []; if (rawHTML) { const parser = new DOMParser(); const doc = parser.parseFromString(rawHTML, 'text/html'); const rows = doc.querySelectorAll('tr'); let colMap = { art: -1, title: -1, qty: -1, price: -1, sum: -1, unit: -1 }; let headerFound = false; rows.forEach((row, rIndex) => { const cells = Array.from(row.cells).map(c => c.textContent.trim()); if (cells.length < 3) return; if (!headerFound) { const txt = cells.join(' ').toLowerCase(); if (txt.includes('артикул') || txt.includes('код') || txt.includes('товар') || txt.includes('наименование')) { let priceIndices = [], sumIndices = []; cells.forEach((c, idx) => { const t = c.toLowerCase(); if (t.includes('артикул') || t.includes('код')) colMap.art = idx; else if (t.includes('товар') || t.includes('наименование') || t.includes('работы')) colMap.title = idx; else if (t.includes('кол') && !t.includes('цена')) colMap.qty = idx; else if (t.includes('ед')) colMap.unit = idx; else if (t.includes('цена')) priceIndices.push(idx); else if (t.includes('сумма') && !t.includes('без')) sumIndices.push(idx); }); if (priceIndices.length > 0) colMap.price = priceIndices[0]; if (sumIndices.length > 0) colMap.sum = sumIndices[sumIndices.length - 1]; if (colMap.title !== -1) { headerFound = true; return; } } } if (!headerFound && rIndex > 5) { colMap = { art: 2, title: 3, qty: 4, unit: 5, price: 6, sum: 7 }; headerFound = true; } if (!headerFound) return; let shift = 0; if (colMap.unit === -1 && colMap.qty !== -1) { const possibleUnit = (cells[colMap.qty + 1] || '').toLowerCase(); if (['шт', 'компл', 'упак', 'м2', 'пог.м', 'л'].some(u => possibleUnit.includes(u))) shift = 1; } const getVal = (colType) => { let idx = colMap[colType]; if (idx === -1) return ''; if (shift > 0 && colMap.qty !== -1 && idx > colMap.qty) return cells[idx + shift] || ''; return cells[idx] || ''; }; const title = getVal('title'); if (!title || title.toLowerCase().includes('итого')) return; let qty = cleanNum(getVal('qty')); let basePrice = cleanNum(getVal('price')); let finalSum = cleanNum(getVal('sum')); if (qty === 0) qty = 1; if (basePrice === 0 && finalSum > 0) basePrice = finalSum / qty; if (finalSum === 0 && basePrice > 0) finalSum = basePrice * qty; let isDiscounted = false, discountValue = ''; let finalPrice = finalSum > 0 ? finalSum : (basePrice * qty); const calculatedBaseSum = basePrice * qty; if (calculatedBaseSum > (finalSum + 2)) { isDiscounted = true; const diff = calculatedBaseSum - finalSum; const percent = (diff / calculatedBaseSum) * 100; discountValue = Math.round(percent); } if (basePrice > 0 || finalSum > 0) { newItems.push({ id: Date.now() + Math.random(), type: 'item', article: getVal('art'), title: title, price: basePrice, quantity: qty, unit: (colMap.unit !== -1 ? getVal('unit') : (shift ? cells[colMap.qty + 1] : 'шт')), finalPrice: finalPrice, isDiscounted: isDiscounted, discountType: 'percent', discountValue: isDiscounted ? discountValue : '', image: null, link: '' }); } }); } if (newItems.length === 0 && rawText) { const lines = rawText.trim().split('\n'); lines.forEach(line => { const row = line.split('\t'); if (row.length < 5) return; const cleanNum = (s) => parseFloat(s?.replace(/[\s\u00A0]/g, '').replace(',', '.') || '0'); const title = row[3]?.trim(); if (!title) return; const qty = cleanNum(row[4]); const basePrice = cleanNum(row[6]); const finalSum = cleanNum(row[row.length - 1]); const q = qty || 1; if (basePrice > 0 || finalSum > 0) { const fSum = finalSum > 0 ? finalSum : basePrice * q; const bPrice = basePrice > 0 ? basePrice : fSum / q; let isDisc = (bPrice * q) > (fSum + 1); let discVal = ''; if (isDisc) discVal = Math.round(((bPrice * q - fSum) / (bPrice * q)) * 100); newItems.push({ id: Date.now() + Math.random(), type: 'item', article: row[2]?.trim(), title: title, price: bPrice, quantity: q, unit: row[5]?.trim() || 'шт', finalPrice: fSum, isDiscounted: isDisc, discountType: 'percent', discountValue: discVal, image: null, link: '' }); } }); } return newItems; }; const handlePasteBox = (e) => { const html = e.clipboardData.getData('text/html'); const text = e.clipboardData.getData('text/plain'); const parsed = smartParse(html, text); if (parsed.length > 0) { setPreviewItems(parsed); e.preventDefault(); } }; const confirmPaste = async () => { if (previewItems.length === 0) return; setItems(prev => [...prev.filter(x => x.title || x.price), ...previewItems]); setShowPasteModal(false); const itemsToProcess = [...previewItems]; setPreviewItems([]); setStatusMessage(`Загрузка фото: 0 из ${itemsToProcess.length}...`); for (let i = 0; i < itemsToProcess.length; i++) { const it = itemsToProcess[i]; if (it.article) { setStatusMessage(`Загрузка фото: ${i + 1} из ${itemsToProcess.length}...`); await enrichItemWithImage(it.id, it.article); await new Promise(r => setTimeout(r, 1200)); } } setStatusMessage(null); alert(`Готово! Добавлено ${itemsToProcess.length} товаров.`); }; const addSection = () => setItems([...items, { id: Date.now(), type: 'section', title: 'НОВЫЙ РАЗДЕЛ' }]); const addItem = () => setItems([...items, { id: Date.now(), type: 'item', title: '', price: 0, quantity: 1, finalPrice: 0, article: '', isDiscounted: false, discountType: 'fixed', discountValue: '' }]); const handleLocalSave = () => { const data = JSON.stringify({ docInfo, items, globalDiscount }, null, 2); const blob = new Blob([data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `KP-${docInfo.number}.json`; a.click(); URL.revokeObjectURL(url); }; const handleLocalLoad = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { try { const data = JSON.parse(ev.target.result); if (data.docInfo) setDocInfo(data.docInfo); if (data.items) setItems(data.items); if (data.globalDiscount !== undefined) { setGlobalDiscount(data.globalDiscount); setShowGlobalDiscountInput(data.globalDiscount > 0); } alert("Проект загружен!"); } catch (err) { alert("Ошибка"); } }; reader.readAsText(file); e.target.value = null; }; const handleCloudSave = async () => { if (!user || !user.id) { alert("Войдите в систему"); return; } setSaving(true); const pName = docInfo.recipient ? `${docInfo.number} Для ${docInfo.recipient}` : docInfo.number; let projectIdToSave = currentProjectId; try { const checkRes = await fetch(`/api/projects/${user.id}?t=${Date.now()}`); const checkData = await checkRes.json(); if (Array.isArray(checkData)) { const existingProject = checkData.find(p => p.name === pName); if (existingProject) { if (!projectIdToSave || projectIdToSave !== existingProject.id) { const confirmOverwrite = confirm(`Файл "${pName}" уже существует. Перезаписать его?`); if (confirmOverwrite) { projectIdToSave = existingProject.id; } else { setSaving(false); return; } } } } } catch (e) { } const projectData = { user_id: user.id, id: projectIdToSave || 0, name: pName, data: JSON.stringify({ docInfo, items, globalDiscount }) }; try { const res = await fetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(projectData) }); const data = await res.json(); if (data.status === 'created' || data.status === 'updated') { setCurrentProjectId(data.id); alert("☁️ Сохранено!"); } else if (res.status === 402) { alert("⛔ " + data.message); } else { alert("Ошибка сохранения: " + data.detail); } } catch (e) { alert("Ошибка"); } finally { setSaving(false); } }; const handleUploadPrice = async (e) => { const file = e.target.files[0]; if (!file) return; const formData = new FormData(); formData.append('file', file); e.target.value = null; try { const res = await fetch('/api/upload', { method: 'POST', body: formData }); const data = await res.json(); if (data.status === 'success') { setFileColumns(data.columns); const guess = { article: '', title: '', price: '', link: '' }; data.columns.forEach(col => { const c = col.toLowerCase(); if (c.includes('арт') || c.includes('code')) guess.article = col; else if (c.includes('назв') || c.includes('name')) guess.title = col; else if (c.includes('цен') || c.includes('price')) guess.price = col; else if (c.includes('ссыл') || c.includes('link')) guess.link = col; }); setMapping(guess); setShowMappingModal(true); } else { alert("Ошибка: " + data.detail); } } catch (err) { alert('Ошибка загрузки.'); } }; const saveColumnMapping = async () => { if (!mapping.article || !mapping.price) { alert("Выберите Артикул и Цену."); return; } try { const res = await fetch('/api/mapping', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(mapping) }); const data = await res.json(); if (data.status === 'ok') { setShowMappingModal(false); alert("✅ Настройки сохранены!"); } } catch (err) { alert("Ошибка сохранения"); } }; const handleItemChange = (id, f, v) => setItems(prev => prev.map(i => { if (i.id === id) { const u = { ...i, [f]: v }; if (i.type === 'item') { const p = parseFloat(u.price) || 0; const q = parseFloat(u.quantity) || 0; u.finalPrice = calcFinal(p, q, u.isDiscounted, u.discountType, u.discountValue); } return u; } return i; })); const handleArticleBlur = (id, artValue, currentTitle) => { if (artValue && !currentTitle) { handleItemChange(id, 'title', artValue); } }; const deleteItem = (id) => setItems(items.filter(i => i.id !== id)); const duplicateItem = (id) => { const idx = items.findIndex(i => i.id === id); if (idx === -1) return; const cp = { ...items[idx], id: Date.now() }; const ni = [...items]; ni.splice(idx + 1, 0, cp); setItems(ni); }; const duplicateItemToBottom = (id) => { const idx = items.findIndex(i => i.id === id); if (idx === -1) return; const cp = { ...items[idx], id: Date.now() }; setItems([...items, cp]); }; const moveItem = (idx, dir) => { const ni = [...items]; if (dir === -1 && idx > 0) { [ni[idx], ni[idx - 1]] = [ni[idx - 1], ni[idx]]; } else if (dir === 1 && idx < ni.length - 1) { [ni[idx], ni[idx + 1]] = [ni[idx + 1], ni[idx]]; } setItems(ni); }; const formatPrice = (p) => new Intl.NumberFormat('ru-RU').format(Math.round(p || 0)); const totals = useMemo(() => { const baseTotal = items.reduce((acc, i) => i.type === 'item' ? acc + (i.price * i.quantity) : acc, 0); const finalTotal = items.reduce((acc, i) => i.type === 'item' ? acc + i.finalPrice : acc, 0); const disc = baseTotal - finalTotal; return { t: baseTotal, disc: disc, f: finalTotal }; }, [items]); const hasGlobalDiscount = items.some(i => i.isDiscounted); const handlePdf = async () => { setIsPdf(true); await new Promise(r => setTimeout(r, 100)); const el = document.getElementById('print-area'); const imgs = el.querySelectorAll('.pdf-no-border'); imgs.forEach(i => i.style.border = 'none'); for (const item of items) { if (item.image && item.image.startsWith('http')) { const b64 = await urlToBase64(item.image); if (b64) { const imgEls = el.querySelectorAll(`img[src="${item.image}"]`); imgEls.forEach(img => img.src = b64); } } } window.html2pdf().set({ margin: 0, filename: `${docInfo.number}.pdf`, image: { type: 'jpeg', quality: 0.98 }, html2canvas: { scale: 2, useCORS: true, scrollY: 0, backgroundColor: '#ffffff' }, jsPDF: { format: 'a4', orientation: 'portrait', unit: 'mm' } }).from(el).save().then(() => { setIsPdf(false); imgs.forEach(i => i.style.border = ''); }); }; const handleImageUpload = (id, e) => { const f = e.target.files[0]; if (f) { const r = new FileReader(); r.onload = (ev) => handleItemChange(id, 'image', ev.target.result); r.readAsDataURL(f); } }; const handleImageDrop = (e, id) => { e.preventDefault(); e.stopPropagation(); const file = e.dataTransfer.files[0]; if (file && file.type.startsWith('image/')) { const reader = new FileReader(); reader.onload = (ev) => handleItemChange(id, 'image', ev.target.result); reader.readAsDataURL(file); } }; const handleImagePaste = (e, id) => { const items = e.clipboardData.items; for (let i = 0; i < items.length; i++) { if (items[i].type.indexOf('image') !== -1) { e.preventDefault(); const file = items[i].getAsFile(); const reader = new FileReader(); reader.onload = (ev) => handleItemChange(id, 'image', ev.target.result); reader.readAsDataURL(file); if (e.target.tagName === 'INPUT') e.target.value = ''; break; } } }; const handleDescDrop = (e, id) => { e.preventDefault(); e.stopPropagation(); const text = e.dataTransfer.getData('text'); if (text) handleItemChange(id, 'title', text); }; const handlePriceDrop = (e, id) => { e.preventDefault(); e.stopPropagation(); const text = e.dataTransfer.getData('text'); if (!text) return; let clean = text.replace(/[^\d.,]/g, '').replace(',', '.'); const val = parseFloat(clean); if (!isNaN(val)) { handleItemChange(id, 'price', val); } }; const handleCopyTo1C = () => { const canExport = user && (user.is_vip || user.premium || user.role === 'admin'); if (!canExport) { alert("Экспорт в 1С доступен в PRO версии"); return; } const lines = items.filter(i => i.type === 'item').map(i => { let p = parseFloat(i.price); if (isNaN(p)) p = 0; let q = parseFloat(i.quantity); if (isNaN(q)) q = 0; if (i.isDiscounted && q > 0) { let fp = parseFloat(i.finalPrice); if (isNaN(fp)) fp = 0; p = fp / q; } const fmt = (n) => n.toFixed(2).replace('.', ','); const cleanArt = (i.article || '').replace(/\t/g, ' '); const cleanTitle = (i.title || '').replace(/\t/g, ' '); return `\t\t${cleanArt}\t${cleanTitle}\t${fmt(q)}\t${fmt(p)}`; }); if (lines.length === 0) { alert("Нет товаров для экспорта"); return; } navigator.clipboard.writeText(lines.join('\n')).then(() => alert("📋 Скопировано! Вставьте в 1С (Ctrl+V)")).catch(() => alert("Ошибка доступа к буферу обмена")); }; const isPro = user?.premium || user?.is_vip || user?.role === 'admin'; const isFree = user?.tier === 'free' && !user?.is_vip; const can1C = user?.tier === 'speed' || user?.is_vip || user?.tier === 'vip' || user?.role === 'admin'; const hasWatermark = user?.tier === 'free' && !user?.is_vip; const gridContainerClass = `kp-grid-container ${!hasGlobalDiscount ? 'no-discount' : ''}`; return (
{statusMessage &&
{statusMessage}
} {showMappingModal && (

Настройка колонок Excel

)} {showPasteModal && (

Импорт из 1С

Нажмите на область ниже и нажмите Ctrl+V.

Вставьте таблицу сюда (Ctrl+V)
{previewItems.length > 0 ? ( {previewItems.map((it, idx) => ())}
АртикулНазваниеКол-воБазовая ЦенаСкидкаСумма
{it.article}{it.title}{it.quantity}{formatPrice(it.price)}{it.isDiscounted ? `-${it.discountValue}%` : '-'}{formatPrice(it.finalPrice)}
) : (
Нет данных для просмотра
)}
)} {!isPdf && (
{/* Only show Local Save/Load if NOT Free tier */} {!isFree && ( <> )} {/* 1C Features */} {can1C && ( <> {/* "Загрузка из 1С" file button hidden as requested for everyone */} {/* */} )}
)} {/* --- FLOATING BUTTONS --- */} {!isPdf && (
)}