From 969b88c89a7e2ddbd009cb3c6f497a1085b7b1ef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Oct 2025 23:22:54 +0000 Subject: [PATCH] feat: Implement estimate editor UI and logic Co-authored-by: broker.imperia --- app.js | 413 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 38 +++++ styles.css | 76 ++++++++++ 3 files changed, 527 insertions(+) create mode 100644 app.js create mode 100644 index.html create mode 100644 styles.css diff --git a/app.js b/app.js new file mode 100644 index 0000000..364b66b --- /dev/null +++ b/app.js @@ -0,0 +1,413 @@ +/* global React, ReactDOM */ +const { useState, useEffect, useMemo, useRef } = React; + +// ----------------------------- Utilities ----------------------------- +const CURRENCY = new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', minimumFractionDigits: 2 }); +const numberFormat = (v) => { + const n = Number(v || 0); + return Number.isFinite(n) ? n : 0; +}; + +const nowTimeString = () => new Date().toLocaleTimeString('ru-RU', { hour12: false }); + +const generateId = () => `id_${Date.now().toString(36)}_${Math.random().toString(36).slice(2,8)}`; + +function useDebouncedCallback(callback, delay) { + const timerRef = useRef(null); + return (...args) => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => callback(...args), delay); + }; +} + +// ----------------------------- Defaults ----------------------------- +const DEFAULT_UNITS = [ + 'чел./день','чел./час','рейс','кг.','м3','%','т.','боб','кор.','предм.','соедин.','секц.','час','м2','м.п.','шт.','точка','компл.','этаж','упак.','услуга','мес.','см','ступенька','сотка','проба','литр','пара','лист','рулон','бух.','10 шт.','5 шт.','сутки' +]; + +const EMPTY_POSITION = () => ({ id: generateId(), name: '', unit: 'шт.', quantity: 1, price: 0 }); +const EMPTY_SECTION = () => ({ id: generateId(), name: 'Раздел', expanded: true, positions: [EMPTY_POSITION()] }); +const EMPTY_ROOM = () => ({ id: generateId(), name: 'Помещение', expanded: true, sections: [EMPTY_SECTION()] }); +const EMPTY_STAGE = () => ({ id: generateId(), name: 'Этап', expanded: true, rooms: [EMPTY_ROOM()] }); + +const STORAGE_KEYS = { + state: 'estimate-editor-state', + units: 'estimate-editor-units', + dict: 'estimate-editor-dictionary', +}; + +function loadLocalStorageJSON(key, fallback) { + try { + const raw = localStorage.getItem(key); + if (!raw) return fallback; + const parsed = JSON.parse(raw); + return parsed ?? fallback; + } catch (e) { + return fallback; + } +} + +// ----------------------------- App ----------------------------- +function App() { + const [units, setUnits] = useState(() => loadLocalStorageJSON(STORAGE_KEYS.units, DEFAULT_UNITS)); + const [dictionary, setDictionary] = useState(() => loadLocalStorageJSON(STORAGE_KEYS.dict, ['оснастка пола'])); + const [estimate, setEstimate] = useState(() => { + const saved = loadLocalStorageJSON(STORAGE_KEYS.state, null); + if (saved && saved.data) return saved.data; // legacy shape + return loadLocalStorageJSON(STORAGE_KEYS.state, { stages: [EMPTY_STAGE()] }); + }); + const [savedAt, setSavedAt] = useState(() => loadLocalStorageJSON(STORAGE_KEYS.state, {}).savedAt || null); + + const debouncedSave = useDebouncedCallback((nextEstimate, nextUnits, nextDict) => { + const payload = { data: nextEstimate, savedAt: nowTimeString() }; + localStorage.setItem(STORAGE_KEYS.state, JSON.stringify(payload)); + localStorage.setItem(STORAGE_KEYS.units, JSON.stringify(nextUnits)); + localStorage.setItem(STORAGE_KEYS.dict, JSON.stringify(nextDict)); + setSavedAt(payload.savedAt); + }, 500); + + useEffect(() => { + debouncedSave(estimate, units, dictionary); + }, [estimate, units, dictionary]); + + useEffect(() => { + const cancelBtn = document.getElementById('cancelBtn'); + const createBtn = document.getElementById('createBtn'); + const onCancel = () => { + const saved = loadLocalStorageJSON(STORAGE_KEYS.state, null); + if (saved && saved.data) { + setEstimate(saved.data); + setSavedAt(saved.savedAt || nowTimeString()); + } else { + setEstimate({ stages: [EMPTY_STAGE()] }); + setSavedAt(nowTimeString()); + } + }; + const onCreate = () => { + const blob = new Blob([JSON.stringify(buildExport(estimate), null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `smeta_${new Date().toISOString().slice(0,19).replace(/[:T]/g,'-')}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + cancelBtn.addEventListener('click', onCancel); + createBtn.addEventListener('click', onCreate); + return () => { + cancelBtn.removeEventListener('click', onCancel); + createBtn.removeEventListener('click', onCreate); + }; + }, [estimate]); + + const total = useMemo(() => calculateEstimateTotal(estimate), [estimate]); + + useEffect(() => { + const top = document.getElementById('totalTop'); + if (top) top.textContent = CURRENCY.format(total); + const status = document.getElementById('saveStatus'); + status.textContent = savedAt ? `Изменения сохранены в ${savedAt}` : 'Изменения не сохранены'; + }, [total, savedAt]); + + const ctx = { + units, + setUnits, + dictionary, + setDictionary, + estimate, + setEstimate, + }; + + return ( +
+ {estimate.stages.map((stage) => ( + + ))} +
+ Добавить этап + +
+
+ ); +} + +// ----------------------------- Data ops ----------------------------- +function clone(obj) { return JSON.parse(JSON.stringify(obj)); } + +function addStage(ctx) { + ctx.setEstimate((prev) => ({ ...prev, stages: [...prev.stages, EMPTY_STAGE()] })); +} + +function addRoom(ctx, stageId) { + ctx.setEstimate((prev) => { + const next = clone(prev); + const stage = next.stages.find((s) => s.id === stageId); + stage.rooms.push(EMPTY_ROOM()); + return next; + }); +} + +function addSection(ctx, stageId, roomId) { + ctx.setEstimate((prev) => { + const next = clone(prev); + const room = next.stages.find((s) => s.id === stageId).rooms.find((r) => r.id === roomId); + room.sections.push(EMPTY_SECTION()); + return next; + }); +} + +function addPosition(ctx, stageId, roomId, sectionId) { + ctx.setEstimate((prev) => { + const next = clone(prev); + const section = next.stages + .find((s) => s.id === stageId) + .rooms.find((r) => r.id === roomId) + .sections.find((sec) => sec.id === sectionId); + section.positions.push(EMPTY_POSITION()); + return next; + }); +} + +function removePosition(ctx, stageId, roomId, sectionId, positionId) { + ctx.setEstimate((prev) => { + const next = clone(prev); + const section = next.stages + .find((s) => s.id === stageId) + .rooms.find((r) => r.id === roomId) + .sections.find((sec) => sec.id === sectionId); + section.positions = section.positions.filter((p) => p.id !== positionId); + if (section.positions.length === 0) section.positions.push(EMPTY_POSITION()); + return next; + }); +} + +function updatePosition(ctx, stageId, roomId, sectionId, positionId, patch) { + ctx.setEstimate((prev) => { + const next = clone(prev); + const section = next.stages + .find((s) => s.id === stageId) + .rooms.find((r) => r.id === roomId) + .sections.find((sec) => sec.id === sectionId); + const pos = section.positions.find((p) => p.id === positionId); + Object.assign(pos, patch); + return next; + }); +} + +function updateGroupName(ctx, path, name) { + ctx.setEstimate((prev) => { + const next = clone(prev); + const [stageId, roomId, sectionId] = path; + const stage = next.stages.find((s) => s.id === stageId); + if (stage && !roomId) { stage.name = name; return next; } + const room = stage.rooms.find((r) => r.id === roomId); + if (room && !sectionId) { room.name = name; return next; } + const section = room.sections.find((sec) => sec.id === sectionId); + section.name = name; + return next; + }); +} + +function toggleExpanded(ctx, path) { + ctx.setEstimate((prev) => { + const next = clone(prev); + const [stageId, roomId, sectionId] = path; + const stage = next.stages.find((s) => s.id === stageId); + if (stage && !roomId) { stage.expanded = !stage.expanded; return next; } + const room = stage.rooms.find((r) => r.id === roomId); + if (room && !sectionId) { room.expanded = !room.expanded; return next; } + const section = room.sections.find((sec) => sec.id === sectionId); + section.expanded = !section.expanded; + return next; + }); +} + +function calculateSectionTotal(section) { + return section.positions.reduce((sum, p) => sum + numberFormat(p.quantity) * numberFormat(p.price), 0); +} + +function calculateEstimateTotal(estimate) { + return estimate.stages.flatMap((s) => s.rooms).flatMap((r) => r.sections).reduce((sum, sec) => sum + calculateSectionTotal(sec), 0); +} + +function buildExport(estimate) { + const cloneEstimate = clone(estimate); + const out = { + stages: cloneEstimate.stages.map((s) => ({ + name: s.name, + rooms: s.rooms.map((r) => ({ + name: r.name, + sections: r.sections.map((sec) => ({ + name: sec.name, + total: calculateSectionTotal(sec), + positions: sec.positions.map((p) => ({ name: p.name, unit: p.unit, quantity: numberFormat(p.quantity), price: numberFormat(p.price), cost: numberFormat(p.quantity) * numberFormat(p.price) })) + })) + })) + })), + total: calculateEstimateTotal(cloneEstimate) + }; + return out; +} + +// ----------------------------- Components ----------------------------- +function StageView({ stage, ctx }) { + const stageTotal = useMemo(() => stage.rooms.reduce((sum, r) => sum + r.sections.reduce((acc, s) => acc + calculateSectionTotal(s), 0), 0), [stage]); + return ( +
+
+ + Этап + updateGroupName(ctx, [stage.id], e.target.value)} /> +
Итого: {CURRENCY.format(stageTotal)}
+
+ {stage.expanded && ( +
+ {stage.rooms.map((room) => ( + + ))} +
+ Добавить помещение + +
+
+ )} +
+ ); +} + +function RoomView({ stage, room, ctx }) { + const roomTotal = useMemo(() => room.sections.reduce((acc, s) => acc + calculateSectionTotal(s), 0), [room]); + return ( +
+
+ + Помещение + updateGroupName(ctx, [stage.id, room.id], e.target.value)} /> +
Итого: {CURRENCY.format(roomTotal)}
+
+ {room.expanded && ( +
+ {room.sections.map((section) => ( + + ))} +
+ +
+
+ )} +
+ ); +} + +function SectionView({ stageId, roomId, section, ctx }) { + const sectionTotal = useMemo(() => calculateSectionTotal(section), [section]); + return ( +
+
+ + Раздел + updateGroupName(ctx, [stageId, roomId, section.id], e.target.value)} /> +
Итого: {CURRENCY.format(sectionTotal)}
+
+ + {section.expanded && ( +
+ + + + + + + + + + + + + {section.positions.map((pos) => ( + + ))} + +
НаименованиеЕд. изм.Кол-воЦенаСтоимость
+
+
+
{CURRENCY.format(sectionTotal)}
+
+
+ +
+
+ )} +
+ ); +} + +function PositionRow({ stageId, roomId, sectionId, position, ctx }) { + const [nameDraft, setNameDraft] = useState(position.name); + useEffect(() => setNameDraft(position.name), [position.name]); + + const cost = useMemo(() => numberFormat(position.quantity) * numberFormat(position.price), [position.quantity, position.price]); + + const handleNameChange = (value) => { + setNameDraft(value); + updatePosition(ctx, stageId, roomId, sectionId, position.id, { name: value }); + }; + + const handleAddToDictionary = () => { + const trimmed = (nameDraft || '').trim(); + if (!trimmed) return; + ctx.setDictionary((prev) => prev.includes(trimmed) ? prev : [...prev, trimmed]); + }; + + const addCustomUnit = () => { + const u = prompt('Введите новую единицу измерения'); + if (!u) return; + const unit = u.trim(); + if (!unit) return; + ctx.setUnits((prev) => prev.includes(unit) ? prev : [...prev, unit]); + updatePosition(ctx, stageId, roomId, sectionId, position.id, { unit }); + }; + + return ( + + +
+ handleNameChange(e.target.value)} placeholder="Наименование" /> + + {ctx.dictionary.map((d, i) => ( + +
+ + + + + + updatePosition(ctx, stageId, roomId, sectionId, position.id, { quantity: e.target.value })} /> + + + updatePosition(ctx, stageId, roomId, sectionId, position.id, { price: e.target.value })} /> + + {CURRENCY.format(cost)} + + + + + ); +} + +// ----------------------------- Mount ----------------------------- +ReactDOM.createRoot(document.getElementById('root')).render(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..c09571f --- /dev/null +++ b/index.html @@ -0,0 +1,38 @@ + + + + + + Создание сметного расчёта + + + + + + +
+
+ +

Создание сметного расчёта

+
Итого: 0 ₽
+
+
+ +
+
+
+ +
+
Изменения не сохранены
+ +
+ + + + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..f14e13c --- /dev/null +++ b/styles.css @@ -0,0 +1,76 @@ +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; + background: #f6f7f9; + color: #0f172a; +} +.container { max-width: 1160px; margin: 0 auto; padding: 0 16px; } + +.app-header { background: #111827; color: #fff; padding: 16px 0 20px; position: sticky; top: 0; z-index: 10; } +.app-header h1 { margin: 6px 0 0; font-size: 22px; font-weight: 600; } +.app-header .breadcrumbs { font-size: 12px; color: #94a3b8; } +.app-header .chevron { margin: 0 6px; color: #6b7280; } +.app-header .total-top { position: absolute; right: 24px; top: 18px; font-weight: 600; } + +.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); } +.section-card { margin-top: 14px; } + +.group-header { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid #eef2f7; background: #fafafa; border-radius: 10px 10px 0 0; } +.group-header .label { font-weight: 600; color: #0f172a; } +.group-tools { margin-left: auto; display: flex; gap: 8px; } + +.room-bar { background: #f3f0ff; color: #111827; padding: 12px 14px; border: 1px solid #e5e7eb; border-radius: 8px; display: flex; align-items: center; gap: 10px; } +.room-bar .label { font-weight: 600; } +.room-bar .tools { margin-left: auto; display: flex; gap: 8px; } + +.btn { border: 1px solid #e5e7eb; background: #fff; border-radius: 8px; height: 36px; padding: 0 12px; cursor: pointer; font-weight: 600; } +.btn:hover { background: #f3f4f6; } +.btn-primary { background: #4f46e5; border-color: #4f46e5; color: #fff; } +.btn-primary:hover { background: #4338ca; } +.btn-secondary { background: #fff; } +.btn-danger { background: #fee2e2; border-color: #fecaca; color: #b91c1c; } +.btn-ghost { background: transparent; border-color: transparent; color: #64748b; } + +.table { width: 100%; border-collapse: collapse; } +.table thead th { font-size: 12px; text-transform: uppercase; letter-spacing: .02em; color: #64748b; padding: 8px 10px; text-align: left; border-bottom: 1px solid #e5e7eb; background: #fff; } +.table tbody td { padding: 8px 10px; border-bottom: 1px solid #f1f5f9; } +.table tfoot td { padding: 10px; font-weight: 700; } + +.input, .select { width: 100%; height: 36px; padding: 6px 10px; border: 1px solid #e5e7eb; border-radius: 8px; font-size: 14px; background: #fff; } +.input:focus, .select:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 3px rgba(99,102,241,.15); } +.input.center, .select.center { text-align: center; } + +.kbd { background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 6px; padding: 4px 8px; font-size: 12px; } + +.row-tools { display: flex; gap: 8px; align-items: center; } + +.muted { color: #6b7280; } +.total-row { display: flex; justify-content: flex-end; font-weight: 700; border-top: 1px solid #e5e7eb; padding: 10px 6px 12px; } +.total-row .total { min-width: 140px; text-align: right; } + +.stack { display: flex; flex-direction: column; gap: 12px; } +.hstack { display: flex; gap: 10px; align-items: center; } + +.add-bar { border: 2px dashed #e5e7eb; padding: 14px; border-radius: 10px; display: flex; align-items: center; justify-content: center; color: #64748b; } +.add-bar button { margin-left: 10px; } + +.app-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px; } +.save-status { color: #10b981; } + +/* Layout constraints for columns */ +.col-name { width: 45%; } +.col-unit { width: 14%; } +.col-qty { width: 11%; } +.col-price { width: 13%; } +.col-cost { width: 13%; text-align: right; } +.col-actions { width: 4%; text-align: right; } + +.badge { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px; background: #eef2ff; color: #4338ca; border-radius: 999px; font-size: 12px; font-weight: 600; } + +.toggle { border: none; background: transparent; cursor: pointer; color: #64748b; } + +@media (max-width: 1024px) { + .col-name { width: 40%; } +}