// «Зарплаты» — доходы + штрафы (Требование 3) и модуль-подготовщик взысканий (Модуль 5). const FINE_STATUS = { prepared: { label: "Подготовлено", cls: "muted" }, explanation_requested: { label: "Затребованы объяснения", cls: "tg" }, approved: { label: "Утверждено", cls: "err" }, removed: { label: "Снято", cls: "ok" }, dismissal_recommended: { label: "Рекомендация к увольнению", cls: "err" }, }; const FINE_KIND = { fine: "Штраф", reprimand: "Выговор", dismissal: "Увольнение" }; const ESCALATION_PREVIEW = { 1: "−25%", 2: "−35%", 3: "−50%" }; function money(n, cur) { return Math.round(n || 0).toLocaleString("ru-RU") + " " + (cur || "₽"); } // prevPeriod — прошлый месяц в формате MM.YYYY (дефолт для импорта из 1С). function prevPeriod() { const d = new Date(); d.setMonth(d.getMonth() - 1); return String(d.getMonth() + 1).padStart(2, "0") + "." + d.getFullYear(); } function SalariesPage() { const [data, setData] = React.useState(null); const [fines, setFines] = React.useState([]); const [monitor, setMonitor] = React.useState({ rows: [] }); const [createOpen, setCreateOpen] = React.useState(false); const [fineOpen, setFineOpen] = React.useState(null); const [profileFor, setProfileFor] = React.useState(null); const [period, setPeriod] = React.useState(""); // "" = текущий срез; MM.YYYY = данные 1С const [importOpen, setImportOpen] = React.useState(false); const load = React.useCallback(() => { if (!window.API) return; Promise.all([ window.API.payroll(period).catch(() => ({ rows: [] })), window.API.listFines().catch(() => []), window.API.monitoring().catch(() => ({ rows: [] })), ]).then(([pay, fn, mon]) => { setData(pay); setFines(Array.isArray(fn) ? fn : []); setMonitor(mon || { rows: [] }); }); }, [period]); React.useEffect(() => { load(); }, [load]); const canManage = window.ME && (window.ME.role === "admin" || window.ME.role === "controller"); // Выгрузка утверждённых удержаний за период в файл для ЗУП (идемпотентно). const exportDeductions = async (force) => { let p = period; if (!p) { const finePeriods = [...new Set(fines.filter(f => f.status === "approved").map(f => f.period))]; p = window.prompt("За какой период выгрузить удержания? (ММ.ГГГГ)", finePeriods[0] || prevPeriod()); if (!p) return; } try { const blob = await window.API.payrollDeductionsExport(p.trim(), force); downloadBlob(blob, `Удержания_${p.trim()}.xlsx`); load(); } catch (e) { if (e && e.alreadyExported) { if (window.confirm(String(e.message) + "\n\nВыгрузить повторно?")) return exportDeductions(true); return; } alert(String((e && e.message) || e)); } }; if (!data) { return (

Зарплаты

Доходы, удержания и взыскания

); } const rows = (data.rows || []).slice().sort((a, b) => b.net - a.net); const cur = (rows[0] && rows[0].currency) || "₽"; const totalGross = rows.reduce((s, r) => s + r.gross, 0); const totalDeduct = rows.reduce((s, r) => s + r.deduction, 0); const totalNet = rows.reduce((s, r) => s + r.net, 0); const activeFines = fines.filter(f => f.status !== "removed"); const monByPerson = {}; (monitor.rows || []).forEach(m => { monByPerson[m.person] = m; }); const periods = data.periods || []; const from1C = data.source === "1c"; const sourceLabel = period ? (from1C ? `данные из 1С за ${period} (read-only)` : `за ${period} данных нет — импортируйте из 1С`) : "текущий срез (ручной/демо — до импорта из 1С)"; return (

Зарплаты и удержания

Доходы и штрафы · вычет не более {data.maxDeductionPct}% в месяц (п.13.16) · {sourceLabel} {from1C && }

{canManage && } {canManage && }
} /> } /> } /> } />
Сотрудник
Начислено
Удержание
К выплате
{rows.map(r => (
setProfileFor(r.person)}>{r.person}
{r.dept || "—"}
{money(r.gross, r.currency)}
оклад {money(r.base, r.currency)}{r.accruals ? ` + ${money(r.accruals, r.currency)}` : ""}
{(r.tariffRate > 0 || r.hoursWorked > 0) && (
{r.tariffRate > 0 ? `ставка ${money(r.tariffRate, r.currency)}/ч` : ""}{r.tariffRate > 0 && r.hoursWorked > 0 ? " · " : ""}{r.hoursWorked > 0 ? `${r.hoursWorked} ч` : ""}
)}
{r.deduction ? `− ${money(r.deduction, r.currency)}` : "—"} {r.deductionPct > 0 &&
{r.deductionPct}%
}
{money(r.net, r.currency)}
))} {rows.length === 0 && (
{period ? <>За период {period} данных ФОТ нет.{canManage ? " Нажмите «Импорт из 1С» и загрузите выгрузку за этот период." : ""} : "Нет данных по зарплате."}
)}
{fines.length === 0 &&
Взысканий нет.
} {fines.map(f => { const st = FINE_STATUS[f.status] || { label: f.status, cls: "muted" }; return (
setFineOpen(f.id)}>
{f.person} {FINE_KIND[f.kind] || f.kind} {f.percent > 0 && −{f.percent}%}
период {f.period} · просрочек: {f.overdueCount}{f.basis ? ` · ${f.basis}` : ""}{f.files && f.files.length ? ` · 📎 ${f.files.length}` : ""}
{f.exportedAt && → 1С} {st.label}
); })}
{createOpen && r.person)} monByPerson={monByPerson} onClose={() => setCreateOpen(false)} onCreated={() => { setCreateOpen(false); load(); }} />} {fineOpen != null && f.id === fineOpen)} onClose={() => setFineOpen(null)} onChanged={load} />} {profileFor && setProfileFor(null)} />} {importOpen && setImportOpen(false)} onApplied={(p) => { setImportOpen(false); setPeriod(p); }} />}
); } // Payroll1CImportModal — импорт ФОТ из выгрузки 1С:ЗУП (Excel/CSV): период + файл → // превью-диф (новые / изменённые / несопоставленные / без изменений) → применить. function Payroll1CImportModal({ defaultPeriod, onClose, onApplied }) { const [period, setPeriod] = React.useState(defaultPeriod); const [file, setFile] = React.useState(null); const [preview, setPreview] = React.useState(null); const [sel, setSel] = React.useState({}); // ключ строки превью → выбрана ли к применению const [busy, setBusy] = React.useState(""); const [err, setErr] = React.useState(""); const [result, setResult] = React.useState(null); const fileRef = React.useRef(null); const validP = /^(0[1-9]|1[0-2])\.\d{4}$/.test((period || "").trim()); const doPreview = async () => { if (!file) { setErr("Выберите файл выгрузки из 1С (.xlsx или .csv)"); return; } if (!validP) { setErr("Период — в формате ММ.ГГГГ, напр. 06.2026"); return; } setBusy("preview"); setErr(""); setResult(null); try { const p = await window.API.payrollImportPreview(file, period.trim()); setPreview(p); const s = {}; (p.new || []).forEach((_, i) => { s["n" + i] = true; }); (p.changed || []).forEach((_, i) => { s["c" + i] = true; }); setSel(s); } catch (e) { setErr(String((e && e.message) || e)); } finally { setBusy(""); } }; const apply = async () => { const rows = []; (preview.new || []).forEach((r, i) => { if (sel["n" + i]) rows.push({ record: r.record, person: r.person }); }); (preview.changed || []).forEach((r, i) => { if (sel["c" + i]) rows.push({ record: r.record, person: r.person }); }); if (!rows.length) { setErr("Не выбрано ни одной строки"); return; } setBusy("apply"); setErr(""); try { const res = await window.API.payrollImportApply(period.trim(), rows); setResult(res); if (!res.errors || !res.errors.length) setTimeout(() => onApplied(period.trim()), 900); } catch (e) { setErr(String((e && e.message) || e)); } finally { setBusy(""); } }; const rowLine = (r, key, extra) => (
setSel(s => ({ ...s, [key]: v }))} />
{r.person} ({r.matchedBy}{r.record.externalId ? ` · таб. ${r.record.externalId}` : ""})
оклад {money(r.record.base)} {r.record.accruals ? `+ надбавки ${money(r.record.accruals)}` : ""} · начислено {money(r.record.gross)} · к выплате по 1С {money(r.record.net)}
{extra}
); return (
e.stopPropagation()}>

Импорт ФОТ из 1С

Выгрузка ЗУП (Excel/CSV) → превью → применить. 1С — источник истины, данные будут read-only.

setPeriod(e.target.value)} />
{ setFile(e.target.files && e.target.files[0]); setPreview(null); }} />
Колонки: Таб. номер · ФИО · Период · Оклад · Надбавки · Начислено · К выплате · Ставка · Часы · Даты аванса/расчёта (порядок и формулировки не критичны). Скачать шаблон-образец · обработка для 1С — в docs/1c/.
{preview && ( <>
новых: {(preview.new || []).length} изменённых: {(preview.changed || []).length} несопоставленных: {(preview.unmatched || []).length} без изменений: {preview.unchanged || 0}
{(preview.new || []).length > 0 && (
Новые записи
{(preview.new || []).map((r, i) => rowLine(r, "n" + i))}
)} {(preview.changed || []).length > 0 && (
Изменённые (файл заменит данные CRM)
{(preview.changed || []).map((r, i) => rowLine(r, "c" + i,
изменилось: {(r.fields || []).join(", ")}
))}
)} {(preview.unmatched || []).length > 0 && (
Несопоставленные (не будут записаны)
{(preview.unmatched || []).map((r, i) => (
{r.record.fullName}{r.record.externalId ? ` (таб. ${r.record.externalId})` : ""} — {r.reason}
))}
Добавьте сотрудника в CRM или проставьте таб. номер 1С в его карточке — и повторите превью.
)} )} {result && (
✓ Записано строк: {result.applied}{result.externalIdSet ? ` · проставлено таб. номеров: ${result.externalIdSet}` : ""} {result.errors && result.errors.length > 0 &&
{result.errors.join("; ")}
}
)} {err &&
{err}
}
{preview ? `В файле строк: ${preview.total}` : ""}
); } // CreateFineModal — подготовить взыскание по факту просрочки. function CreateFineModal({ person, people, monByPerson, onClose, onCreated }) { const [who, setWho] = React.useState(person || (people[0] || "")); const suggested = (monByPerson[who] && monByPerson[who].notDone) || 1; const [count, setCount] = React.useState(String(suggested)); const [basis, setBasis] = React.useState(""); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(""); React.useEffect(() => { setCount(String((monByPerson[who] && monByPerson[who].notDone) || 1)); }, [who]); const n = parseInt(count, 10) || 0; const preview = n >= 4 ? "рекомендация к увольнению" : (ESCALATION_PREVIEW[n] || "—"); const options = (people || []).map(p => ({ value: p, label: p })); const submit = async () => { if (!who || n < 1) { setErr("Укажите сотрудника и число просрочек ≥ 1"); return; } setBusy(true); setErr(""); try { await window.API.createFine({ person: who, overdueCount: n, basis: basis.trim() }); onCreated(); } catch (e) { setErr(String((e && e.message) || e)); setBusy(false); } }; return (
e.stopPropagation()}>

Подготовить взыскание

По факту просрочки. Решение — за руководителем.

setCount(e.target.value)} />
= 4 ? "var(--err)" : "var(--ink-700)", fontWeight: 600 }}>{preview}
Эскалация: 1 → −25%, 2 → −35%, 3 → −50%, 4 → рекомендация к увольнению. Предложено по числу невыполненных за период.