"use strict"; // ---------- Мастер первичной настройки ---------- // Порядок шагов повторяет зависимости данных: график → должность → сотрудник; место → оборудование → карта; склад → инструмент. const TR = { а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "e", ж: "zh", з: "z", и: "i", й: "y", к: "k", л: "l", м: "m", н: "n", о: "o", п: "p", р: "r", с: "s", т: "t", у: "u", ф: "f", х: "h", ц: "ts", ч: "ch", ш: "sh", щ: "sch", ъ: "", ы: "y", ь: "", э: "e", ю: "yu", я: "ya" }; const translit = s => s.toLowerCase().split("").map(c => TR[c] ?? (/[a-z0-9]/.test(c) ? c : "")).join(""); function makeLogin(fio, taken) { const p = fio.trim().split(/\s+/); let base = translit(p[0] || "user") + (p[1] ? "." + translit(p[1])[0] : "") + (p[2] ? translit(p[2])[0] : ""); base = base.replace(/[^a-z0-9.]/g, "") || "user"; let l = base, n = 2; while (taken.has(l)) l = base + n++; taken.add(l); return l; } function makePassword() { const abc = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const a = new Uint32Array(12); crypto.getRandomValues(a); return [...a].map(x => abc[x % abc.length]).join(""); } const norm = s => (s || "").trim().toLowerCase(); const cells = line => line.split(/[;\t]/).map(x => x.trim()); const SCHEDULE_PRESETS = [ ["Пятидневка 08:00–17:00", { kind: "weekly", weekdays: [0, 1, 2, 3, 4], start_time: "08:00", end_time: "17:00" }], ["Сутки через трое", { kind: "pattern", pattern: [{ type: "work", days: 1, start_time: "08:00", end_time: "08:00", label: "сутки" }, { type: "off", days: 3, label: "выходные" }] }], ["2 дня / 2 ночи (12 ч)", { kind: "pattern", pattern: [{ type: "work", days: 2, start_time: "08:00", end_time: "20:00", label: "день" }, { type: "work", days: 2, start_time: "20:00", end_time: "08:00", label: "ночь" }, { type: "off", days: 4, label: "выходные" }] }], ["Круглосуточно (дежурная смена)", { kind: "weekly", weekdays: [0, 1, 2, 3, 4, 5, 6], start_time: "00:00", end_time: "00:00" }], ]; // Итог мастера: проверка готовности по введённым данным — что настроено, что мешает работе, что сделать дальше. async function setupFinish(d) { if (!d.done.has("finished")) { d.done.add("finished"); await api("PUT", "/settings", { setup_done: [...d.done].join(",") }); } const staff = d.users.filter(u => !u.is_admin && u.is_active); const techPos = d.pos.filter(p => p.kind === "technician"); const checks = []; const add = (ok, text, href, fix) => checks.push({ ok, text, href, fix }); add(staff.length > 0, staff.length ? `Сотрудников: ${staff.length}` : "Нет ни одного сотрудника", "#/setup/users", "Завести сотрудников"); const noPos = staff.filter(u => !u.position_id); add(!noPos.length, noPos.length ? `Без должности (не видят наряды должностей): ${noPos.map(u => u.full_name || u.username).join(", ")}` : "У всех сотрудников есть должность", "#/users", "Назначить должности"); const empty = d.pos.filter(p => !staff.some(u => u.position_id === p.id)); add(!empty.length, empty.length ? `Должности без сотрудников (наряды на них никто не получит): ${empty.map(p => p.name).join(", ")}` : "Каждую должность кто-то занимает", "#/users", "Назначить сотрудников"); const noShift = techPos.filter(p => staff.some(u => u.position_id === p.id) && !staff.some(u => u.position_id === p.id && u.on_shift)); add(!noShift.length, noShift.length ? `Сейчас никого на смене: ${noShift.map(p => p.name).join(", ")} — проверьте графики сотрудников` : "По графикам сейчас на смене есть кто-то на каждой должности техников", "#/users", "Проверить графики"); const noSch = staff.filter(u => u.position_id && !u.schedule_id && techPos.some(p => p.id === u.position_id)); if (noSch.length) add(null, `Без графика (всегда на смене, получают все уведомления должности): ${noSch.map(u => u.full_name || u.username).join(", ")}`, "#/users", "Задать графики"); add(d.eqs.length > 0, d.eqs.length ? `Оборудования: ${d.eqs.length}` : "Оборудование не заведено", "#/setup/equipment", "Завести оборудование"); const noLoc = d.eqs.filter(e => !e.location_id); if (d.eqs.length) add(!noLoc.length, noLoc.length ? `Машин без места: ${noLoc.length} (не находятся в публичной форме и фильтре по местам)` : "У всех машин указано место", "#/equipment", "Указать места"); add(!!d.map.has_image, d.map.has_image ? `Карта завода: на ней ${(d.map.equipment || []).filter(e => e.x != null).length} машин` : "Карта завода не загружена", "#/setup/map", "Загрузить карту"); add(d.cls.length > 0, d.cls.length ? `Чек-листов: ${d.cls.length}` : "Нет чек-листов — нечего проверять в нарядах", "#/checklists/new", "Создать чек-лист"); add(d.tpls.length > 0, d.tpls.length ? `Расписаний нарядов: ${d.tpls.length}` : "Нет расписания — наряды не будут создаваться сами", "#/templates", "Настроить расписание"); add(!!(d.bk.auto_hours), d.bk.auto_hours ? `Автокопии каждые ${d.bk.auto_hours} ч` : "Автоматические резервные копии выключены", "#/backups", "Включить"); if (d.settings.photo_keep_days !== undefined) add(null, +d.settings.photo_keep_days ? `Старые фото удаляются через ${d.settings.photo_keep_days} дн.` : "Автоочистка старых фото выключена — место на диске со временем закончится", "#/server", "Настроить"); const bad = checks.filter(c => c.ok === false).length; const icon = c => c.ok === true ? "✅" : c.ok === false ? "⚠️" : "ℹ️"; fill($app, h("a", { href: "#/setup" }, "← Мастер"), h("h1", {}, bad ? "🧭 Настройка почти завершена" : "🎉 Портал готов к работе"), h("p", { class: "muted" }, bad ? `Мастер пройден. Осталось поправить: ${bad}. Пока это не сделано, часть нарядов и уведомлений может никому не прийти.` : "Мастер пройден, всё основное настроено. Раздайте сотрудникам логины и создайте первый наряд."), h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Проверка готовности"), ...checks.map(c => h("div", { class: "row", style: "padding:6px 0;border-bottom:1px solid var(--line)" }, h("span", {}, icon(c)), h("span", { style: "flex:1" }, c.text), c.ok !== true && c.href ? h("a", { class: "btn sec sm", href: c.href }, c.fix) : null))), h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Что дальше"), h("ol", { class: "small" }, h("li", {}, "Раздайте логины и пароли (таблица на шаге «Сотрудники»), установите приложение: ссылка внизу бокового меню."), h("li", {}, "Создайте первый наряд вручную и пройдите его с телефона — так проще всего показать работу сотрудникам."), h("li", {}, "Проверьте уведомления: «🔔» внизу меню у каждого сотрудника.")), h("div", { class: "row" }, h("a", { class: "btn", href: "#/new-order" }, "Создать наряд"), h("a", { class: "btn sec", href: "#/work" }, "Открыть «Задачи»"), h("button", { class: "sec", onclick: guard(() => setupPage("done")) }, "↻ Проверить ещё раз")))); } async function setupPage(stepKey) { if (!me.is_admin) throw new Error("Мастер доступен администратору"); const G = p => api("GET", p).catch(() => []); const [sch, pos, deps, locs, eqs, users, map, whs, items, cls, tpls, settings, keys, bk] = await Promise.all([ G("/schedules"), G("/positions"), G("/departments"), G("/locations"), G("/equipment"), G("/users"), G("/map"), G("/warehouses"), G("/inventory/items"), G("/checklists"), G("/order-templates"), G("/settings"), G("/api-keys"), G("/backups")]); const done = new Set((settings.setup_done || "").split(",").filter(Boolean)); const markDone = async (k, on = true) => { on ? done.add(k) : done.delete(k); await api("PUT", "/settings", { setup_done: [...done].join(",") }); }; for (const k in cache) delete cache[k]; const reload = k => setupPage(k || stepKey); const byName = (list, name) => list.find(x => norm(x.name) === norm(name)); const placed = (map.equipment || []).filter(e => e.x != null).length; /** Пакетный ввод: одна строка — одна запись; поля через «;» (или табуляцию — можно вставить столбцы из Excel). */ function bulk({ hint, example, run, after }) { const ta = h("textarea", { style: "min-height:140px;font-family:ui-monospace,Consolas,monospace", placeholder: example }); const out = h("div"); return h("div", {}, h("p", { class: "small muted" }, hint), ta, h("div", { class: "row", style: "margin-top:8px" }, h("button", { onclick: guard(async () => { const lines = ta.value.split("\n").map(s => s.trim()).filter(s => s && !s.startsWith("#")); if (!lines.length) throw new Error("Введите хотя бы одну строку"); const ok = [], bad = []; for (const [i, line] of lines.entries()) { try { ok.push(await run(cells(line), line)); } catch (e) { bad.push(`Строка ${i + 1} «${line}»: ${e.message}`); } } for (const k in cache) delete cache[k]; if (after) await after(ok); out.replaceChildren(h("div", { class: "sheet", style: "margin-top:8px" }, `Добавлено: ${ok.length}`, bad.length ? `, с ошибками: ${bad.length}` : "", ...bad.map(b => h("div", { class: "small", style: "color:var(--bad)" }, b)))); if (!bad.length) { ta.value = ""; toast(`Добавлено: ${ok.length}`); if (!after) setTimeout(() => reload(), 700); } }) }, "Добавить"), h("button", { class: "sec sm", onclick: () => { ta.value = example; ta.focus(); } }, "Вставить пример")), out); } const list = (rows, name) => rows.length ? h("div", { class: "small", style: "margin:6px 0 10px" }, h("b", {}, "Уже есть: "), rows.slice(0, 30).map(name).join(", "), rows.length > 30 ? ` и ещё ${rows.length - 30}` : "") : null; function usersResult(res, ok) { if (!ok.length) return; const tbl = h("table", {}, h("thead", {}, h("tr", {}, ...["ФИО", "Логин", "Временный пароль", "Должность", "Отдел"].map(t => h("th", {}, t)))), h("tbody", {}, ...ok.map(r => h("tr", {}, h("td", {}, r.fio), h("td", {}, h("code", {}, r.username)), h("td", {}, h("code", {}, r.password)), h("td", {}, r.position), h("td", {}, r.dep))))); const print = () => { const w = window.open("", "_blank"), d = w.document; d.title = "Учётные записи"; const st = d.createElement("style"); st.textContent = "body{font:14px sans-serif}td,th{border:1px solid #999;padding:6px 10px}table{border-collapse:collapse}"; const h2 = d.createElement("h2"); h2.textContent = "Учётные записи портала"; const p = d.createElement("p"); p.textContent = "Адрес: " + location.origin; d.head.append(st); d.body.append(h2, p, d.importNode(tbl, true)); w.print(); }; const csv = () => { const text = "ФИО;Логин;Пароль;Должность;Отдел\n" + ok.map(r => [r.fio, r.username, r.password, r.position, r.dep].join(";")).join("\n"); const a = h("a", { href: URL.createObjectURL(new Blob(["" + text], { type: "text/csv" })), download: "users.csv" }); document.body.append(a); a.click(); a.remove(); }; res.replaceChildren(h("div", { class: "card", style: "border-color:var(--warn)" }, h("h2", { style: "margin-top:0" }, "Логины и пароли новых сотрудников"), h("p", { class: "small" }, "Пароли показываются только сейчас. Распечатайте или сохраните таблицу и раздайте сотрудникам; сменить пароль можно в «Пользователях»."), tbl, h("div", { class: "row", style: "margin-top:8px" }, h("button", { class: "sec", onclick: print }, "🖨 Печать"), h("button", { class: "sec", onclick: csv }, "⬇ CSV")))); } const STEPS = [ { k: "server", t: "Сервер и организация", n: () => null, why: "Часовой пояс, хранение фото, уведомления. Проверьте один раз.", body: () => h("div", {}, h("ul", { class: "small" }, h("li", {}, `Часовой пояс сервера: ${settings.timezone} (задаётся переменной APP_TZ при развёртывании).`), h("li", {}, `Каталог фото: ${settings.photo_dir}, лимит снимка ${settings.max_photo_mb} МБ.`), h("li", {}, `Время сервера сейчас: ${fmt(settings.now)} — должно совпадать с часами на стене.`)), h("a", { class: "btn sec", href: "#/server" }, "Открыть настройки сервера")) }, { k: "schedules", t: "Графики работы", n: () => sch.length, why: "График задаётся каждому сотруднику: по нему система понимает, кто сейчас на смене, кому показывать наряды должности и слать уведомления.", body: () => h("div", {}, list(sch, x => x.name), h("p", { class: "small muted" }, "Добавьте типовые графики одним нажатием, а сложные циклы соберите в конструкторе."), h("div", { class: "row" }, ...SCHEDULE_PRESETS.map(([name, body]) => h("button", { class: "sec", disabled: !!byName(sch, name), onclick: guard(async () => { await api("POST", "/schedules", { name, ...body }); toast("Добавлен график «" + name + "»"); reload(); }) }, (byName(sch, name) ? "✔ " : "+ ") + name)), h("a", { class: "btn sec", href: "#/schedules" }, "Конструктор графиков"))) }, { k: "positions", t: "Должности", n: () => pos.length, why: "Тип должности: техник выполняет наряды, инженер планирует и проверяет. График — не у должности, а у сотрудника (шаг «Сотрудники»).", body: () => h("div", {}, list(pos, x => x.name), bulk({ hint: "Формат: Название; техник или инженер.", example: "Слесарь-ремонтник; техник\nДежурный механик; техник\nЭлектромонтёр; техник\nИнженер-механик; инженер", run: async ([name, kind]) => { if (!name) throw new Error("нет названия"); const p = await api("POST", "/positions", { name, kind: norm(kind).startsWith("инж") ? "engineer" : "technician" }); pos.push(p); return p; } })) }, { k: "departments", t: "Отделы", n: () => deps.length, why: "Службы и участки. Отделы можно вкладывать друг в друга.", body: () => h("div", {}, list(deps, x => x.name), bulk({ hint: "Формат: Название; входит в отдел (необязательно). Вышестоящий отдел пишите раньше вложенного.", example: "Служба главного инженера\nМеханическая служба; Служба главного инженера\nЭлектрослужба; Служба главного инженера", run: async ([name, parent]) => { const p = parent ? byName(deps, parent) : null; if (parent && !p) throw new Error(`отдел «${parent}» не найден`); const d = await api("POST", "/departments", { name, parent_id: p ? p.id : null }); deps.push(d); return d; } }), h("p", { class: "small muted" }, "Руководителей отделов назначите в разделе «Отделы» после заведения сотрудников.")) }, { k: "locations", t: "Места", n: () => locs.length, why: "Цеха, участки, помещения. По месту публичная форма сужает список машин.", body: () => h("div", {}, list(locs, x => x.name), bulk({ hint: "Формат: Название; входит в (необязательно). Сначала крупные места, затем вложенные.", example: "Цех №1\nКомпрессорная; Цех №1\nКотельная\nНасосная станция", run: async ([name, parent]) => { const p = parent ? byName(locs, parent) : null; if (parent && !p) throw new Error(`место «${parent}» не найдено`); const l = await api("POST", "/locations", { name, parent_id: p ? p.id : null }); locs.push(l); return l; } })) }, { k: "equipment", t: "Оборудование", n: () => eqs.length, why: "Машины, по которым ведутся наряды, ТО, отказы и история.", body: () => h("div", {}, list(eqs, x => x.name), bulk({ hint: "Формат: Название; инвентарный номер; место; текущая наработка, м/ч; отдел; кран (последние три — необязательно). " + "Отдел — за кем закреплена машина: её увидят только сотрудники этого отдела. «кран» — машина уйдёт в раздел «Краны».", example: "Насос Н-1; НС-401; Насосная станция; 12650; Механическая служба\nКомпрессор ВК-30; КМ-301; Компрессорная; 4180\nКран мостовой 10 т; КМ-702; Цех №2; ; Крановое хозяйство; кран", run: async ([name, inv, place, hours, dname, crane]) => { if (!name) throw new Error("нет названия"); const l = place ? byName(locs, place) : null; if (place && !l) throw new Error(`место «${place}» не найдено`); const d = dname ? byName(deps, dname) : null; if (dname && !d) throw new Error(`отдел «${dname}» не найден`); const e = await api("POST", "/equipment", { name, inventory_no: inv || "", location_id: l ? l.id : null, is_active: true, department_id: d ? d.id : null, is_crane: /^(кран|да|1|\+)$/i.test((crane || "").trim()) }); const hv = +(hours || "").replace(",", "."); if (hv > 0) await api("POST", `/equipment/${e.id}/meter`, { hours: hv, note: "начальная наработка" }); return e; } }), h("p", { class: "small muted" }, "Иконку и обслуживаемые узлы (масло, фильтры, ремни) задайте в карточке машины: «Оборудование» → «Карточка»."), h("a", { class: "btn sec", href: "#/equipment" }, "Открыть оборудование")) }, { k: "users", t: "Сотрудники", n: () => users.filter(u => !u.is_admin).length, why: "Учётные записи: должность определяет права, график — когда сотрудник на смене, отдел — принадлежность.", body: () => { const res = h("div"); return h("div", {}, list(users.filter(u => !u.is_admin), x => x.full_name || x.username), bulk({ hint: "Формат: ФИО; логин (пусто — придумается сам); должность; отдел; график; роль (склад / модератор). График и роль — необязательно; без графика сотрудник всегда на смене. Пароли создаются автоматически — распечатайте таблицу и раздайте.", example: "Иванов Иван Петрович; ; Дежурный механик; Механическая служба; Сутки через трое\nСидоров Олег Ильич; ; Дежурный механик; Механическая служба; Сутки через трое\nПетров Сергей Алексеевич; ; Электромонтёр; Электрослужба; Пятидневка 08:00–17:00\nКозлов Дмитрий Олегович; ; Слесарь-ремонтник; Механическая служба; ; склад", run: async ([fio, login, p, d, s, role]) => { if (!fio || fio.length < 3) throw new Error("нет ФИО"); const taken = new Set(users.map(u => u.username)); const position = p ? byName(pos, p) : null, dep = d ? byName(deps, d) : null; if (p && !position) throw new Error(`должность «${p}» не найдена`); if (d && !dep) throw new Error(`отдел «${d}» не найден`); const sc = s ? byName(sch, s) : null; if (s && !sc) throw new Error(`график «${s}» не найден`); const password = makePassword(), username = login || makeLogin(fio, taken); const u = await api("POST", "/users", { username, full_name: fio, password, position_id: position ? position.id : null, schedule_id: sc ? sc.id : null, department_id: dep ? dep.id : null, is_storekeeper: norm(role).startsWith("скл"), is_moderator: norm(role).startsWith("мод"), is_active: true }); users.push(u); return { fio, username, password, position: position ? position.name : "", dep: dep ? dep.name : "" }; }, after: async ok => usersResult(res, ok) }), res, h("p", { class: "small muted" }, "Роли администратора и модератора, телефоны и навыки можно поправить в «Пользователях» и карточках сотрудников.")); } }, { k: "map", t: "Карта завода", n: () => (map.has_image ? placed : 0), why: "План предприятия: состояние машин видно с одного взгляда.", body: () => h("div", {}, h("p", {}, map.has_image ? `Картинка загружена, на карте ${placed} из ${(map.equipment || []).length} машин.` : "Картинка плана ещё не загружена."), h("div", { class: "row" }, filePicker(async f => { toast("Загрузка карты…"); await sendFile("PUT", "/map/image", f, 4000); toast("Карта загружена"); reload(); }, map.has_image ? "🗺 Заменить картинку" : "🗺 Загрузить картинку плана", "sec"), h("a", { class: "btn sec", href: "#/map" }, "Расставить машины на карте")), h("p", { class: "small muted" }, "На карте нажмите «Редактировать карту», выберите машину и кликните, где она стоит. Зоны складов отмечаются на вкладке «Склады»."), h("p", { class: "small muted" }, settings.crane_map === "1" ? ["Краны — отдельная служба: у них своя ", h("a", { href: "#/map/cranes" }, "карта кранов"), " (можно загрузить свою картинку, иначе — план завода)."] : ["Если кранов много и за них отвечает отдельная служба, включите «Краны — отдельная служба» в ", h("a", { href: "#/server" }, "настройках сервера"), " — у кранов будет свой раздел и своя карта."])) }, { k: "stock", t: "Склады и инструмент", n: () => whs.length, why: "Учёт инструмента: что лежит на складах и что выдано лично сотрудникам.", body: () => h("div", {}, list(whs, x => x.name), h("h2", {}, "Склады"), bulk({ hint: "Формат: Название склада; примечание (необязательно).", example: "Инструментальная кладовая; Ручной и электроинструмент\nСклад расходников; Смазки, СИЗ, крепёж", run: async ([name, notes]) => { const w = await api("POST", "/warehouses", { name, notes: notes || "" }); whs.push(w); return w; } }), h("h2", {}, "Каталог и начальные остатки"), bulk({ hint: "Формат: Название; категория; единица; инв. № (для штучного); количество; склад. Количество и склад — чтобы сразу оприходовать остаток.", example: "Дрель аккумуляторная Makita; Электроинструмент; шт; ИН-001; 3; Инструментальная кладовая\nПерчатки х/б; СИЗ; пар; ; 200; Склад расходников", run: async ([name, cat, unit, inv, qty, wname]) => { const it = await api("POST", "/inventory/items", { name, category: cat || "", unit: unit || "шт", inventory_no: inv || "" }); if (qty && +qty > 0) { const w = byName(whs, wname || ""); if (!w) throw new Error(`позиция создана, но склад «${wname || ""}» не найден — остаток не оприходован`); await api("POST", "/inventory/moves", { kind: "receive", item_id: it.id, qty: +qty, to: { warehouse_id: w.id }, note: "Начальные остатки" }); } return it; } }), h("p", { class: "small muted" }, `В каталоге позиций: ${items.length}. Администратора склада назначьте в «Пользователях» (роль «Администратор склада»).`)) }, { k: "work", t: "Чек-листы и расписание", n: () => (cls.length && tpls.length ? cls.length : 0), why: "Что проверять (чек-листы) и как часто создавать наряды (расписание).", body: () => h("div", {}, h("p", {}, `Чек-листов: ${cls.length}, расписаний нарядов: ${tpls.length}.`), h("ul", { class: "small" }, h("li", {}, "Чек-лист — список задач с типом ответа (статус, выбор, число с нормой, текст) и фото."), h("li", {}, "Для ТО по узлам машины создайте чек-лист типа «Регламентное обслуживание»."), h("li", {}, "Расписание создаёт наряды автоматически: ежедневно (суточные осмотры), по дням недели, раз в месяц, каждые N дней.")), h("div", { class: "row" }, h("a", { class: "btn sec", href: "#/checklists/new" }, "+ Чек-лист"), h("a", { class: "btn sec", href: "#/templates" }, "Расписание нарядов"))) }, { k: "external", t: "Внешние обращения", n: () => null, why: "Публичная форма для подрядчиков и смежников, ключи для внешних систем.", body: () => h("div", {}, h("p", {}, "Публичная форма: ", h("a", { href: "/report", target: "_blank" }, location.origin + "/report"), settings.public_incidents === "0" ? " — сейчас выключена." : " — включена."), h("p", { class: "small muted" }, `Ключей доступа к API: ${keys.filter(k => k.is_active).length}. Ссылку на форму удобно разместить на QR-кодах у оборудования.`), h("a", { class: "btn sec", href: "#/server" }, "Настроить форму и ключи")) }, { k: "backup", t: "Резервные копии", n: () => (bk.items || []).length, why: "Копия базы и конфигурации — защита от потери данных.", body: () => h("div", {}, h("p", {}, (bk.auto_hours ? `Автокопии каждые ${bk.auto_hours} ч, хранится ${bk.keep}. ` : "Автокопии выключены. ") + `Копий сейчас: ${(bk.items || []).length}.`), h("div", { class: "row" }, h("button", { onclick: guard(async () => { await api("POST", "/backups", { note: "после первичной настройки" }); toast("Копия создана"); reload(); }) }, "Создать копию сейчас"), h("a", { class: "btn sec", href: "#/backups" }, "Резервные копии"))) }, ]; const state = s => done.has(s.k) || s.n() ? "done" : "todo"; if (stepKey === "done") return setupFinish({ STEPS, state, done, sch, pos, users, eqs, locs, map, cls, tpls, bk, settings }); const cur = STEPS.find(s => s.k === stepKey) || STEPS.find(s => state(s) === "todo") || STEPS[0]; const idx = STEPS.indexOf(cur); const ready = STEPS.filter(s => state(s) === "done").length; fill($app, h("a", { href: "#/admin" }, "← Администрирование"), h("h1", {}, "🧭 Мастер первичной настройки"), h("div", { class: "row", style: "margin-bottom:10px" }, h("div", { class: "bar", style: "flex:1;max-width:420px;height:10px" }, h("i", { style: `width:${ready * 100 / STEPS.length}%` })), h("span", { class: "small muted" }, `готово ${ready} из ${STEPS.length}`)), h("div", { class: "wizard" }, h("div", { class: "wsteps" }, ...STEPS.map((s, i) => h("a", { href: "#/setup/" + s.k, class: (s === cur ? "on " : "") + state(s) }, h("span", { class: "wn" }, state(s) === "done" ? "✓" : String(i + 1)), h("span", {}, s.t, s.n() ? h("small", {}, ` · ${s.n()}`) : null)))), h("div", { class: "card wbody" }, h("div", { class: "small muted" }, `Шаг ${idx + 1} из ${STEPS.length}`), h("h2", { style: "margin-top:2px" }, cur.t), h("p", { class: "muted" }, cur.why), cur.body(), h("div", { class: "row", style: "margin-top:18px;border-top:1px solid var(--line);padding-top:12px" }, idx > 0 ? h("a", { class: "btn sec", href: "#/setup/" + STEPS[idx - 1].k }, "← Назад") : null, h("span", { style: "flex:1" }), h("label", { class: "chk" }, h("input", { type: "checkbox", checked: done.has(cur.k), onchange: guard(async e => { await markDone(cur.k, e.target.checked); location.hash = "#/setup/" + (e.target.checked ? (STEPS[idx + 1] || { k: "done" }).k : cur.k); if (!e.target.checked) reload(cur.k); }) }), "шаг выполнен"), idx < STEPS.length - 1 ? h("a", { class: "btn", href: "#/setup/" + STEPS[idx + 1].k }, "Далее →") : h("a", { class: "btn", href: "#/setup/done" }, "Завершить и проверить"))))); }