// «Зарплаты» — доходы + штрафы (Требование 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 && 1С }
setPeriod(e.target.value)} title="Период ФОТ">
Текущий срез
{periods.map(p => {p} · из 1С )}
{period && !periods.includes(period) && {period} }
{canManage && setImportOpen(true)}> Импорт из 1С }
{canManage && exportDeductions(false)} title="Excel утверждённых удержаний за период — для загрузки в ЗУП"> Удержания → 1С }
setCreateOpen(true)}> Подготовить взыскание
} />
} />
} />
} />
Сотрудник
Начислено
Удержание
К выплате
{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)}
setCreateOpen(r.person)}>Взыскание
))}
{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.
Колонки: Таб. номер · ФИО · Период · Оклад · Надбавки · Начислено · К выплате · Ставка · Часы · Даты аванса/расчёта
(порядок и формулировки не критичны).
Скачать шаблон-образец ·
обработка для 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}` : ""}
Закрыть
{busy === "apply" ? "Запись…" : "Применить выбранное"}
);
}
// 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()}>
Подготовить взыскание По факту просрочки. Решение — за руководителем.
Сотрудник
Эскалация: 1 → −25%, 2 → −35%, 3 → −50%, 4 → рекомендация к увольнению. Предложено по числу невыполненных за период.
Основание
{err &&
{err}
}
Отмена
Подготовить
);
}
// FineModal — процедура по Правилам: затребование объяснений → решение/снятие.
function FineModal({ fine, onClose, onChanged }) {
const [expl, setExpl] = React.useState((fine && fine.explanationText) || "");
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState("");
const fileRef = React.useRef(null);
if (!fine) return null;
const st = FINE_STATUS[fine.status] || { label: fine.status, cls: "muted" };
const act = async (fn, ok) => {
setBusy(true); setErr("");
try { await fn(); onChanged(); ok && ok(); }
catch (e) { setErr(String((e && e.message) || e)); setBusy(false); }
};
const saveExpl = () => act(() => window.API.fineExplanation(fine.id, expl));
const request = () => act(() => window.API.fineRequestExplanation(fine.id), onClose);
const approve = () => { if (window.confirm("Утвердить взыскание? Это решение руководителя (п.13.5).")) act(() => window.API.approveFine(fine.id), onClose); };
const remove = () => {
const reason = window.prompt("Основание для снятия взыскания:");
if (reason == null) return;
act(() => window.API.removeFine(fine.id, reason), onClose);
};
const upload = async (e) => {
const files = e.target.files; e.target.value = "";
if (!files || !files.length) return;
await act(() => window.API.uploadFineFiles(fine.id, files));
};
return (
e.stopPropagation()}>
Взыскание · {fine.person}
{FINE_KIND[fine.kind] || fine.kind} · период {fine.period} · просрочек {fine.overdueCount}
{st.label}
{fine.kind === "dismissal"
?
4-я просрочка — сформирована рекомендация к увольнению . Решение принимает Руководитель Центра (п.13.5); система лишь сигнализирует.
:
Размер: −{fine.percent}%
{fine.amount > 0 &&
Сумма: {money(fine.amount)}
}
}
{fine.basis &&
Основание: {fine.basis}
}
Объяснительная работника
{fine.requestedAt &&
Объяснения затребованы: {fine.requestedAt} (отказ дать объяснения не препятствует — п.13.6)
}
{fine.decidedBy &&
Решение: {fine.decidedBy} · {fine.decidedAt}
}
{fine.removedReason &&
Снято: {fine.removedReason}
}
{err &&
{err}
}
{fine.status === "prepared" && fine.kind !== "dismissal" &&
Затребовать объяснения }
{fine.status === "explanation_requested" &&
Утвердить взыскание }
{fine.status !== "removed" &&
Снять }
);
}
window.SalariesPage = SalariesPage;