// «Контроль исполнения поручений» — 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)} поручений · АО «Юридический центр»
{yearMenu && (
setYearMenu(false)}> {YEARS.map(y => ( ))}
)}
setTgCfgOpen(true)} title="Настройки Telegram-бота">
Telegram-бот
{tgInfo.configured ? <> подключён{tgInfo.username ? ` · @${tgInfo.username}` : ""} : <>● не подключён · настроить}
{months.map(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} />
{ setQuery(e.target.value); if (e.target.value.trim()) setYearScope(true); }} />
{[["all","Все"],["overdue","Просрочено"],["sent","Ждут"],["in_progress","В работе"],["done","Выполнено"]].map(([k,l]) => ( ))}
{anyFilter && ( )}
{exportOpen && (
setExportOpen(false)}>
)}
{canEdit && ( <> )} {canEdit && }
{(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 && }
) : 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 (
{n}
{icon} {label}
); } // 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)} />}
{t.assignee}
{p.tg}
срок
{due}
); } // ---- 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}».
)}
); } // ---- 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 && ( )} {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" }}> {c.fields.map(f => (
{FIELD[f] || f}: {cellVal(obj, f)}
))}
); })}
))}
)} {err &&
{err}
}
Будет применено: {(includeNew ? news.length : 0) + fileWins} · без изменений {prev.same}
); } // ---- 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)} />