Files
tech-service-portal/app/static/setup.js
T

228 lines
25 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 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Инженер-механик; инженер; Пятидневка 08:00–17:00",
run: async ([name, kind, s]) => {
if (!name) throw new Error("нет названия");
const sc = s ? byName(sch, s) : null;
if (s && !sc) throw new Error(`график «${s}» не найден`);
const p = await api("POST", "/positions", { name, kind: norm(kind).startsWith("инж") ? "engineer" : "technician", schedule_id: sc ? sc.id : null });
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Козлов Дмитрий Олегович; ; Слесарь-ремонтник; Механическая служба; склад",
run: async ([fio, login, p, d, 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 password = makePassword(), username = login || makeLogin(fio, taken);
const u = await api("POST", "/users", { username, full_name: fio, password, position_id: position ? position.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";
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); reload(cur.k); }) }), "шаг выполнен"),
idx < STEPS.length - 1 ? h("a", { class: "btn", href: "#/setup/" + STEPS[idx + 1].k }, "Далее →") : h("a", { class: "btn", href: "#/admin" }, "Завершить")))));
}