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
This commit is contained in:
+1
-1
@@ -93,5 +93,5 @@ async function assistantSettings(box) {
|
||||
window.assistantRefresh && window.assistantRefresh();
|
||||
}) }, "Проверить"),
|
||||
h("button", { onclick: guard(async () => { await api("PUT", "/assistant/config", body()); toast("Сохранено"); key.value = ""; window.assistantRefresh && window.assistantRefresh(); assistantSettings(box); }) }, "Сохранить")),
|
||||
out);
|
||||
out, await docsAiSection());
|
||||
}
|
||||
|
||||
+70
-15
@@ -154,6 +154,9 @@ async function route() {
|
||||
else if (page === "admin") await adminPage();
|
||||
else if (page === "server") await serverPage();
|
||||
else if (page === "feedback") await feedbackPage(id);
|
||||
else if (page === "docs") await docsPage(+id);
|
||||
else if (page === "monitor") await monitorPage();
|
||||
else if (page === "monitor") await monitorPage();
|
||||
else if (page === "backups") await backupsPage();
|
||||
else if (page === "setup") await setupPage(id);
|
||||
else if (page === "new-order") await newOrderPage();
|
||||
@@ -350,13 +353,14 @@ const CRUD = {
|
||||
{ k: "notes", label: "Примечание", type: "textarea" }, { k: "is_active", label: "Активно", type: "bool" }],
|
||||
init: { is_active: true },
|
||||
filter: r => !(me.crane_map && r.is_crane), // при отдельной службе кранов краны живут в своём разделе
|
||||
canEdit: () => isEng(), icon: true,
|
||||
canEdit: () => isEng(), icon: true, locFilter: true,
|
||||
actions: r => h("a", { class: "btn sec sm", style: "background:transparent;color:var(--text);border:1px solid var(--line)", href: "#/equipment/" + r.id }, "Карточка") },
|
||||
locations: { title: "Места", path: "locations", cols: [["name", "Название"], ["parent_id", "Входит в", "locations"]],
|
||||
fields: [{ k: "name", label: "Название", type: "text", req: 1 }, { k: "parent_id", label: "Входит в", type: "ref", ref: "locations" }], init: {} },
|
||||
positions: { title: "Должности", path: "positions", cols: [["name", "Название"], ["kind", "Тип", null, v => v === "engineer" ? "Инженер" : "Техник"], ["schedule_id", "График", "schedules"]],
|
||||
fields: [{ k: "name", label: "Название", type: "text", req: 1 }, { k: "kind", label: "Тип", type: "enum", options: [["technician", "Техник (выполняет наряды)"], ["engineer", "Инженер (планирует, проверяет)"]] },
|
||||
{ k: "schedule_id", label: "График работы", hint: "Пусто — всегда на смене", type: "ref", ref: "schedules" }], init: { kind: "technician" } },
|
||||
positions: { title: "Должности", path: "positions", cols: [["name", "Название"], ["kind", "Тип", null, v => v === "engineer" ? "Инженер" : "Техник"]],
|
||||
fields: [{ k: "name", label: "Название", type: "text", req: 1 }, { k: "kind", label: "Тип", type: "enum", options: [["technician", "Техник (выполняет наряды)"], ["engineer", "Инженер (планирует, проверяет)"]] }],
|
||||
note: "Должность выполняет функции по графику того, кто её занимает: график задаётся каждому сотруднику в «Пользователях». Наряд или задание на должность видят и получают уведомление только те, кто сейчас на смене.",
|
||||
init: { kind: "technician" } },
|
||||
schedules: { title: "Графики работы", path: "schedules", cols: [["name", "Название"], ["kind", "Тип", null, (v, r) => v === "cycle" ? `${r.work_days}/${r.off_days}` : r.weekdays.map(i => DAYS[i]).join(" ")], ["start_time", "Смена", null, (v, r) => `${v.slice(0, 5)}–${r.end_time.slice(0, 5)}`]],
|
||||
fields: [{ k: "name", label: "Название", type: "text", req: 1 },
|
||||
{ k: "kind", label: "Тип", type: "enum", options: [["weekly", "По дням недели"], ["cycle", "Цикл (N через M)"]], reload: 1 },
|
||||
@@ -370,10 +374,12 @@ const CRUD = {
|
||||
fields: [{ k: "name", label: "Название отдела", type: "text", req: 1 }, { k: "parent_id", label: "Входит в отдел", hint: "Для вложенных служб и участков; пусто — верхний уровень", type: "ref", ref: "departments" },
|
||||
{ k: "head_user_id", label: "Руководитель", type: "ref", ref: "users" }, { k: "notes", label: "Примечание", type: "textarea" }],
|
||||
init: {}, actions: r => h("a", { class: "btn sec sm", href: "#/people?dep=" + r.id }, "Сотрудники") },
|
||||
users: { title: "Пользователи", path: "users", cols: [["username", "Логин"], ["full_name", "ФИО"], ["position_id", "Должность", "positions"], ["department_id", "Отдел", "departments"], ["is_admin", "Роль", null, (v, r) => [v ? "Админ" : r.is_moderator ? "Модератор" : "", r.is_storekeeper ? "Склад" : ""].filter(Boolean).join(", ") || "—"], ["is_active", "Активен"], ["on_shift", "На смене"]],
|
||||
users: { title: "Пользователи", path: "users", cols: [["username", "Логин"], ["full_name", "ФИО"], ["position_id", "Должность", "positions"], ["schedule_id", "График", "schedules"], ["department_id", "Отдел", "departments"], ["is_admin", "Роль", null, (v, r) => [v ? "Админ" : r.is_moderator ? "Модератор" : "", r.is_storekeeper ? "Склад" : ""].filter(Boolean).join(", ") || "—"], ["is_active", "Активен"], ["on_shift", "На смене"]],
|
||||
fields: [{ k: "username", label: "Логин", type: "text", req: 1 }, { k: "full_name", label: "ФИО", type: "text" },
|
||||
{ k: "password", label: "Пароль", hint: "При правке оставьте пустым, чтобы не менять", type: "password" },
|
||||
{ k: "position_id", label: "Должность", type: "ref", ref: "positions" }, { k: "department_id", label: "Отдел", type: "ref", ref: "departments" }, { k: "is_moderator", label: "Модератор", hint: "Управляет пользователями, должностями, чек-листами; наряды сам не выполняет", type: "bool", show: () => me.is_admin },
|
||||
{ k: "position_id", label: "Должность", type: "ref", ref: "positions" },
|
||||
{ k: "schedule_id", label: "График работы", hint: "По нему сотрудник выполняет свою должность: на смене он видит наряды должности и получает уведомления, сменщики дома — нет. Пусто — всегда на смене", type: "ref", ref: "schedules" },
|
||||
{ k: "department_id", label: "Отдел", type: "ref", ref: "departments" }, { k: "is_moderator", label: "Модератор", hint: "Управляет пользователями, должностями, чек-листами; наряды сам не выполняет", type: "bool", show: () => me.is_admin },
|
||||
{ k: "is_admin", label: "Администратор", hint: "Полный доступ к администрированию", type: "bool", show: () => me.is_admin },
|
||||
{ k: "portal_admin", label: "Администратор портала", hint: "Настройки сервера и ответы обратной связи. Выдаёт только администратор по умолчанию (admin); у самого admin эти права всегда", type: "bool", show: () => me.is_root },
|
||||
{ k: "is_storekeeper", label: "Администратор склада", hint: "Каталог инструмента, склады, выдача сотрудникам, заявки на инструмент", type: "bool" },
|
||||
@@ -403,12 +409,40 @@ CRUD.cranes = { ...CRUD.equipment, title: "Краны", init: { is_active: true,
|
||||
cols: CRUD.equipment.cols.filter(c => c[0] !== "is_crane"),
|
||||
fields: CRUD.equipment.fields.map(f => f.k === "is_crane" ? { ...f, hint: "Снимите, чтобы перенести машину в обычное оборудование" } : f) };
|
||||
|
||||
const crudLoc = {}; // выбранное место в фильтре списка (по разделу)
|
||||
// место и все вложенные в него места
|
||||
function locSubtree(locs, root) {
|
||||
const out = new Set([root]);
|
||||
for (let grew = true; grew;) { grew = false; for (const l of locs) if (l.parent_id && out.has(l.parent_id) && !out.has(l.id)) { out.add(l.id); grew = true; } }
|
||||
return out;
|
||||
}
|
||||
// места деревом: «Цех №1», «— Компрессорная» …
|
||||
const NBSP3 = String.fromCharCode(160).repeat(3);
|
||||
function locOptions(locs) {
|
||||
const kids = p => locs.filter(l => (l.parent_id || null) === p).sort((a, b) => a.name.localeCompare(b.name, "ru"));
|
||||
const out = [], walk = (p, d) => { for (const l of kids(p)) { if (out.some(x => x[0] === l.id)) continue; out.push([l.id, NBSP3.repeat(d) + (d ? "— " : "") + l.name]); walk(l.id, d + 1); } };
|
||||
walk(null, 0);
|
||||
for (const l of locs) if (!out.some(x => x[0] === l.id)) out.push([l.id, l.name]);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function crudPage(page) {
|
||||
const C = CRUD[page], box = h("div"), formBox = h("div"), can = C.canEdit ? C.canEdit() : true;
|
||||
const bar = h("div");
|
||||
$app.replaceChildren(h("div", { class: "row" }, h("h1", { style: "flex:1" }, C.title),
|
||||
can ? h("button", { onclick: () => edit(null) }, "+ Добавить") : null), formBox, box);
|
||||
can ? h("button", { onclick: () => edit(null) }, "+ Добавить") : null), ...(C.note ? [h("p", { class: "small muted" }, C.note)] : []), bar, formBox, box);
|
||||
if (C.locFilter) {
|
||||
const locs = await ref("locations", true);
|
||||
if (locs.length) bar.replaceChildren(h("div", { class: "row", style: "margin-bottom:10px" }, h("label", { style: "margin:0" }, "Место:"),
|
||||
h("select", { style: "max-width:360px", onchange: e => { crudLoc[page] = e.target.value ? +e.target.value : null; load(); } },
|
||||
h("option", { value: "" }, "Все места"), ...locOptions(locs).map(([id, n]) => h("option", { value: id, selected: crudLoc[page] === id }, n)))));
|
||||
}
|
||||
async function load() {
|
||||
const rows = (await api("GET", "/" + C.path)).filter(C.filter || (() => true));
|
||||
let rows = (await api("GET", "/" + C.path)).filter(C.filter || (() => true));
|
||||
if (C.locFilter && crudLoc[page]) {
|
||||
const sub = locSubtree(await ref("locations"), crudLoc[page]);
|
||||
rows = rows.filter(r => sub.has(r.location_id));
|
||||
}
|
||||
const lookups = {};
|
||||
for (const c of C.cols) if (c[2]) lookups[c[2]] = await ref(c[2], true);
|
||||
const cell = (r, [k, , rf, fn]) => {
|
||||
@@ -490,7 +524,9 @@ async function checklistEditor(id) {
|
||||
h("input", { type: t, step: "any", value: it[k] ?? "", onchange: e => it[k] = e.target.value === "" ? (k === "unit" ? "" : null) : (t === "number" ? +e.target.value : e.target.value) })))) : null,
|
||||
h("div", { class: "row", style: "margin-top:8px" },
|
||||
h("label", { style: "display:flex;gap:5px;align-items:center;margin:0;color:var(--text)" }, h("input", { type: "checkbox", checked: it.required, onchange: e => it.required = e.target.checked }), "Обязательный"),
|
||||
h("select", { style: "width:auto", onchange: e => it.photo = e.target.value }, ...[["none", "📷 без фото"], ["optional", "📷 фото по желанию"], ["required", "📷 фото обязательно"]].map(([k, t]) => h("option", { value: k, selected: (it.photo || "none") === k }, t))),
|
||||
h("select", { style: "width:auto", onchange: e => { it.photo = e.target.value; draw(); } }, ...[["none", "📷 без фото"], ["optional", "📷 фото по желанию"], ["required", "📷 фото обязательно"]].map(([k, t]) => h("option", { value: k, selected: (it.photo || "none") === k }, t))),
|
||||
(it.photo || "none") !== "none" ? h("label", { class: "chk", title: "Фото этого пункта кладутся в отдельный каталог и не удаляются автоочисткой сервера" },
|
||||
h("input", { type: "checkbox", checked: !!it.photo_keep, onchange: e => it.photo_keep = e.target.checked }), "хранить более года") : null,
|
||||
h("span", { style: "flex:1" }),
|
||||
h("button", { class: "sec sm", disabled: n === 0, onclick: () => { [items[n - 1], items[n]] = [items[n], items[n - 1]]; draw(); } }, "↑"),
|
||||
h("button", { class: "sec sm", disabled: n === items.length - 1, onclick: () => { [items[n + 1], items[n]] = [items[n], items[n + 1]]; draw(); } }, "↓"),
|
||||
@@ -503,7 +539,7 @@ async function checklistEditor(id) {
|
||||
h("h2", {}, "Задачи"), box,
|
||||
h("div", { class: "row" }, h("button", { class: "sec", onclick: add }, "+ Добавить задачу"), h("button", { onclick: guard(async () => {
|
||||
const body = { name: name.value, description: desc.value, kind: kv.kind, only_due: kv.only_due, items: items.map(i => ({
|
||||
text: i.text, answer_type: i.answer_type, required: i.required, photo: i.photo || "none",
|
||||
text: i.text, answer_type: i.answer_type, required: i.required, photo: i.photo || "none", photo_keep: (i.photo || "none") !== "none" && !!i.photo_keep,
|
||||
options: i.answer_type === "choice" ? i.optText.split("\n").map(s => s.trim()).filter(Boolean) : null,
|
||||
unit: i.answer_type === "number" ? (i.unit || "") : "", min_value: i.answer_type === "number" ? i.min_value ?? null : null,
|
||||
max_value: i.answer_type === "number" ? i.max_value ?? null : null })) };
|
||||
@@ -572,6 +608,7 @@ async function adminPage() {
|
||||
const [users, pos, sch] = await Promise.all([api("GET", "/users"), api("GET", "/positions"), api("GET", "/schedules")]);
|
||||
const tile = (href, title, text) => h("a", { href, class: "tile" }, h("b", {}, title), h("span", { class: "muted small" }, text));
|
||||
const eqCount = me.is_admin && !me.demo_mode ? (await api("GET", "/equipment").catch(() => [])).length : 1;
|
||||
const setupFinished = me.is_admin && ((await api("GET", "/settings").catch(() => ({}))).setup_done || "").split(",").includes("finished");
|
||||
$app.replaceChildren(h("h1", {}, me.is_admin ? "Панель администратора" : "Панель модератора"),
|
||||
...(eqCount === 0 || (me.is_admin && users.length <= 1) ? [h("div", { class: "card banner-setup" }, h("b", { style: "font-size:17px" }, "🧭 Похоже, система ещё не настроена."),
|
||||
h("span", { class: "muted" }, "Мастер по шагам проведёт через графики, должности, отделы, места, оборудование, сотрудников, карту и склады."),
|
||||
@@ -586,17 +623,19 @@ async function adminPage() {
|
||||
h("div", { class: "tiles" },
|
||||
tile("#/checklists", "Чек-листы", "шаблоны проверок"), tile("#/equipment", "Оборудование", "справочник"),
|
||||
tile("#/locations", "Места", "расположение"), tile("#/templates", "Расписание нарядов", "автозапуск")),
|
||||
...(me.is_admin ? [h("h2", {}, "Сервер (только администратор)"), h("div", { class: "tiles" }, tile("#/setup", "🧭 Мастер первичной настройки", "пошагово: графики, люди, оборудование, карта, склады"), ...(me.portal_admin ? [tile("#/server", "Настройки сервера", "каталог фото, лимиты, уведомления, внешние обращения, AI-помощник"), tile("#/feedback", "Обратная связь", "идеи и замечания сотрудников по порталу")] : []), tile("#/backups", "Резервные копии", "база и конфигурация, автокопии, восстановление"), tile("#/demo", "Демонстрационный режим", me.demo_mode ? "ВКЛЮЧЁН — управление" : "тестовые данные для презентации"))]
|
||||
...(me.is_admin ? [h("h2", {}, "Сервер (только администратор)"), h("div", { class: "tiles" }, tile(setupFinished ? "#/setup/done" : "#/setup", "🧭 Мастер первичной настройки", setupFinished ? "пройден — проверка готовности и что поправить" : "пошагово: графики, люди, оборудование, карта, склады"), ...(me.portal_admin ? [tile("#/server", "Настройки сервера", "каталог фото, лимиты, уведомления, внешние обращения, AI-помощник"), tile("#/feedback", "Обратная связь", "идеи и замечания сотрудников по порталу"), tile("#/monitor", "Мониторинг сервера", "процессор, память, место на дисках"), tile("#/monitor", "Мониторинг сервера", "процессор, память, место на дисках")] : []), tile("#/backups", "Резервные копии", "база и конфигурация, автокопии, восстановление"), tile("#/demo", "Демонстрационный режим", me.demo_mode ? "ВКЛЮЧЁН — управление" : "тестовые данные для презентации"))]
|
||||
: [h("p", { class: "small muted" }, "Параметры сервера доступны только администратору. Модератор сам наряды не выполняет.")]));
|
||||
}
|
||||
|
||||
async function serverPage() {
|
||||
if (!me.portal_admin) throw new Error("Настройки сервера доступны только администраторам портала");
|
||||
const keysBox = h("div", { style: "margin-top:14px" }), aiBox = h("div", { class: "card" });
|
||||
const cleanupBox = h("div", {}, "…");
|
||||
const keysBox = h("div", { style: "margin-top:14px" }), aiBox = h("div", { class: "card" }), docsBox = h("div", { class: "card" });
|
||||
const s = await api("GET", "/settings");
|
||||
const f = formFields([
|
||||
{ k: "photo_dir", label: "Каталог для фото", hint: "Абсолютный путь на сервере. В Docker — примонтированный том", type: "text" },
|
||||
{ k: "max_photo_mb", label: "Максимальный размер фото, МБ", type: "number" },
|
||||
{ k: "photo_keep_days", label: "Автоочистка: удалять фото нарядов и заданий старше, дней", hint: "Например, 365 — раз в сутки удаляются фото старше года. 0 — не удалять. Фото пунктов и заданий с отметкой «хранить более года» лежат в отдельном каталоге keep/ и не удаляются", type: "number" },
|
||||
{ k: "vapid_contact", label: "Контакт для push-уведомлений", hint: "mailto:… или https://…", type: "text" },
|
||||
{ k: "fcm_credentials_path", label: "Android push (FCM): ключ Firebase", hint: "Путь к JSON-ключу сервисного аккаунта на сервере. Пусто — выключено", type: "text" },
|
||||
{ k: "public_incidents", label: "Публичная форма «Сообщить о неисправности» (/report)", hint: "Сторонние люди без входа сообщают об аварии: место, машина, описание, контакты", type: "bool" },
|
||||
@@ -606,12 +645,14 @@ async function serverPage() {
|
||||
const mb = n => (n / 1048576).toFixed(1) + " МБ";
|
||||
$app.replaceChildren(h("a", { href: "#/admin" }, "← Администрирование"), h("h1", {}, "Настройки сервера"),
|
||||
h("div", { class: "card" }, f.el, h("div", { style: "margin-top:12px" }, h("button", { onclick: guard(async () => {
|
||||
await api("PUT", "/settings", { photo_dir: f.vals.photo_dir, max_photo_mb: f.vals.max_photo_mb, vapid_contact: f.vals.vapid_contact, fcm_credentials_path: f.vals.fcm_credentials_path ?? "", public_incidents: !!f.vals.public_incidents, crane_map: !!f.vals.crane_map, reliability: !!f.vals.reliability });
|
||||
await api("PUT", "/settings", { photo_dir: f.vals.photo_dir, max_photo_mb: f.vals.max_photo_mb, photo_keep_days: +(f.vals.photo_keep_days || 0), vapid_contact: f.vals.vapid_contact, fcm_credentials_path: f.vals.fcm_credentials_path ?? "", public_incidents: !!f.vals.public_incidents, crane_map: !!f.vals.crane_map, reliability: !!f.vals.reliability });
|
||||
me = await api("GET", "/me"); toast("Сохранено"); serverPage();
|
||||
}) }, "Сохранить")), h("p", { class: "small muted" }, "Смена каталога не переносит уже загруженные фото — скопируйте их вручную в новый каталог.")),
|
||||
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Состояние"),
|
||||
h("div", { class: "grid" },
|
||||
h("div", {}, h("label", {}, "Фото на сервере"), `${s.photo_stats.files} шт., ${mb(s.photo_stats.bytes)}`),
|
||||
h("div", {}, h("label", {}, "Фото на сервере"), `${s.photo_stats.files} шт., ${mb(s.photo_stats.bytes)}`,
|
||||
h("div", { class: "small muted" }, `из них хранить долго (keep/): ${s.photo_keep_stats.files} шт., ${mb(s.photo_keep_stats.bytes)}`)),
|
||||
h("div", {}, h("label", {}, "Автоочистка фото"), cleanupBox),
|
||||
h("div", {}, h("label", {}, "Свободно на диске"), mb(s.photo_stats.disk_free)),
|
||||
h("div", {}, h("label", {}, "Время сервера"), `${fmt(s.now)} (${s.timezone})`),
|
||||
h("div", {}, h("label", {}, "Адрес клиента, каким его видит сервер"), s.client_ip || "—",
|
||||
@@ -620,11 +661,25 @@ async function serverPage() {
|
||||
h("div", {}, h("label", {}, "Браузерные уведомления"), location.protocol === "https:" || location.hostname === "localhost" ? "доступны" : "нужен HTTPS"))),
|
||||
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Сообщения об отказах и неисправностях извне"),
|
||||
h("p", { class: "small" }, "Публичная форма: ", h("a", { href: "/report", target: "_blank" }, location.origin + "/report"),
|
||||
" — ссылку можно разместить на QR-коде у оборудования или на сайте."), keysBox), aiBox);
|
||||
" — ссылку можно разместить на QR-коде у оборудования или на сайте."), keysBox), docsBox, aiBox);
|
||||
apiKeys(keysBox);
|
||||
docsSettings(docsBox);
|
||||
photoCleanupInfo(cleanupBox);
|
||||
assistantSettings(aiBox);
|
||||
}
|
||||
|
||||
async function photoCleanupInfo(box) {
|
||||
const c = await api("GET", "/photo-cleanup");
|
||||
const mb = n => (n / 1048576).toFixed(1) + " МБ";
|
||||
if (!c.enabled) return box.replaceChildren("выключена");
|
||||
box.replaceChildren(`старше ${c.days} дн.`, h("div", { class: "small muted" }, c.photos ? `сейчас к удалению: ${c.photos} фото, ${mb(c.bytes)}` : "сейчас удалять нечего",
|
||||
c.last_run ? ` · последний запуск ${fmt(c.last_run)}` : ""),
|
||||
c.photos ? h("button", { class: "sec sm", style: "margin-top:4px", onclick: guard(async () => {
|
||||
if (!confirm(`Удалить ${c.photos} фото старше ${c.days} дней? Это необратимо.`)) return;
|
||||
const r = await api("POST", "/photo-cleanup"); toast(`Удалено фото: ${r.photos}`); serverPage();
|
||||
}) }, "Очистить сейчас") : null);
|
||||
}
|
||||
|
||||
async function apiKeys(box) {
|
||||
const keys = await api("GET", "/api-keys");
|
||||
const name = h("input", { placeholder: "Название системы, например «Диспетчерская» или «SCADA цех 1»" });
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"use strict";
|
||||
// ---------- Документация машин из Nextcloud ----------
|
||||
// Nextcloud сотрудникам не виден: папки и файлы отдаёт портал. PDF, картинки и текст открываются прямо здесь,
|
||||
// документы Office — после перевода в PDF на сервере ONLYOFFICE. Привязывают папки администратор и модератор.
|
||||
const DOC_ICON = { dir: "📁", pdf: "📕", image: "🖼", text: "📄", office: "📝", file: "📎" };
|
||||
const docSize = n => n >= 1048576 ? (n / 1048576).toFixed(1) + " МБ" : Math.max(1, Math.round(n / 1024)) + " КБ";
|
||||
const docUrl = (id, path, view) => `${API}/equipment/${id}/docs/file?` + new URLSearchParams({ path, ...(view ? { view: 1 } : {}) });
|
||||
const hashQuery = () => new URLSearchParams((location.hash.split("?")[1]) || "");
|
||||
|
||||
// блок в карточке машины
|
||||
function docsBlock(eq, reload) {
|
||||
if (!me.docs || (!eq.docs_path && !isMgr())) return null;
|
||||
return h("div", { class: "card" },
|
||||
h("div", { class: "row" }, h("h2", { style: "flex:1;margin:0" }, "📁 Документация"),
|
||||
eq.docs_path ? h("a", { class: "btn sm", href: "#/docs/" + eq.id }, "Открыть") : null,
|
||||
isMgr() ? h("button", { class: "sec sm", onclick: () => docsPicker(eq, reload) }, eq.docs_path ? "Сменить папку" : "Привязать папку") : null),
|
||||
h("div", { class: "small muted", style: "margin-top:4px" }, eq.docs_path ? "Папка: " + eq.docs_path
|
||||
: "Папка с документацией ещё не привязана. Выберите её в проводнике — сотрудники будут открывать документы прямо в портале."));
|
||||
}
|
||||
|
||||
// проводник для привязки папки
|
||||
async function docsPicker(eq, done) {
|
||||
if (document.querySelector(".dcmodal")) return;
|
||||
let path = eq.docs_path ? eq.docs_path.split("/").slice(0, -1).join("/") : "";
|
||||
const body = h("div"), close = () => wrap.remove();
|
||||
const wrap = h("div", { class: "dcmodal", onclick: e => { if (e.target === wrap) close(); } }, h("div", { class: "card" }, body));
|
||||
const save = guard(async p => {
|
||||
await api("PUT", `/equipment/${eq.id}/docs`, { path: p });
|
||||
delete cache.equipment; close(); toast(p ? "Папка привязана" : "Папка отвязана"); done();
|
||||
});
|
||||
async function draw() {
|
||||
body.replaceChildren(h("p", { class: "muted" }, "Загрузка…"));
|
||||
let d;
|
||||
try { d = await api("GET", "/docs/browse?" + new URLSearchParams({ path })); }
|
||||
catch (e) { body.replaceChildren(h("h2", { style: "margin-top:0" }, "Документация"), h("p", { class: "error" }, e.message), h("button", { class: "sec", onclick: close }, "Закрыть")); return; }
|
||||
const parts = d.path ? d.path.split("/") : [];
|
||||
const crumbs = h("div", { class: "crumbs" }, h("a", { href: "javascript:void 0", onclick: () => { path = ""; draw(); } }, "Документация"),
|
||||
...parts.flatMap((p, i) => [" / ", h("a", { href: "javascript:void 0", onclick: () => { path = parts.slice(0, i + 1).join("/"); draw(); } }, p)]));
|
||||
body.replaceChildren(h("h2", { style: "margin-top:0" }, "Папка документации: " + eq.name), crumbs,
|
||||
h("div", { class: "dclist" }, ...(d.items.length ? d.items.map(x => h("div", { class: "dcrow" },
|
||||
h("a", { href: "javascript:void 0", style: "flex:1", onclick: () => { path = x.path; draw(); } }, "📁 " + x.name),
|
||||
x.linked.length ? h("span", { class: "small muted" }, "привязана: " + x.linked.join(", ")) : null,
|
||||
h("button", { class: "sm", onclick: () => save(x.path) }, "Выбрать"))) : [h("p", { class: "muted small" }, "Вложенных папок нет")])),
|
||||
h("div", { class: "small muted", style: "margin-top:6px" }, `В этой папке файлов: ${d.files}`),
|
||||
h("div", { class: "row", style: "margin-top:12px" },
|
||||
d.path ? h("button", { onclick: () => save(d.path) }, "Привязать эту папку") : null,
|
||||
eq.docs_path ? h("button", { class: "sec", onclick: () => confirm("Отвязать папку от машины?") && save("") }, "Отвязать") : null,
|
||||
h("span", { style: "flex:1" }), h("button", { class: "sec", onclick: close }, "Отмена")));
|
||||
}
|
||||
document.body.append(wrap);
|
||||
draw();
|
||||
}
|
||||
|
||||
// страница документации машины: #/docs/ID?d=папка или ?p=файл
|
||||
async function docsPage(id) {
|
||||
const q = hashQuery(), file = q.get("p"), sub = q.get("d") || (file ? file.split("/").slice(0, -1).join("/") : "");
|
||||
const eq = await api("GET", "/equipment/" + id);
|
||||
const d = await api("GET", `/equipment/${id}/docs?` + new URLSearchParams({ sub }));
|
||||
const viewer = h("div");
|
||||
const go = (k, v) => `#/docs/${id}` + (v ? "?" + new URLSearchParams({ [k]: v }) : "");
|
||||
const parts = sub ? sub.split("/") : [];
|
||||
const row = x => h("a", { class: "dcrow" + (x.path === file ? " on" : ""), href: go(x.is_dir ? "d" : "p", x.path) },
|
||||
h("span", { style: "flex:1" }, (DOC_ICON[x.kind] || "📎") + " " + x.name),
|
||||
x.is_dir ? null : h("span", { class: "small muted" }, docSize(x.size) + (x.mtime ? " · " + fmtDate(x.mtime) : "")));
|
||||
fill($app, h("a", { href: "#/equipment/" + id }, "← " + eq.name), h("h1", {}, "Документация"),
|
||||
h("div", { class: "crumbs" }, h("a", { href: go() }, d.folder), ...parts.flatMap((p, i) => [" / ", h("a", { href: go("d", parts.slice(0, i + 1).join("/")) }, p)])),
|
||||
h("div", { class: "dcwrap" }, h("div", { class: "card dclist" }, ...(d.items.length ? d.items.map(row) : [h("p", { class: "muted" }, "Папка пуста")])), viewer));
|
||||
if (file) await docsView(id, d.items.find(x => x.path === file) || { path: file, name: file.split("/").pop(), kind: "file" }, viewer);
|
||||
}
|
||||
|
||||
async function docsView(id, x, box) {
|
||||
const dl = h("button", { class: "sec sm", onclick: guard(() => docsDownload(id, x)) }, "⬇ Скачать");
|
||||
const head = (...extra) => h("div", { class: "row", style: "margin-bottom:8px" }, h("b", { style: "flex:1" }, x.name), ...extra, dl);
|
||||
if (x.kind === "file") return box.replaceChildren(h("div", { class: "card" }, head(), h("p", { class: "muted" }, "Этот формат в портале не открывается — скачайте файл.")));
|
||||
box.replaceChildren(h("div", { class: "card" }, head(), h("p", { class: "muted" }, x.kind === "office" ? "Готовлю документ к просмотру…" : "Загрузка…")));
|
||||
const r = await authFetch(docUrl(id, x.path, true));
|
||||
if (!r.ok) { let m = r.statusText; try { m = (await r.json()).detail; } catch (_) {} return box.replaceChildren(h("div", { class: "card" }, head(), h("p", { class: "error" }, m))); }
|
||||
const blob = await r.blob(), url = URL.createObjectURL(blob);
|
||||
const open = h("button", { class: "sec sm", onclick: () => window.open(url, "_blank", "noopener") }, "↗ В новой вкладке");
|
||||
const view = x.kind === "image" ? h("img", { src: url, alt: x.name, class: "dcimg" })
|
||||
: x.kind === "text" ? h("pre", { class: "dctext" }, await blob.text())
|
||||
: h("iframe", { src: url, class: "dcframe", title: x.name });
|
||||
box.replaceChildren(h("div", { class: "card" }, head(open), view));
|
||||
}
|
||||
|
||||
async function docsDownload(id, x) {
|
||||
const r = await authFetch(docUrl(id, x.path, false));
|
||||
if (!r.ok) { let m = r.statusText; try { m = (await r.json()).detail; } catch (_) {} throw new Error(m); }
|
||||
const a = document.createElement("a"); a.href = URL.createObjectURL(await r.blob()); a.download = x.name;
|
||||
document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||||
}
|
||||
|
||||
// настройки сервера: подключение к Nextcloud и ONLYOFFICE
|
||||
async function docsSettings(box) {
|
||||
const c = await api("GET", "/docs/config");
|
||||
const inp = (k, ph, type) => h("input", { value: type === "password" ? "" : c[k] || "", placeholder: ph, ...(type ? { type, autocomplete: "new-password" } : {}) });
|
||||
const f = {
|
||||
nc_url: inp("nc_url", "http://192.168.1.30 или https://cloud.example.ru"), nc_user: inp("nc_user", "служебная учётная запись, например portal-docs"),
|
||||
nc_password: inp("nc_password", c.nc_password_set ? "пароль задан — оставьте пустым, чтобы не менять" : "пароль приложения", "password"),
|
||||
nc_root: inp("nc_root", "папка с документацией, например Документация/Оборудование"),
|
||||
onlyoffice_url: inp("onlyoffice_url", "http://192.168.1.31 — Document Server в локальной сети"),
|
||||
onlyoffice_jwt: inp("onlyoffice_jwt", c.onlyoffice_jwt_set ? "секрет задан — оставьте пустым, чтобы не менять" : "секрет JWT Document Server (если включён)", "password"),
|
||||
portal_internal_url: inp("portal_internal_url", "http://192.168.1.20:8000 — адрес портала, видимый для ONLYOFFICE"),
|
||||
};
|
||||
const out = h("div");
|
||||
const body = () => { const b = {}; for (const [k, el] of Object.entries(f)) if (!(el.type === "password" && !el.value)) b[k] = el.value.trim(); return b; };
|
||||
box.replaceChildren(h("h2", { style: "margin-top:0" }, "📁 Документация из Nextcloud"),
|
||||
h("p", { class: "small muted" }, "Портал читает папки Nextcloud от служебной учётной записи и сам отдаёт документы сотрудникам — Nextcloud им не нужен и не виден. ",
|
||||
"Заведите в Nextcloud отдельного пользователя, дайте ему доступ ", h("b", {}, "только на чтение"), " к папке с документацией и создайте для него пароль приложения ",
|
||||
"(Настройки → Безопасность → «Создать пароль приложения»). Адрес лучше указать внутренний, в локальной сети."),
|
||||
h("div", { class: "grid" }, field("Адрес Nextcloud", f.nc_url), field("Учётная запись", f.nc_user), field("Пароль приложения", f.nc_password), field("Корневая папка", f.nc_root)),
|
||||
h("h3", {}, "Просмотр документов Office (ONLYOFFICE)"),
|
||||
h("p", { class: "small muted" }, "Word, Excel и PowerPoint сервер ONLYOFFICE переводит в PDF, и документ открывается прямо в портале. Без ONLYOFFICE такие файлы можно только скачать. ",
|
||||
"Внутренний адрес портала нужен, чтобы ONLYOFFICE забрал исходник по одноразовой ссылке."),
|
||||
h("div", { class: "grid" }, field("Адрес ONLYOFFICE", f.onlyoffice_url), field("Секрет JWT", f.onlyoffice_jwt), field("Внутренний адрес портала", f.portal_internal_url)),
|
||||
h("div", { class: "row", style: "margin-top:10px" }, h("span", { style: "flex:1" }),
|
||||
h("button", { class: "sec", onclick: guard(async () => {
|
||||
await api("PUT", "/docs/config", body()); toast("Проверяю…");
|
||||
const r = await api("POST", "/docs/test");
|
||||
out.replaceChildren(h("div", { class: "sheet", style: "margin-top:8px" }, h("div", {}, h("b", {}, "Nextcloud: "), r.nextcloud),
|
||||
h("div", {}, h("b", {}, "ONLYOFFICE: "), r.onlyoffice || "не указан — документы Office только скачиваются")));
|
||||
}) }, "Проверить"),
|
||||
h("button", { onclick: guard(async () => { await api("PUT", "/docs/config", body()); me = await api("GET", "/me"); toast("Сохранено"); docsSettings(box); }) }, "Сохранить")),
|
||||
out,
|
||||
h("p", { class: "small muted" }, `Привязано машин: ${c.index.linked}. Папку к машине привязывают администратор или модератор в карточке машины — блок «Документация».`));
|
||||
}
|
||||
|
||||
// опция AI-помощника «Работа с документацией» (в карточке помощника)
|
||||
async function docsAiSection() {
|
||||
const c = await api("GET", "/docs/config");
|
||||
const on = h("input", { type: "checkbox", checked: c.docs_ai });
|
||||
const ix = c.index, box = h("div", { class: "docsai" });
|
||||
const status = ix.running ? `строится: прочитано ${ix.done} файлов…` : c.docs_index_at ? `в индексе ${ix.files} файлов, обновлён ${fmt(c.docs_index_at)}` : "ещё не строился";
|
||||
box.replaceChildren(h("h3", {}, "📚 Работа с документацией"),
|
||||
h("label", { class: "chk" }, on, "Помощник ищет ответы в документации машин и подсказывает, какой файл открыть"),
|
||||
h("div", { class: "sheet risk" }, h("b", {}, "Риски — прочитайте перед включением"),
|
||||
h("ul", {},
|
||||
h("li", {}, h("b", {}, "Передача данных провайдеру ИИ. "), "В запрос к модели уходят имена файлов и фрагменты текста документов (паспорта, схемы, регламенты). ",
|
||||
"С облачным провайдером это значит, что содержимое документации покидает предприятие. Для документов с ограниченным доступом используйте локальную модель (Ollama, LM Studio в своей сети)."),
|
||||
h("li", {}, h("b", {}, "Большой расход токенов. "), "Каждый вопрос о документации добавляет к запросу результаты поиска — запросы становятся в несколько раз длиннее и дороже. ",
|
||||
"У платного провайдера следите за расходом; у локальной модели ответы будут медленнее."),
|
||||
h("li", {}, h("b", {}, "Неточность. "), "Помощник видит только начало каждого документа и подсказывает, где искать, — окончательный ответ проверяйте в самом документе."),
|
||||
h("li", {}, h("b", {}, "Нагрузка. "), "Раз в сутки портал перечитывает изменившиеся файлы из Nextcloud, чтобы обновить индекс. Сканы без текстового слоя не индексируются — находятся только по имени."))),
|
||||
h("div", { class: "small muted", style: "margin-top:6px" }, "Индекс: " + status, ix.error ? h("div", { class: "error" }, ix.error) : null),
|
||||
h("div", { class: "row", style: "margin-top:8px" },
|
||||
h("button", { onclick: guard(async () => {
|
||||
if (on.checked && !c.docs_ai && !confirm("Включить «Работа с документацией»? Фрагменты документов будут уходить провайдеру ИИ.")) return;
|
||||
await api("PUT", "/docs/config", { docs_ai: on.checked });
|
||||
if (on.checked && !c.docs_index_at) await api("POST", "/docs/reindex");
|
||||
toast("Сохранено"); box.replaceWith(await docsAiSection());
|
||||
}) }, "Сохранить"),
|
||||
c.docs_ai ? h("button", { class: "sec", onclick: guard(async () => { await api("POST", "/docs/reindex"); toast("Индекс обновляется в фоне"); setTimeout(async () => box.replaceWith(await docsAiSection()), 1500); }) }, "Обновить индекс") : null));
|
||||
if (!me.docs) box.append(h("p", { class: "small muted" }, "Сначала подключите Nextcloud в разделе «Документация из Nextcloud» выше."));
|
||||
return box;
|
||||
}
|
||||
@@ -23,6 +23,9 @@
|
||||
<script src="/static/assistant.js"></script>
|
||||
<script src="/static/demo.js"></script>
|
||||
<script src="/static/feedback.js"></script>
|
||||
<script src="/static/docs.js"></script>
|
||||
<script src="/static/monitor.js"></script>
|
||||
<script src="/static/monitor.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use strict";
|
||||
// ---------- Мониторинг сервера: ЦП, память, диски (круговые диаграммы, обновление каждые 5 секунд) ----------
|
||||
const SVGNS = "http://www.w3.org/2000/svg";
|
||||
function svgEl(tag, attrs) {
|
||||
const e = document.createElementNS(SVGNS, tag);
|
||||
for (const [k, v] of Object.entries(attrs || {})) e.setAttribute(k, v);
|
||||
return e;
|
||||
}
|
||||
const gb = n => n >= 1073741824 ? (n / 1073741824).toFixed(1) + " ГБ" : (n / 1048576).toFixed(0) + " МБ";
|
||||
const levelColor = p => p >= 90 ? "var(--bad)" : p >= 75 ? "var(--warn)" : "var(--ok)";
|
||||
|
||||
// кольцевая диаграмма: доля p (0–100), подпись в центре
|
||||
function donut(p, center) {
|
||||
const r = 52, c = 2 * Math.PI * r, v = Math.max(0, Math.min(100, p ?? 0));
|
||||
const svg = svgEl("svg", { viewBox: "0 0 140 140", class: "donut", role: "img", "aria-label": center });
|
||||
svg.append(svgEl("circle", { cx: 70, cy: 70, r, fill: "none", stroke: "var(--line)", "stroke-width": 16 }),
|
||||
svgEl("circle", { cx: 70, cy: 70, r, fill: "none", stroke: levelColor(v), "stroke-width": 16, "stroke-linecap": "round",
|
||||
"stroke-dasharray": `${c * v / 100} ${c}`, transform: "rotate(-90 70 70)" }));
|
||||
const t = svgEl("text", { x: 70, y: 77, "text-anchor": "middle", class: "donut-v" });
|
||||
t.textContent = p == null ? "—" : Math.round(v) + "%";
|
||||
svg.append(t);
|
||||
return svg;
|
||||
}
|
||||
|
||||
const gauge = (title, p, lines) => h("div", { class: "card gauge" }, h("h2", {}, title), donut(p, title), ...lines.filter(Boolean).map(l => h("div", { class: "small muted" }, l)));
|
||||
|
||||
async function monitorPage() {
|
||||
if (!me.portal_admin) throw new Error("Мониторинг доступен администраторам портала");
|
||||
const box = h("div", { class: "gauges" }), stamp = h("div", { class: "small muted", style: "margin-bottom:8px" });
|
||||
fill($app, h("a", { href: "#/admin" }, "← Администрирование"), h("h1", {}, "Мониторинг сервера"),
|
||||
h("p", { class: "small muted" }, "Загрузка процессора, память и место на дисках сервера портала. Обновляется каждые 5 секунд, пока страница открыта."), stamp, box);
|
||||
const load = async () => {
|
||||
const m = await api("GET", "/server/monitor");
|
||||
const mem = m.memory, memP = mem ? 100 * mem.used / mem.total : null;
|
||||
box.replaceChildren(
|
||||
gauge("Процессор", m.cpu, [`ядер: ${m.cpu_count || "—"}`, m.load ? `средняя нагрузка: ${m.load.map(x => x.toFixed(2)).join(" / ")}` : ""]),
|
||||
gauge("Память (ОЗУ)", memP, mem ? [`занято ${gb(mem.used)} из ${gb(mem.total)}`, `свободно ${gb(mem.total - mem.used)}`, mem.scope === "container" ? "лимит контейнера Docker" : ""] : ["нет данных"]),
|
||||
...m.disks.map(d => gauge("Диск: " + d.labels.join(", "), 100 * d.used / d.total,
|
||||
[`свободно ${gb(d.free)} из ${gb(d.total)}`, h("span", { title: d.path }, d.path.length > 40 ? "…" + d.path.slice(-40) : d.path)])));
|
||||
stamp.textContent = "Обновлено " + new Date().toLocaleTimeString("ru-RU");
|
||||
};
|
||||
await load();
|
||||
timer = setInterval(guard(load), 5000);
|
||||
}
|
||||
@@ -583,8 +583,10 @@ async function taskForm(t) {
|
||||
field("Трудоёмкость (норма), ч", h("input", { type: "number", min: "0", step: "0.25", value: s.labor_hours ?? "", placeholder: "необязательно",
|
||||
onchange: e => s.labor_hours = e.target.value === "" ? null : +e.target.value }), "Для отчёта по сотрудникам; пусто — учитывается фактическое время в работе")),
|
||||
h("div", { style: "margin-top:8px" }, field("Описание", h("textarea", { onchange: e => s.description = e.target.value }, s.description))),
|
||||
h("label", { class: "chk", style: "margin-top:8px", title: "Фото к заданию кладутся в отдельный каталог и не удаляются автоочисткой сервера" },
|
||||
h("input", { type: "checkbox", checked: !!s.photo_keep, onchange: e => s.photo_keep = e.target.checked }), "📷 Фото к заданию хранить более года"),
|
||||
h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => {
|
||||
const body = { title: s.title, description: s.description, day: s.day || null, assignee_id: s.assignee_id, equipment_id: s.equipment_id, unit_id: s.unit_id, labor_hours: s.labor_hours ?? null };
|
||||
const body = { title: s.title, description: s.description, day: s.day || null, assignee_id: s.assignee_id, equipment_id: s.equipment_id, unit_id: s.unit_id, labor_hours: s.labor_hours ?? null, photo_keep: !!s.photo_keep };
|
||||
const r = t ? await api("PATCH", "/tasks/" + t.id, body) : await api("POST", "/tasks", body);
|
||||
toast("Сохранено"); location.hash = "#/task/" + r.id;
|
||||
}) }, t ? "Сохранить" : "Создать задание"))));
|
||||
|
||||
@@ -114,6 +114,7 @@ async function equipmentPage(id) {
|
||||
h("h1", {}, eq.name, eq.inventory_no ? h("span", { class: "muted", style: "font-size:16px" }, " № " + eq.inventory_no) : null),
|
||||
h("div", { class: "small muted", style: "margin-bottom:10px" }, loc ? "Место: " + loc.name : "", eq.notes ? " · " + eq.notes : ""),
|
||||
portal.head,
|
||||
docsBlock(eq, reload),
|
||||
meterCard,
|
||||
h("div", { class: "row", style: "margin:8px 0" }, h("h2", { style: "flex:1;margin:0" }, "Обслуживаемые узлы"),
|
||||
canEdit ? h("button", { onclick: () => unitForm(null) }, "+ Добавить обслуживаемый узел") : null),
|
||||
|
||||
+56
-15
@@ -27,6 +27,46 @@ const SCHEDULE_PRESETS = [
|
||||
["Круглосуточно (дежурная смена)", { 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(() => []);
|
||||
@@ -92,21 +132,19 @@ async function setupPage(stepKey) {
|
||||
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: "По графику система понимает, кто сейчас на смене и кому показывать наряды должности.",
|
||||
{ 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: "Тип должности: техник выполняет наряды, инженер планирует и проверяет. У должности — график.",
|
||||
{ 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]) => {
|
||||
hint: "Формат: Название; техник или инженер.",
|
||||
example: "Слесарь-ремонтник; техник\nДежурный механик; техник\nЭлектромонтёр; техник\nИнженер-механик; инженер",
|
||||
run: async ([name, kind]) => {
|
||||
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 });
|
||||
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: "Службы и участки. Отделы можно вкладывать друг в друга.",
|
||||
@@ -145,20 +183,22 @@ async function setupPage(stepKey) {
|
||||
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: "Учётные записи: должность определяет график и права, отдел — принадлежность.",
|
||||
{ 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]) => {
|
||||
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, department_id: dep ? dep.id : null,
|
||||
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 : "" };
|
||||
@@ -209,6 +249,7 @@ async function setupPage(stepKey) {
|
||||
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;
|
||||
@@ -222,6 +263,6 @@ async function setupPage(stepKey) {
|
||||
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" }, "Завершить")))));
|
||||
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" }, "Завершить и проверить")))));
|
||||
}
|
||||
|
||||
@@ -276,3 +276,29 @@ button.fault{background:var(--fault);border-color:var(--fault);color:#fff}
|
||||
.fbmodal{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:200;padding:16px}
|
||||
.fbmodal .card{width:min(560px,100%);margin:0}.fbmodal textarea{width:100%}
|
||||
.fbcard.fb-new{border-left:4px solid var(--accent)}.fbcard.fb-taken{border-left:4px solid var(--warn)}.fbcard.fb-done{opacity:.8}
|
||||
|
||||
/* документация из Nextcloud */
|
||||
.dcmodal{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:200;padding:16px}
|
||||
.dcmodal .card{width:min(640px,100%);max-height:86vh;overflow:auto;margin:0}
|
||||
.crumbs{font-size:14px;margin:4px 0 10px;word-break:break-word}.crumbs a{text-decoration:none}
|
||||
.dclist{display:flex;flex-direction:column;gap:2px;margin:0}
|
||||
.dcrow{display:flex;gap:8px;align-items:center;padding:7px 8px;border-radius:8px;color:var(--text);text-decoration:none;word-break:break-word}
|
||||
.dcrow:hover,.dcrow.on{background:var(--bg)}
|
||||
.dcwrap{display:grid;grid-template-columns:minmax(220px,320px) 1fr;gap:12px;align-items:start}.dcwrap>.card{margin:0}
|
||||
@media (max-width:900px){.dcwrap{grid-template-columns:1fr}}
|
||||
.dcframe{width:100%;height:78vh;border:1px solid var(--line);border-radius:8px;background:#fff}
|
||||
.dcimg{max-width:100%;border-radius:8px}.dctext{white-space:pre-wrap;max-height:78vh;overflow:auto;font-size:13px;margin:0}
|
||||
.docsai{margin-top:16px;border-top:1px solid var(--line);padding-top:6px}
|
||||
.sheet.risk{border-left:4px solid var(--warn);margin-top:8px}.sheet.risk ul{margin:6px 0 0;padding-left:18px}.sheet.risk li{margin:3px 0}
|
||||
|
||||
/* мониторинг сервера */
|
||||
.gauges{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}.gauges .card{margin:0}
|
||||
.gauge{text-align:center}.gauge h2{margin:0 0 6px;font-size:16px}
|
||||
.donut{width:150px;height:150px;display:block;margin:4px auto 8px}.donut circle{transition:stroke-dasharray .6s}
|
||||
.donut-v{font-size:24px;font-weight:700;fill:var(--text)}
|
||||
|
||||
/* мониторинг сервера */
|
||||
.gauges{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}.gauges .card{margin:0}
|
||||
.gauge{text-align:center}.gauge h2{margin:0 0 6px;font-size:16px}
|
||||
.donut{width:150px;height:150px;display:block;margin:4px auto 8px}.donut circle{transition:stroke-dasharray .6s}
|
||||
.donut-v{font-size:24px;font-weight:700;fill:var(--text)}
|
||||
|
||||
Reference in New Issue
Block a user