// «Контроль исполнения поручений» — the main register page.
const MONTH_ORDER = ["январь","февраль","март","апрель","май","июнь","июль","август","сентябрь","октябрь","ноябрь","декабрь"];
function personFor(assignee, dept) {
if (PEOPLE[assignee]) return PEOPLE[assignee];
const m = assignee.match(/[А-ЯЁ][а-яё]+\s+[А-ЯЁ]\.[А-ЯЁ]\.?/);
if (m && PEOPLE[m[0]]) return PEOPLE[m[0]];
// destination phrase (e.g. «В бухгалтерию») → department bot persona
return { name: assignee, tg: "@" + (DEPT_COLORS[dept] ? "otdel" : "porucheniya") + "_bot", color: DEPT_COLORS[dept] || "#5d6b82" };
}
function todayStr() {
const t = (typeof effToday === "function") ? effToday() : DEMO_TODAY;
return `${String(t.d).padStart(2,"0")}.${String(t.m).padStart(2,"0")}.${t.y}`;
}
const YEARS = [2024, 2025, 2026];
function PorucheniyaPage({ intent }) {
const [tasks, setTasks] = React.useState(() => PORUCHENIYA.map(t => ({ ...t, year: 2025 })));
const [year, setYear] = React.useState(2025);
const [yearMenu, setYearMenu] = React.useState(false);
const [month, setMonth] = React.useState("январь");
const [group, setGroup] = React.useState("dept");
const [query, setQuery] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
const [assigneeFilter, setAssigneeFilter] = React.useState("all");
const [deptFilter, setDeptFilter] = React.useState("all");
const [yearScope, setYearScope] = React.useState(false); // "Весь год": filters span the whole year instead of the selected month
const [collapsed, setCollapsed] = React.useState(new Set());
const [expanded, setExpanded] = React.useState(new Set());
const [tg, setTg] = React.useState({ open: false, taskId: null });
const [toasts, setToasts] = React.useState([]);
const [exportOpen, setExportOpen] = React.useState(false);
const [newOpen, setNewOpen] = React.useState(false);
const [tgCfgOpen, setTgCfgOpen] = React.useState(false);
const [tgInfo, setTgInfo] = React.useState({ configured: false, running: false, username: "", chats: [] });
const [editTask, setEditTask] = React.useState(null);
const [reassign, setReassign] = React.useState(null); // task being reassigned, or null
const [importPrev, setImportPrev] = React.useState(null); // import diff preview, or null
const [importing, setImporting] = React.useState(false);
const importInputRef = React.useRef(null);
const [notesByTask, setNotesByTask] = React.useState({}); // taskId -> [note], loaded lazily on expand
const dir = useDirectory(); // live people/departments from the DB (re-renders on load)
// Load the live register from the backend; fall back to bundled mock data if offline.
const reload = React.useCallback(() => {
if (!window.API) return;
window.API.listTasks()
.then(data => { if (Array.isArray(data)) setTasks(data); })
.catch(() => {});
}, []);
React.useEffect(() => { reload(); }, [reload]);
// Live updates pushed from the backend (e.g. Telegram button callbacks).
React.useEffect(() => {
if (!window.API || !window.API.events) return;
const es = window.API.events({
onTask: (task) => {
setTasks(ts => {
const i = ts.findIndex(t => t.id === task.id);
if (i === -1) return [...ts, task];
const next = ts.slice();
next[i] = task;
return next;
});
},
onDeleted: ({ id }) => setTasks(ts => ts.filter(t => t.id !== id)),
onNote: (note) => setNotesByTask(m => {
const cur = m[note.taskId];
if (!cur) return m; // not loaded yet — will be fetched fresh on expand
if (cur.some(n => n.id === note.id)) return m;
return { ...m, [note.taskId]: [...cur, note] };
}),
});
return () => { if (es) es.close(); };
}, []);
// Telegram bot status (for the banner + config modal).
const refreshTgStatus = React.useCallback(() => {
if (window.API && window.API.telegramStatus) {
window.API.telegramStatus().then(setTgInfo).catch(() => {});
}
}, []);
React.useEffect(() => { refreshTgStatus(); }, [refreshTgStatus]);
// Load the note/question thread when the Telegram panel opens for a task.
React.useEffect(() => {
if (tg.open && tg.taskId != null && window.API && window.API.listNotes && !notesByTask[tg.taskId]) {
window.API.listNotes(tg.taskId)
.then(ns => setNotesByTask(m => ({ ...m, [tg.taskId]: Array.isArray(ns) ? ns : [] })))
.catch(() => {});
}
}, [tg.open, tg.taskId]);
// Server-provided "today" so the overdue calc isn't hardcoded in the UI.
React.useEffect(() => {
if (!window.API || !window.API.config) return;
window.API.config().then(cfg => {
const m = cfg && cfg.today && String(cfg.today).match(/(\d{4})-(\d{2})-(\d{2})/);
if (m) window.SERVER_TODAY = { d: +m[3], m: +m[2], y: +m[1] };
}).catch(() => {});
}, []);
// Apply navigation intent (topbar search / overdue bell / employee link).
// An intent defines a fresh filter view: filters it doesn't mention reset to defaults.
React.useEffect(() => {
if (!intent) return;
setQuery(intent.query != null ? intent.query : "");
setStatusFilter(intent.status || "all");
setAssigneeFilter(intent.assignee || "all");
setDeptFilter(intent.dept || "all");
if (intent.year) setYear(intent.year);
if (intent.month) {
setMonth(intent.month);
setYearScope(false);
} else {
// Topbar search / overdue bell / employee link land on the whole-year view.
const wantYear = !!intent.allYear || !!intent.query
|| (intent.status && intent.status !== "all")
|| (intent.assignee && intent.assignee !== "all")
|| (intent.dept && intent.dept !== "all");
setYearScope(!!wantYear);
}
}, [intent]);
// ---- filtering ----
// Every filter (search / status / assignee / dept) composes uniformly with the
// current scope: a single month, or the whole year ("Весь год" tab / yearScope).
// Applying any filter auto-switches to the year scope so matches in other months
// aren't hidden (e.g. searching "Фролова" while on Январь); clicking a month tab
// narrows back to that month.
const yearTasks = tasks.filter(t => t.year === year);
const monthTasks = yearTasks.filter(t => t.mon === month);
const passFilter = (t) => {
if (query && !(t.text.toLowerCase().includes(query.toLowerCase()) || t.assignee.toLowerCase().includes(query.toLowerCase()))) return false;
if (statusFilter !== "all" && effStatus(t) !== statusFilter) return false;
if (assigneeFilter !== "all" && t.assignee !== assigneeFilter) return false;
if (deptFilter !== "all" && t.dept !== deptFilter) return false;
return true;
};
const anyFilter = query.trim() !== "" || statusFilter !== "all" || assigneeFilter !== "all" || deptFilter !== "all";
const filtered = (yearScope ? yearTasks : monthTasks).filter(passFilter);
const yearMatch = yearTasks.filter(passFilter); // count for the "Весь год" tab
const months = MONTH_ORDER;
const monthCount = (m) => yearTasks.filter(t => t.mon === m && passFilter(t)).length;
const yearCount = (y) => tasks.filter(t => t.year === y).length;
const changeYear = (delta) => {
const idx = YEARS.indexOf(year);
const ni = Math.min(YEARS.length - 1, Math.max(0, idx + delta));
setYear(YEARS[ni]);
};
const pushToast = (text, icon, color) => {
const id = Date.now() + Math.random();
setToasts(ts => [...ts, { id, text, icon, color }]);
setTimeout(() => setToasts(ts => ts.filter(t => t.id !== id)), 3200);
};
const setStatus = (id, status, extra = {}) => {
setTasks(ts => ts.map(t => t.id === id ? { ...t, status, ...extra } : t));
// Persist the transition to the backend (best-effort; the UI is already updated).
if (window.API) window.API.updateTask(id, { status, ...extra }).catch(() => {});
};
// ---- Telegram actions ----
const openTelegram = async (task) => {
const needsSend = task.status === "draft" || task.status === "in_progress";
if (needsSend && window.API && window.API.sendTelegram) {
try {
await window.API.sendTelegram(task.id); // backend sends + sets 'sent' (SSE updates state)
const p = personFor(task.assignee, task.dept);
pushToast(`Отправлено в Telegram → ${p.tg}`, , "#2682c0");
setTg({ open: true, taskId: task.id });
return;
} catch (err) {
const msg = String((err && err.message) || err);
if (/не настроен/i.test(msg)) {
pushToast("Telegram-бот не подключён", , "var(--warn)");
setTgCfgOpen(true);
return;
}
// bot is on but e.g. the user hasn't done /start — show why, then open a local preview
pushToast(msg, , "var(--warn)");
setStatus(task.id, "sent");
setTg({ open: true, taskId: task.id });
return;
}
}
// No backend, or the task was already sent: original local behavior.
if (needsSend) {
setStatus(task.id, "sent");
const p = personFor(task.assignee, task.dept);
pushToast(`Отправлено в Telegram → ${p.tg}`, , "#2682c0");
}
setTg({ open: true, taskId: task.id });
};
const tgAccept = () => {
const t = tasks.find(x => x.id === tg.taskId);
if (!t) return;
if (!window.confirm(`Отметить поручение №${t.num} как «Принято в работу» от имени исполнителя?`)) return;
setStatus(t.id, "accepted");
const p = personFor(t.assignee, t.dept);
pushToast(`${p.name} принял(а) поручение в работу`, , "#5546b0");
};
const tgDone = () => {
const t = tasks.find(x => x.id === tg.taskId);
if (!t) return;
if (!window.confirm(`Отметить поручение №${t.num} как «Выполнено»? Это изменит статус в реестре.`)) return;
setStatus(t.id, "done", { comment: "выполнено", dateEnd: t.dateEnd || todayStr() });
pushToast(`Поручение №${t.num} отмечено «Выполнено»`, , "var(--ok)");
};
const buildMessages = (task) => {
if (!task) return [];
const due = task.dateEnd || task.dateStart || "—";
const msgs = [
{ dir: "in", bot: true, text: "Вам назначено новое поручение 👇", time: "14:30" },
{ dir: "in", bot: true, card: { num: task.num, mon: task.mon, text: task.text, due, dept: task.dept }, keyboard: true, time: "14:30" },
];
if (task.status === "accepted" || task.status === "done") {
msgs.push({ dir: "out", text: "✅ Принял в работу", time: "14:33" });
msgs.push({ dir: "in", bot: true, text: "Принято. На сайте статус обновлён на «Принято». Отдел контроля уведомлён.", time: "14:33" });
msgs.push({ kind: "sync", text: "Синхронизировано с реестром" });
}
if (task.status === "done") {
msgs.push({ dir: "out", text: "✔️ Выполнено", time: "15:10" });
msgs.push({ dir: "in", bot: true, text: `Отлично! Поручение №${task.num} закрыто и отмечено «Выполнено» в реестре.`, time: "15:10" });
msgs.push({ kind: "sync", text: "Реестр обновлён · контроль уведомлён" });
}
return msgs;
};
const activeTask = tasks.find(t => t.id === tg.taskId) || null;
const activePerson = activeTask ? personFor(activeTask.assignee, activeTask.dept) : null;
// ---- stats for the current view (month, or the filtered set when searching) ----
const stat = (arr) => {
const s = { total: arr.length, done: 0, work: 0, overdue: 0, waiting: 0 };
arr.forEach(t => {
const e = effStatus(t);
if (e === "done") s.done++;
else if (e === "overdue") s.overdue++;
else if (e === "sent") s.waiting++;
else s.work++;
});
return s;
};
const S = stat(filtered);
const toggleDept = (d) => {
const n = new Set(collapsed);
n.has(d) ? n.delete(d) : n.add(d);
setCollapsed(n);
};
const toggleExpand = (id) => {
const n = new Set(expanded);
if (n.has(id)) {
n.delete(id);
} else {
n.add(id);
if (window.API && window.API.listNotes && !notesByTask[id]) {
window.API.listNotes(id)
.then(ns => setNotesByTask(m => ({ ...m, [id]: Array.isArray(ns) ? ns : [] })))
.catch(() => {});
}
}
setExpanded(n);
};
const addTaskNote = async (taskId, text) => {
if (!text.trim() || !window.API || !window.API.addNote) return;
try {
const note = await window.API.addNote(taskId, text.trim());
// Dedup by id: the SSE 'note' event may have already appended this note
// (race between the POST response and the live event).
setNotesByTask(m => {
const cur = m[taskId] || [];
if (cur.some(n => n.id === note.id)) return m;
return { ...m, [taskId]: [...cur, note] };
});
pushToast("Заметка добавлена", , "var(--accent)");
} catch (e) {
pushToast(String((e && e.message) || e), , "var(--warn)");
}
};
// ---- export ----
const exportCSV = () => {
const header = ["№","Дата поручения","Формулировка поручения","Ответственный","Дата начала","Дата окончания","Статус"];
const esc = (v) => `"${String(v == null ? "" : v).replace(/"/g, '""')}"`;
const lines = [header.map(esc).join(";")];
filtered.forEach(t => {
lines.push([t.num, t.dateOrder, t.text, t.assignee, t.dateStart, t.dateEnd || "", STATUS_META[effStatus(t)].label].map(esc).join(";"));
});
const blob = new Blob(["\uFEFF" + lines.join("\r\n")], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `Поручения_${month}_${year}.csv`;
a.click();
setExportOpen(false);
pushToast("Выгружено в Excel (CSV)", , "var(--ok)");
};
// Server-side .xlsx export in the register format (one sheet per month).
// scope "year" → whole year, "month" → current month tab.
const exportXlsx = (scope) => {
setExportOpen(false);
const q = new URLSearchParams();
q.set("year", year);
if (scope === "month") q.set("month", month);
const a = document.createElement("a");
a.href = "/api/tasks/export?" + q.toString();
document.body.appendChild(a);
a.click();
a.remove();
pushToast(scope === "month" ? `Выгружен ${month} ${year} (Excel)` : `Выгружен ${year} год (Excel)`, , "var(--ok)");
};
const exportPrint = () => {
setExportOpen(false);
setTimeout(() => window.print(), 100);
};
// ---- import: pick .xlsx → preview diff → resolve conflicts → apply ----
const handleImportFile = async (e) => {
const file = e.target.files && e.target.files[0];
e.target.value = "";
if (!file || !window.API) return;
setExportOpen(false);
setImporting(true);
try {
const prev = await window.API.importPreview(file, year, month);
setImportPrev(prev);
} catch (err) {
pushToast("Импорт: " + String((err && err.message) || err), , "var(--err)");
} finally {
setImporting(false);
}
};
const onImported = (res) => {
setImportPrev(null);
reload();
pushToast(`Импорт: добавлено ${res.created}, обновлено ${res.updated}`, , "var(--ok)");
};
const createOrder = async (payload) => {
const fallbackDept = personFor(payload.assignee, "").dept || (PEOPLE[payload.assignee]?.dept) || "Юридический отдел";
const realDept = PEOPLE[payload.assignee] ? PEOPLE[payload.assignee].dept : fallbackDept;
const input = { mon: month, year, text: payload.text, assignee: payload.assignee, dept: realDept, dateEnd: payload.due || "" };
// Persist via the backend (server assigns id and the next number); fall back to a local task if offline.
let t = null;
try {
if (window.API) t = await window.API.createTask(input);
} catch (e) {
pushToast("Не удалось сохранить на сервере — создано локально", , "var(--warn)");
}
if (!t) {
const maxNum = Math.max(0, ...monthTasks.map(x => x.num || 0));
t = {
id: Date.now(),
mon: month,
year,
num: maxNum + 1,
dateOrder: todayStr(),
dateStart: todayStr(),
dateEnd: payload.due || null,
text: payload.text,
assignee: payload.assignee,
dept: realDept,
status: "draft",
comment: "",
};
}
setTasks(ts => [...ts, t]);
setNewOpen(false);
pushToast(`Создано поручение №${t.num}`, , "var(--accent)");
if (payload.sendNow) {
setTimeout(() => openTelegram(t), 300);
}
};
const saveOrder = async (id, patch) => {
setTasks(ts => ts.map(t => t.id === id ? { ...t, ...patch } : t));
setEditTask(null);
if (window.API) { try { await window.API.updateTask(id, patch); } catch (e) {} }
pushToast("Поручение обновлено", , "var(--accent)");
};
const deleteOrder = async (task) => {
if (!window.confirm(`Удалить поручение №${task.num} без возможности восстановления?`)) return;
setTasks(ts => ts.filter(t => t.id !== task.id));
if (window.API && window.API.deleteTask) {
try { await window.API.deleteTask(task.id); }
catch (e) { pushToast(String((e && e.message) || e), , "var(--warn)"); reload(); return; }
}
pushToast(`Поручение №${task.num} удалено`, , "var(--err)");
};
const cancelOrder = async (task) => {
if (!window.confirm(`Отменить поручение №${task.num}? Оно останется в реестре со статусом «Отменено».`)) return;
setStatus(task.id, "cancelled");
pushToast(`Поручение №${task.num} отменено`, , "var(--ink-400)");
};
const reassignOrder = async (id, assignee, dept) => {
setReassign(null);
const patch = { assignee };
if (dept) patch.dept = dept;
setTasks(ts => ts.map(t => t.id === id ? { ...t, ...patch } : t));
if (window.API) { try { await window.API.updateTask(id, patch); } catch (e) { pushToast(String((e && e.message) || e), , "var(--warn)"); reload(); return; } }
pushToast(`Ответственный изменён → ${assignee}`, , "var(--accent)");
};
// Group by the departments actually present in the filtered tasks (not just the
// directory), so imported tasks with an empty/unknown dept aren't hidden — they
// fall into a "Без отдела" bucket. Known departments keep their directory order.
const deptOf = (t) => t.dept || "Без отдела";
const depts = [...new Set(filtered.map(deptOf))].sort((a, b) => {
const ia = DEPARTMENTS.indexOf(a), ib = DEPARTMENTS.indexOf(b);
return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib);
});
const canEdit = !window.ME || window.ME.role !== "executor"; // executor: read-only register (own tasks)
const canDelete = window.ME && window.ME.role === "admin"; // hard delete: admin only
return (
Контроль исполнения поручений
Реестр {year} · {yearCount(year)} поручений · АО «Юридический центр»
changeYear(-1)} disabled={year <= YEARS[0]} title="Предыдущий год">
setYearMenu(o => !o)}>
{year}
changeYear(1)} disabled={year >= YEARS[YEARS.length - 1]} title="Следующий год">
{yearMenu && (
setYearMenu(false)}>
{YEARS.map(y => (
{ setYear(y); setYearMenu(false); }}>
{y} год
{yearCount(y)} поруч.
))}
)}
setTgCfgOpen(true)} title="Настройки Telegram-бота">
Telegram-бот
{tgInfo.configured
? <> подключён{tgInfo.username ? ` · @${tgInfo.username}` : ""}>
: <>● не подключён · настроить>}
setYearScope(true)}>
Весь год{yearMatch.length}
{months.map(m => (
{ setYearScope(false); setMonth(m); }}>
{m}{monthCount(m)}
))}
} barColor="var(--ink-300)" pct={100} />
} barColor="var(--ok)" pct={S.total ? S.done / S.total * 100 : 0} />
} barColor="var(--warn)" pct={S.total ? S.work / S.total * 100 : 0} />
} barColor="#2682c0" pct={S.total ? S.waiting / S.total * 100 : 0} />
} barColor="var(--err)" pct={S.total ? S.overdue / S.total * 100 : 0} />
setGroup("dept")}> По отделам
setGroup("list")}> Списком
{ setQuery(e.target.value); if (e.target.value.trim()) setYearScope(true); }} />
{ setAssigneeFilter(e.target.value); if (e.target.value !== "all") setYearScope(true); }} title="Фильтр по ответственному">
Все ответственные
{Object.keys(dir.people).map(n => {n} )}
{ setDeptFilter(e.target.value); if (e.target.value !== "all") setYearScope(true); }} title="Фильтр по отделу">
Все отделы
{dir.departments.map(d => {d} )}
{[["all","Все"],["overdue","Просрочено"],["sent","Ждут"],["in_progress","В работе"],["done","Выполнено"]].map(([k,l]) => (
{ setStatusFilter(k); if (k !== "all") setYearScope(true); }}>{l}
))}
{anyFilter && (
{ setQuery(""); setStatusFilter("all"); setAssigneeFilter("all"); setDeptFilter("all"); setYearScope(false); }}>
Сбросить
)}
setExportOpen(o => !o)}> Экспорт
{exportOpen && (
setExportOpen(false)}>
exportXlsx("year")}> Excel — весь {year} год
exportXlsx("month")}> Excel — {month}
Текущий вид (CSV)
Печать / PDF
)}
{canEdit && (
<>
importInputRef.current && importInputRef.current.click()} disabled={importing}>
{importing ? "Чтение…" : "Импорт"}
>
)}
{canEdit &&
setNewOpen(true)}> Новое поручение }
{(anyFilter || yearScope) && (
{yearScope ? `За весь ${year} год` : `За ${month} ${year}`}{statusFilter !== "all" ? ` · ${({ overdue: "просрочено", sent: "ждут", in_progress: "в работе", done: "выполнено" })[statusFilter] || statusFilter}` : ""}{assigneeFilter !== "all" ? ` · ${assigneeFilter}` : ""}{deptFilter !== "all" ? ` · ${deptFilter}` : ""}{query ? ` · «${query}»` : ""} — найдено {filtered.length}
)}
{/* column header */}
{filtered.length > 0 && (
№
Формулировка поручения
Ответственный
Срок
Статус
)}
{!yearScope && !anyFilter && monthTasks.length === 0 ? (
За {month} {year} поручений пока нет
Выберите другой месяц или год в шапке реестра — либо создайте первое поручение, оно сразу уйдёт исполнителю в Telegram.
{canEdit &&
setNewOpen(true)}> Новое поручение }
) : filtered.length === 0 ? (
{(anyFilter || yearScope) ? "По заданным фильтрам поручений не найдено" : "По заданным условиям поручений не найдено"}
) : null}
{group === "dept" ? (
depts.map(d => {
const list = filtered.filter(t => deptOf(t) === d);
const done = list.filter(t => effStatus(t) === "done").length;
const pct = list.length ? Math.round(done / list.length * 100) : 0;
const isOpen = !collapsed.has(d);
return (
toggleDept(d)}>
{d}
{list.length} поруч. · выполнено {done}
{pct}%
{isOpen && (
{list.map(t => (
toggleExpand(t.id)} onTelegram={() => openTelegram(t)} onEdit={() => setEditTask(t)} onDelete={() => deleteOrder(t)} onCancel={() => cancelOrder(t)} onReassign={() => setReassign(t)} canEdit={canEdit} canDelete={canDelete} showMonth={yearScope} notes={notesByTask[t.id]} onAddNote={addTaskNote} />
))}
)}
);
})
) : (
{filtered.map(t => (
toggleExpand(t.id)} onTelegram={() => openTelegram(t)} onEdit={() => setEditTask(t)} onDelete={() => deleteOrder(t)} onCancel={() => cancelOrder(t)} onReassign={() => setReassign(t)} canEdit={canEdit} canDelete={canDelete} showDept showMonth={yearScope} notes={notesByTask[t.id]} onAddNote={addTaskNote} />
))}
)}
setTg({ open: false, taskId: null })}
onAccept={tgAccept}
onDone={tgDone}
onSendNote={(text) => addTaskNote(tg.taskId, text)}
/>
{(newOpen || editTask) && (
{ setNewOpen(false); setEditTask(null); }}
onCreate={createOrder}
onSave={saveOrder}
/>
)}
{reassign && setReassign(null)} onApply={reassignOrder} />}
{importPrev && setImportPrev(null)} onApplied={onImported} />}
{tgCfgOpen && setTgCfgOpen(false)} onSaved={(st) => setTgInfo(st)} />}
);
}
function MiniKpi({ n, label, icon, barColor, pct }) {
return (
);
}
// NotesBlock — shared note/question thread for a task (site + Telegram), shown when a row is expanded.
function NotesBlock({ notes, onAdd }) {
const [text, setText] = React.useState("");
const list = notes || [];
const submit = () => { if (text.trim()) { onAdd(text.trim()); setText(""); } };
return (
e.stopPropagation()}>
Заметки и вопросы{list.length > 0 ? ` · ${list.length}` : ""}
{list.length === 0 &&
Пока нет заметок. Вопросы исполнителя из Telegram появятся здесь.
}
{list.map(n => (
{n.kind === "question" ? : }
{n.author}
· {n.source === "telegram" ? "Telegram" : "сайт"}
· {n.created}
{n.text}
))}
setText(e.target.value)} onKeyDown={e => { if (e.key === "Enter") submit(); }} />
setText(p => (p ? p.trimEnd() + " " : "") + t)} />
Добавить
);
}
function OrderRow({ t, expanded, onExpand, onTelegram, onEdit, onDelete, onCancel, onReassign, canEdit, canDelete, showDept, showMonth, notes, onAddNote }) {
const e = effStatus(t);
const p = personFor(t.assignee, t.dept);
const due = t.dateEnd || t.dateStart || "—";
const btnLabel = (t.status === "draft" || t.status === "in_progress")
? "Отправить" : t.status === "sent" ? "Ожидает" : t.status === "done" ? "Чат" : "Открыть";
const btnCls = (t.status === "draft" || t.status === "in_progress") ? "" : t.status === "done" ? "done" : "sent";
return (
{t.num != null ? t.num : "—"}
{t.text}
{showMonth && {t.mon} }
от {t.dateOrder || "—"}
{showDept && ● {t.dept} }
{t.comment && e === "done" && {t.comment} }
{expanded && canEdit && (
Редактировать
Сменить ответственного
{e !== "cancelled" && e !== "done" && (
Отменить
)}
{canDelete && Удалить }
)}
{expanded &&
onAddNote && onAddNote(t.id, txt)} />}
{btnLabel}
);
}
// ---- Reassign modal: change the responsible person on a task ----
function ReassignModal({ task, onClose, onApply }) {
const dir = useDirectory(); // active people only (dismissed are filtered out)
const peopleMap = dir.people || {};
const [assignee, setAssignee] = React.useState(task.assignee || "");
const options = Object.keys(peopleMap).map(k => ({ value: k, label: k, sub: peopleMap[k].dept }));
if (assignee && !peopleMap[assignee]) options.unshift({ value: assignee, label: assignee + " (текущий)" });
const newDept = (peopleMap[assignee] || {}).dept || "";
return (
e.stopPropagation()}>
Сменить ответственного
Поручение №{task.num} · сейчас: {task.assignee || "—"}
Новый ответственный
{newDept && newDept !== task.dept && (
Отдел поручения изменится на «{newDept}».
)}
Отмена
onApply(task.id, assignee, newDept)} disabled={!assignee || assignee === task.assignee}>
Применить
);
}
// ---- Import resolver: git-style left (DB) / right (file) conflict resolution ----
function ImportModal({ prev, onClose, onApplied }) {
const conflicts = prev.conflicts || [];
const news = prev.new || [];
const [choices, setChoices] = React.useState(() => conflicts.map(c => c.suggest || "file"));
const [includeNew, setIncludeNew] = React.useState(true);
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState("");
const FIELD = { text: "Формулировка", assignee: "Ответственный", dateEnd: "Срок", status: "Статус" };
const statusLbl = (code) => (code && STATUS_META[code] ? STATUS_META[code].label : (code || "—"));
const cellVal = (obj, field) => {
if (field === "status") return statusLbl(obj.status); // stored step (not derived overdue)
return (obj[field] || "—");
};
const pick = (i, side) => setChoices(cs => cs.map((c, j) => (j === i ? side : c)));
const fileWins = choices.filter(c => c === "file").length;
const apply = async () => {
setBusy(true); setErr("");
const tasks = [];
if (includeNew) tasks.push(...news);
conflicts.forEach((c, i) => { if (choices[i] === "file") tasks.push(c.file); });
try {
const r = await window.API.importApply(prev.year, tasks);
onApplied(r);
} catch (e) { setErr(String((e && e.message) || e)); setBusy(false); }
};
return (
e.stopPropagation()}>
Импорт из Excel · {prev.year}
{news.length} новых · {conflicts.length} конфликтов · {prev.same} без изменений
{news.length > 0 && (
setIncludeNew(e.target.checked)} />
Добавить {news.length} новых — есть в файле, нет в базе
)}
{conflicts.length === 0 ? (
Конфликтов нет — существующие данные совпадают.
) : (
Конфликты — отметьте, где данные актуальнее
{conflicts.map((c, i) => (
№{c.num} · {c.mon}
{(c.db && c.db.text) || (c.file && c.file.text) || ""}
{["db", "file"].map(side => {
const sel = choices[i] === side;
const obj = side === "db" ? c.db : c.file;
return (
pick(i, side)}
style={{ padding: "8px 10px", cursor: "pointer", borderLeft: side === "file" ? "1px solid var(--ink-100)" : "none", background: sel ? "var(--accent-soft)" : "transparent" }}>
pick(i, side)} /> {side === "db" ? "В базе" : "Из файла"}
{c.fields.map(f => (
{FIELD[f] || f}:
{cellVal(obj, f)}
))}
);
})}
))}
)}
{err &&
{err}
}
Будет применено: {(includeNew ? news.length : 0) + fileWins} · без изменений {prev.same}
Отмена
{busy ? "Применение…" : "Применить"}
);
}
// ---- New / edit order modal ----
function NewOrderModal({ month, task, onClose, onCreate, onSave }) {
const editing = !!task;
const toInput = (ru) => {
const m = (ru || "").match(/(\d{2})\.(\d{2})\.(\d{4})/);
return m ? `${m[3]}-${m[2]}-${m[1]}` : "";
};
const [text, setText] = React.useState(editing ? task.text : "");
const dir = useDirectory();
const peopleMap = dir.people;
const [assignee, setAssignee] = React.useState(editing ? task.assignee : (Object.keys(peopleMap)[0] || ""));
const [due, setDue] = React.useState(editing ? toInput(task.dateEnd) : "");
const [sendNow, setSendNow] = React.useState(!editing);
const person = peopleMap[assignee] || PEOPLE[assignee] || personFor(assignee, "");
const comboOptions = Object.keys(peopleMap).map(k => ({ value: k, label: k, sub: peopleMap[k].dept }));
if (editing && assignee && !peopleMap[assignee]) comboOptions.unshift({ value: assignee, label: assignee });
const submit = () => {
if (!text.trim()) return;
const ru = due ? due.split("-").reverse().join(".") : "";
if (editing) {
onSave(task.id, { text: text.trim(), assignee, dateEnd: ru });
} else {
onCreate({ text: text.trim(), assignee, due: ru || null, sendNow });
}
};
return (
e.stopPropagation()}>
{editing ? `Редактировать поручение №${task.num}` : "Новое поручение"}
{editing ? "Изменения сохранятся в реестре" : `В реестр «${month}» · после создания будет отправлено исполнителю в Telegram`}
Формулировка поручения
setText(p => (p ? p.trimEnd() + " " : "") + t)} />
{!editing && (
{person.name}
{person.tg}
Отправить в Telegram сразу
)}
Отмена
{editing ? <> Сохранить> : <> Создать и назначить>}
);
}
// ---- Telegram bot config modal (dynamic token, long-polling) ----
function TelegramConfigModal({ onClose, onSaved }) {
const [info, setInfo] = React.useState({ configured: false, running: false, username: "", chats: [] });
const [token, setToken] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState("");
React.useEffect(() => {
if (window.API && window.API.telegramStatus) {
window.API.telegramStatus().then(setInfo).catch(() => {});
}
}, []);
const chatSet = new Set((info.chats || []).map(c => (c.username || "").toLowerCase()));
const people = Object.keys(PEOPLE).map(k => {
const uname = (PEOPLE[k].tg || "").replace(/^@/, "").toLowerCase();
return { name: k, tg: PEOPLE[k].tg, registered: chatSet.has(uname) };
});
const regCount = people.filter(p => p.registered).length;
const connect = async () => {
if (!token.trim() || !window.API) return;
setBusy(true); setError("");
try {
const st = await window.API.telegramConfig(token.trim());
setInfo(st);
setToken("");
onSaved && onSaved(st);
} catch (e) {
setError(String((e && e.message) || e));
} finally {
setBusy(false);
}
};
return (
e.stopPropagation()}>
Telegram-бот
{info.configured
? <>Подключён{info.username ? <> как @{info.username} > : null} · чатов: {regCount}/{people.length}>
: "Не подключён — вставьте токен от @BotFather"}
Токен бота
setToken(e.target.value)}
onKeyDown={e => { if (e.key === "Enter") connect(); }}
/>
{busy ? "Проверка…" : info.configured ? "Обновить" : "Подключить"}
{error &&
{error}
}
Режим long-polling: токен задаётся здесь, в рантайме. Чтобы исполнитель получал поручения,
он должен один раз написать боту /start — так бот узнаёт его chat id.
Исполнители · {regCount}/{people.length} подключены
{people.map(p => (
{p.name} {p.tg}
{p.registered
? /start выполнен
: ждёт /start }
))}
Закрыть
);
}
window.PorucheniyaPage = PorucheniyaPage;