Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit c298f5a830
247 changed files with 23200 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
"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" }, "Завершить")))));
}