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="Наименование" />
+
+
+
+ |
+
+
+ |
+
+ 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 @@
+
+
+
+
+
+ Создание сметного расчёта
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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%; }
+}