Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для 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
+123
View File
@@ -0,0 +1,123 @@
"use strict";
// ---------- Графики работы: конструктор ----------
const PRESETS = [
{ name: "3 дня · отсыпной · 3 ночи · отсыпной · 3 выходных", steps: [
{ type: "work", days: 3, start_time: "08:00", end_time: "20:00", label: "день" }, { type: "off", days: 1, label: "отсыпной" },
{ type: "work", days: 3, start_time: "20:00", end_time: "08:00", label: "ночь" }, { type: "off", days: 1, label: "отсыпной" },
{ type: "off", days: 3, label: "выходные" }] },
{ name: "Сутки через трое", steps: [
{ type: "work", days: 1, start_time: "08:00", end_time: "08:00", label: "сутки" }, { type: "off", days: 3, label: "выходные" }] },
{ name: "2 дня / 2 ночи / 4 выходных", steps: [
{ 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: "выходные" }] },
];
const hm = t => String(t || "").slice(0, 5);
const dayWord = n => n === 1 ? "день" : (n >= 2 && n <= 4 ? "дня" : "дней");
function describeSchedule(r) {
if (r.kind === "pattern") {
const steps = (r.pattern || []).map(x => x.type === "work"
? `${x.days}×${x.label || "смена"} ${hm(x.start_time)}–${hm(x.end_time)}` : `${x.days}×${x.label || "вых."}`);
const total = (r.pattern || []).reduce((a, x) => a + x.days, 0);
return `Цикл ${total} дн.: ` + steps.join(" → ");
}
if (r.kind === "cycle") return `${r.work_days} через ${r.off_days}, ${hm(r.start_time)}–${hm(r.end_time)}`;
return `${(r.weekdays || []).map(i => DAYS[i]).join(" ")}, ${hm(r.start_time)}–${hm(r.end_time)}`;
}
async function schedulesPage() {
const box = h("div"), formBox = h("div");
$app.replaceChildren(h("div", { class: "row" }, h("h1", { style: "flex:1" }, "Графики работы"),
h("button", { onclick: () => edit(null) }, "+ Добавить")), formBox, box);
async function load() {
const [rows, pos] = await Promise.all([api("GET", "/schedules"), api("GET", "/positions")]);
box.replaceChildren(rows.length ? h("div", { class: "card tablewrap" }, h("table", {},
h("thead", {}, h("tr", {}, ...["Название", "Расписание", "Должности"].map(t => h("th", {}, t)), h("th"))),
h("tbody", {}, ...rows.map(r => h("tr", {}, h("td", {}, r.name), h("td", { class: "small" }, describeSchedule(r)),
h("td", { class: "small muted" }, pos.filter(p => p.schedule_id === r.id).map(p => p.name).join(", ") || "—"),
h("td", { class: "row end" }, h("button", { class: "sec sm", onclick: () => edit(r) }, "Изменить"),
h("button", { class: "sec sm", onclick: guard(async () => { if (confirm("Удалить график?")) { await api("DELETE", "/schedules/" + r.id); load(); } }) }, "✕"))))))) : h("div", { class: "card muted" }, "Пока пусто"));
}
function edit(row) {
const st = row ? JSON.parse(JSON.stringify(row)) : { name: "", kind: "pattern", weekdays: [0, 1, 2, 3, 4], work_days: 2, off_days: 2, start_time: "08:00", end_time: "20:00",
pattern: JSON.parse(JSON.stringify(PRESETS[0].steps)), cycle_start: new Date().toLocaleDateString("sv") };
st.pattern = st.pattern || []; st.cycle_start = st.cycle_start || new Date().toLocaleDateString("sv");
for (const x of st.pattern) { x.start_time = hm(x.start_time); x.end_time = hm(x.end_time); }
st.start_time = hm(st.start_time); st.end_time = hm(st.end_time);
const body = h("div"), preview = h("div");
const payload = () => ({
name: st.name, kind: st.kind, weekdays: st.weekdays, work_days: st.work_days, off_days: st.off_days,
cycle_start: st.kind === "weekly" ? null : st.cycle_start || null, start_time: st.start_time, end_time: st.end_time,
pattern: st.kind === "pattern" ? st.pattern.map(x => ({ type: x.type, days: +x.days || 1, start_time: x.start_time || "08:00", end_time: x.end_time || "20:00", label: x.label || "" })) : null,
});
let timer;
const refresh = () => { clearTimeout(timer); timer = setTimeout(guard(drawPreview), 250); };
async function drawPreview() {
if (st.kind === "pattern" && !st.pattern.some(x => x.type === "work")) { preview.replaceChildren(h("p", { class: "muted small" }, "Добавьте хотя бы одну рабочую смену")); return; }
const from = st.kind === "weekly" ? new Date().toLocaleDateString("sv") : st.cycle_start;
let days;
try { days = await api("POST", "/schedules/preview", { schedule: payload(), from_date: from, days: 42 }); }
catch (e) { preview.replaceChildren(h("p", { class: "warnline" }, e.message)); return; }
const first = (new Date(days[0].date + "T00:00").getDay() + 6) % 7; // сдвиг до понедельника
const cells = [...Array(first).fill(null), ...days];
const grid = h("div", { class: "cal" }, ...DAYS.map(d => h("div", { class: "calh" }, d)),
...cells.map(d => {
if (!d) return h("div");
const night = d.work && d.end.slice(0, 10) !== d.start.slice(0, 10);
return h("div", { class: "calc " + (d.work ? (night ? "night" : "work") : "off") }, h("b", {}, new Date(d.date + "T00:00").getDate()),
d.work ? h("span", {}, d.start.slice(11, 16) + "–" + d.end.slice(11, 16)) : h("span", {}, "—"), d.label ? h("i", {}, d.label) : null);
}));
preview.replaceChildren(h("h2", {}, "Как это выглядит (6 недель с " + new Date(from + "T00:00").toLocaleDateString("ru-RU") + ")"), grid,
h("p", { class: "small muted" }, "Ночные смены показаны в день начала и заканчиваются утром следующего дня."));
}
function stepRow(x, n) {
const mv = d => () => { const j = n + d; [st.pattern[j], st.pattern[n]] = [st.pattern[n], st.pattern[j]]; draw(); };
return h("div", { class: "step " + x.type },
h("select", { onchange: e => { x.type = e.target.value; draw(); } }, h("option", { value: "work", selected: x.type === "work" }, "Смена"), h("option", { value: "off", selected: x.type === "off" }, "Выходной")),
h("input", { type: "number", min: 1, max: 60, value: x.days, title: "Сколько дней подряд", onchange: e => { x.days = +e.target.value || 1; draw(); } }),
h("span", { class: "small muted" }, dayWord(x.days)),
x.type === "work" ? [h("input", { type: "time", value: x.start_time, onchange: e => { x.start_time = e.target.value; draw(); } }), "–",
h("input", { type: "time", value: x.end_time, onchange: e => { x.end_time = e.target.value; draw(); } })] : h("span", { class: "spacer" }),
h("input", { placeholder: x.type === "work" ? "метка: день/ночь" : "метка: отсыпной", value: x.label || "", onchange: e => { x.label = e.target.value; draw(); } }),
h("button", { class: "sec sm", disabled: n === 0, onclick: mv(-1) }, "↑"), h("button", { class: "sec sm", disabled: n === st.pattern.length - 1, onclick: mv(1) }, "↓"),
h("button", { class: "sec sm", onclick: () => { st.pattern.splice(n, 1); draw(); } }, "✕"));
}
function draw() {
const name = h("input", { value: st.name, onchange: e => st.name = e.target.value });
const kind = h("select", { onchange: e => { st.kind = e.target.value; draw(); } },
...[["pattern", "Цикл со своими шагами (конструктор)"], ["cycle", "Простой цикл N через M"], ["weekly", "По дням недели"]].map(([k, t]) => h("option", { value: k, selected: st.kind === k }, t)));
const inp = (key, type, label) => h("div", {}, h("label", {}, label), h("input", { type, value: st[key] ?? "", onchange: e => { st[key] = type === "number" ? +e.target.value : e.target.value; draw(); } }));
const parts = [h("div", { class: "grid" }, h("div", {}, h("label", {}, "Название *"), name), h("div", {}, h("label", {}, "Тип графика"), kind))];
if (st.kind === "pattern") {
const total = st.pattern.reduce((a, x) => a + (+x.days || 1), 0);
parts.push(h("h2", {}, "Шаги цикла"),
h("p", { class: "small muted" }, "Шаги идут по кругу. Для ночной смены поставьте конец раньше начала (20:00–08:00), для суток — одинаковое время (08:00–08:00). «Отсыпной» — это просто выходной шаг с меткой."),
h("div", { class: "row", style: "margin-bottom:8px" }, h("span", { class: "small muted" }, "Шаблоны:"),
...PRESETS.map(p => h("button", { class: "sec sm", onclick: () => { st.pattern = JSON.parse(JSON.stringify(p.steps)); draw(); } }, p.name))),
...st.pattern.map(stepRow),
h("div", { class: "row", style: "margin-top:8px" },
h("button", { class: "sec", onclick: () => { st.pattern.push({ type: "work", days: 1, start_time: "08:00", end_time: "20:00", label: "" }); draw(); } }, "+ Смена"),
h("button", { class: "sec", onclick: () => { st.pattern.push({ type: "off", days: 1, label: "" }); draw(); } }, "+ Выходной"),
h("span", { class: "small muted" }, `Длина цикла: ${total} ${dayWord(total)}`)),
h("div", { class: "grid", style: "margin-top:12px;max-width:320px" }, h("div", {}, h("label", {}, "Первый день цикла (с какой даты начинается шаг №1)"),
h("input", { type: "date", value: st.cycle_start, onchange: e => { st.cycle_start = e.target.value; draw(); } }))));
} else if (st.kind === "cycle") {
parts.push(h("div", { class: "grid", style: "margin-top:12px" }, inp("work_days", "number", "Рабочих дней подряд"), inp("off_days", "number", "Выходных подряд"),
inp("cycle_start", "date", "Первый рабочий день цикла"), inp("start_time", "time", "Начало смены"), inp("end_time", "time", "Конец смены")));
} else {
parts.push(h("div", { style: "margin-top:12px" }, h("label", {}, "Дни недели"), h("div", { class: "days" }, ...DAYS.map((d, i) => h("label", {},
h("input", { type: "checkbox", checked: st.weekdays.includes(i), onchange: e => { st.weekdays = DAYS.map((_, j) => j).filter(j => j === i ? e.target.checked : st.weekdays.includes(j)); draw(); } }), d)))),
h("div", { class: "grid", style: "margin-top:12px;max-width:440px" }, inp("start_time", "time", "Начало смены"), inp("end_time", "time", "Конец смены")));
}
body.replaceChildren(...parts); refresh();
}
draw();
formBox.replaceChildren(h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, row ? "Изменение графика" : "Новый график"), body, preview,
h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => {
await api(row ? "PATCH" : "POST", "/schedules" + (row ? "/" + row.id : ""), payload());
for (const k in cache) delete cache[k]; formBox.replaceChildren(); toast("Сохранено"); load();
}) }, "Сохранить"), h("button", { class: "sec", onclick: () => formBox.replaceChildren() }, "Отмена"))));
formBox.scrollIntoView({ behavior: "smooth" });
}
await load();
}