Files

124 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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 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();
}