Files
tech-service-portal/app/static/setup.js
T
darkorion e67c175f80 feat: документация из Nextcloud, график у сотрудника, автоочистка фото, мониторинг, итог мастера, фильтр по местам; доп. сервисы в docker-compose
- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели)
- автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов
  во внутренней сети Docker; значения из интерфейса важнее
- локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API
- .env.example, docs/DEPLOY.md (раздел 7а), README
2026-10-13 13:27:20 +03:00

269 lines
32 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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" }, "Завершить и проверить")))));
}