Corp/frontend/src/components/finance/admin/PayrollPeriodTable.tsx

1473 lines
79 KiB
TypeScript
Raw 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 client";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
type DragEndEvent,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { CircleHelp, Eye, EyeOff, GripVertical, Loader2, Pencil, RotateCcw, Search, X } from "lucide-react";
import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { PayrollPointBreakdownModal } from "@/components/finance/admin/PayrollPointBreakdownModal";
import {
PayrollCellCommentsModal,
type PayrollCommentCell,
} from "@/components/finance/admin/PayrollCellCommentsModal";
import { useMiddleMousePan } from "@/hooks/useMiddleMousePan";
import type {
PayrollPeriodTable,
PayrollPeriodTableComponent,
PayrollPeriodTableRow,
PayrollPeriodTableStatus,
} from "@/lib/api/finance";
import { formatPayrollMoney } from "@/lib/payroll";
import { getPayrollAdminError } from "@/lib/payroll-admin";
import { movePayrollColumn, normalizePayrollColumnOrder } from "@/lib/payroll-columns";
import { comparePayrollProjection, type PayrollProjectionDifference } from "@/lib/payroll-projection";
const statusMeta: Record<PayrollPeriodTableStatus, { label: string; className: string }> = {
calculated: { label: "Рассчитано", className: "app-feedback-success" },
ready: { label: "Готово", className: "app-selected" },
draft: { label: "Есть черновики", className: "app-feedback-warning" },
incomplete: { label: "Не заполнено", className: "app-feedback-danger" },
};
const kindLabels = {
earning: "Начисление",
adjustment_credit: "Корректировка +",
adjustment_debit: "Корректировка −",
deduction: "Удержание",
payment: "Выплата",
};
const headerCellClass = "app-surface-muted overflow-hidden border-b border-r border-[var(--border-subtle)] px-2 py-2.5 text-left text-[10px] font-semibold text-[var(--muted-foreground)]";
const bodyCellClass = "overflow-hidden whitespace-normal border-b border-r border-[var(--border-subtle)] px-1.5 py-2 text-right text-[11px] leading-tight text-[var(--foreground)] tabular-nums";
const HIDDEN_COLUMNS_STORAGE_PREFIX = "finances.payroll.period-table.hidden-columns";
const HIDDEN_COLUMNS_CHANGE_EVENT = "payroll-period-table-hidden-columns-change";
const COLUMN_ORDER_STORAGE_PREFIX = "finances.payroll.period-table.column-order";
const COLUMN_ORDER_CHANGE_EVENT = "payroll-period-table-column-order-change";
const HIDDEN_ROWS_STORAGE_PREFIX = "finances.payroll.period-table.hidden-rows";
const HIDDEN_ROWS_CHANGE_EVENT = "payroll-period-table-hidden-rows-change";
const columnIds = {
rateAmount: "rate_amount",
inNormPointRate: "in_norm_point_rate",
excessPointRate: "point_rate",
targetPoints: "target_points",
attendancePoints: "attendance_points",
personnelPoints: "personnel_points",
actualPoints: "actual_points",
pointDelta: "point_delta",
pointAmount: "point_amount",
grossBeforeAdjustments: "gross_before_adjustments",
adjustments: "adjustment_total",
gross: "gross_total",
deductions: "deduction_total",
net: "net_pay",
payments: "payment_total",
payable: "payable",
carryovers: "carryovers",
status: "status",
} as const;
type ColumnDefinition = {
id: string;
label: string;
group: string;
width: number;
};
type ColumnGroupSegment = {
group: string;
columns: ColumnDefinition[];
};
function buildColumnGroupSegments(columns: ColumnDefinition[]): ColumnGroupSegment[] {
return columns.reduce<ColumnGroupSegment[]>((segments, column) => {
const lastSegment = segments.at(-1);
if (lastSegment?.group === column.group) lastSegment.columns.push(column);
else segments.push({ group: column.group, columns: [column] });
return segments;
}, []);
}
const salaryColumns: ColumnDefinition[] = [
{ id: columnIds.rateAmount, label: "Оклад", group: "Оклад и баллы", width: 75 },
{ id: columnIds.inNormPointRate, label: "Цена балла в норме", group: "Оклад и баллы", width: 75 },
{ id: columnIds.excessPointRate, label: "Цена балла сверх нормы", group: "Оклад и баллы", width: 75 },
];
const workColumns: ColumnDefinition[] = [
{ id: columnIds.targetPoints, label: "Норма", group: "Выработка", width: 55 },
{ id: columnIds.attendancePoints, label: "Баллы по посещаемости", group: "Выработка", width: 75 },
{ id: columnIds.personnelPoints, label: "Баллы по кадровым событиям", group: "Выработка", width: 82 },
{ id: columnIds.actualPoints, label: "Баллы", group: "Выработка", width: 55 },
{ id: columnIds.pointDelta, label: "Отклонение", group: "Выработка", width: 55 },
{ id: columnIds.pointAmount, label: "По баллам", group: "Выработка", width: 75 },
];
const calculationColumns: ColumnDefinition[] = [
{ id: columnIds.grossBeforeAdjustments, label: "До корректировок", group: "Расчёт", width: 80 },
{ id: columnIds.adjustments, label: "Корректировки", group: "Расчёт", width: 80 },
{ id: columnIds.gross, label: "Начислено", group: "Расчёт", width: 80 },
{ id: columnIds.deductions, label: "Удержано", group: "Расчёт", width: 80 },
{ id: columnIds.net, label: "После удержаний", group: "Расчёт", width: 80 },
{ id: columnIds.payments, label: "Выплачено", group: "Расчёт", width: 80 },
{ id: columnIds.payable, label: "К выплате", group: "Расчёт", width: 80 },
];
const statusColumn: ColumnDefinition = {
id: columnIds.status,
label: "Состояние",
group: "Состояние",
width: 90,
};
const carryoversColumn: ColumnDefinition = {
id: columnIds.carryovers,
label: "Долги / доплаты",
group: "Переносы",
width: 100,
};
function componentColumnId(code: string): string {
return `component:${code}`;
}
function parseHiddenColumns(value: string): Set<string> {
try {
const parsed: unknown = JSON.parse(value);
return new Set(Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : []);
} catch {
return new Set();
}
}
function parseColumnOrder(value: string): string[] {
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string")
: [];
} catch {
return [];
}
}
function useHiddenPayrollColumns(userId?: number | null) {
const storageKey = `${HIDDEN_COLUMNS_STORAGE_PREFIX}.${userId || "default"}`;
const subscribe = useCallback((notify: () => void) => {
const handleStorage = (event: StorageEvent) => {
if (event.key === storageKey) notify();
};
window.addEventListener("storage", handleStorage);
window.addEventListener(HIDDEN_COLUMNS_CHANGE_EVENT, notify);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(HIDDEN_COLUMNS_CHANGE_EVENT, notify);
};
}, [storageKey]);
const getSnapshot = useCallback(
() => window.localStorage.getItem(storageKey) || "[]",
[storageKey],
);
const serialized = useSyncExternalStore(subscribe, getSnapshot, () => "[]");
const hiddenColumns = useMemo(() => parseHiddenColumns(serialized), [serialized]);
const setHiddenColumns = useCallback((next: Set<string>) => {
window.localStorage.setItem(storageKey, JSON.stringify([...next].sort()));
window.dispatchEvent(new Event(HIDDEN_COLUMNS_CHANGE_EVENT));
}, [storageKey]);
return [hiddenColumns, setHiddenColumns] as const;
}
function usePayrollColumnOrder(userId?: number | null) {
const storageKey = `${COLUMN_ORDER_STORAGE_PREFIX}.${userId || "default"}`;
const subscribe = useCallback((notify: () => void) => {
const handleStorage = (event: StorageEvent) => {
if (event.key === storageKey) notify();
};
window.addEventListener("storage", handleStorage);
window.addEventListener(COLUMN_ORDER_CHANGE_EVENT, notify);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(COLUMN_ORDER_CHANGE_EVENT, notify);
};
}, [storageKey]);
const getSnapshot = useCallback(
() => window.localStorage.getItem(storageKey) || "[]",
[storageKey],
);
const serialized = useSyncExternalStore(subscribe, getSnapshot, () => "[]");
const columnOrder = useMemo(() => parseColumnOrder(serialized), [serialized]);
const setColumnOrder = useCallback((next: string[]) => {
window.localStorage.setItem(storageKey, JSON.stringify(next));
window.dispatchEvent(new Event(COLUMN_ORDER_CHANGE_EVENT));
}, [storageKey]);
return [columnOrder, setColumnOrder] as const;
}
function useHiddenPayrollRows(userId?: number | null) {
const storageKey = `${HIDDEN_ROWS_STORAGE_PREFIX}.${userId || "default"}`;
const subscribe = useCallback((notify: () => void) => {
const handleStorage = (event: StorageEvent) => {
if (event.key === storageKey) notify();
};
window.addEventListener("storage", handleStorage);
window.addEventListener(HIDDEN_ROWS_CHANGE_EVENT, notify);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(HIDDEN_ROWS_CHANGE_EVENT, notify);
};
}, [storageKey]);
const getSnapshot = useCallback(
() => window.localStorage.getItem(storageKey) || "[]",
[storageKey],
);
const serialized = useSyncExternalStore(subscribe, getSnapshot, () => "[]");
const hiddenRows = useMemo(() => parseHiddenColumns(serialized), [serialized]);
const setHiddenRows = useCallback((next: Set<string>) => {
window.localStorage.setItem(storageKey, JSON.stringify([...next].sort()));
window.dispatchEvent(new Event(HIDDEN_ROWS_CHANGE_EVENT));
}, [storageKey]);
return [hiddenRows, setHiddenRows] as const;
}
type CalculationHelp = {
title: string;
logic: string;
formula: string;
manual: string;
};
function ColumnHeader({
children,
label,
help,
onOpen,
onBulkEdit,
onHide,
dragHandle,
}: {
children: ReactNode;
label: string;
help?: CalculationHelp;
onOpen: (help: CalculationHelp) => void;
onBulkEdit?: () => void;
onHide: () => void;
dragHandle?: ReactNode;
}) {
return (
<span className="min-w-0 whitespace-normal break-words">
{children}{" "}
<span className="inline-flex translate-y-0.5 items-center gap-0.5 whitespace-nowrap align-baseline">
{help ? (
<button
type="button"
className="app-accent-text inline-flex h-4 w-4 items-center justify-center rounded-full transition hover:bg-[var(--surface-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
onClick={() => onOpen(help)}
aria-label={`Как рассчитывается «${help.title}»`}
title="Показать логику расчёта"
>
<CircleHelp size={13} strokeWidth={2} />
</button>
) : null}
{onBulkEdit ? (
<button
type="button"
className="app-text-muted inline-flex h-4 w-4 items-center justify-center rounded transition hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
onClick={onBulkEdit}
aria-label={`Массово заполнить колонку «${label}»`}
title="Массовое заполнение"
>
<Pencil size={12} strokeWidth={2} />
</button>
) : null}
<button
type="button"
className="app-text-muted inline-flex h-4 w-4 items-center justify-center rounded transition hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
onClick={onHide}
aria-label={`Скрыть колонку «${label}»`}
title="Скрыть колонку"
>
<Eye size={12} strokeWidth={2} />
</button>
{dragHandle}
</span>
</span>
);
}
function SortableColumnHeader({
column,
children,
}: {
column: ColumnDefinition;
children: (dragHandle: ReactNode) => ReactNode;
}) {
const {
attributes,
isDragging,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: column.id });
return (
<th
ref={setNodeRef}
className={`${headerCellClass} whitespace-normal text-right ${isDragging ? "z-40 opacity-60" : ""}`}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
>
{children(
<button
type="button"
className="app-text-muted inline-flex h-4 w-4 shrink-0 cursor-grab touch-none items-center justify-center rounded transition hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)] active:cursor-grabbing focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
{...attributes}
{...listeners}
aria-label={`Переместить колонку «${column.label}»`}
title="Перетащите, чтобы изменить порядок"
>
<GripVertical size={12} strokeWidth={2} />
</button>,
)}
</th>
);
}
function CalculationHelpModal({ help, rules, onClose }: { help: CalculationHelp; rules: PayrollPeriodTable["calculation_rules"]; onClose: () => void }) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
closeButtonRef.current?.focus();
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("keydown", closeOnEscape);
previouslyFocused?.focus();
};
}, [onClose]);
if (typeof document === "undefined") return null;
const roundingLabel = rules.rounding === "ROUND_HALF_UP"
? "математическое"
: rules.rounding === "ROUND_HALF_EVEN"
? "банковское"
: "вниз";
return createPortal(
<div className="fixed inset-0 z-[1000] flex items-center justify-center p-4" role="presentation">
<button type="button" className="absolute inset-0 bg-black/60" onClick={onClose} aria-label="Закрыть пояснение" />
<section className="app-surface relative z-10 w-full max-w-lg rounded-2xl border border-[var(--border-subtle)] p-5 shadow-2xl" role="dialog" aria-modal="true" aria-labelledby="payroll-formula-title">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="app-text-muted text-xs uppercase tracking-wide">Логика расчёта</p>
<h3 id="payroll-formula-title" className="mt-1 text-lg font-semibold text-[var(--foreground)]">{help.title}</h3>
</div>
<button ref={closeButtonRef} type="button" className="app-action-ghost inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg" onClick={onClose} aria-label="Закрыть"><X size={17} /></button>
</div>
<div className="mt-5 space-y-4 text-sm">
<div>
<p className="font-semibold text-[var(--foreground)]">Как формируется</p>
<p className="app-text-muted mt-1 leading-relaxed">{help.logic}</p>
</div>
<div>
<p className="font-semibold text-[var(--foreground)]">Формула</p>
<div className="app-surface-muted mt-2 whitespace-pre-wrap rounded-xl px-3 py-2.5 font-mono text-xs leading-relaxed text-[var(--foreground)]">{help.formula}</div>
</div>
<div>
<p className="font-semibold text-[var(--foreground)]">Можно изменить вручную?</p>
<p className="app-text-muted mt-1 leading-relaxed">{help.manual}</p>
</div>
</div>
<p className="app-text-muted mt-5 border-t border-[var(--border-subtle)] pt-3 text-xs leading-relaxed">Правила {rules.ruleset_id}, версия {rules.version}. Денежные строки округляются до {rules.money_quantum} ({roundingLabel} округление).</p>
</section>
</div>,
document.body,
);
}
function calculationHelps(pointPolicy: PayrollPeriodTable["calculation_rules"]["point_policy"]): Record<string, CalculationHelp> {
const pointRecalculationLogic = pointPolicy === "proportional_with_excess"
? "Повторяет исходную Excel-таблицу: баллы изменяют общую базу из оклада и премии. Недовыполнение даёт отрицательный перерасчёт, перевыполнение — положительный."
: pointPolicy === "excess_only"
? "Недовыполнение нормы не уменьшает начисление; оплачивается только превышение."
: "Перерасчёт по баллам отключён текущими правилами.";
const pointRecalculationFormula = pointPolicy === "proportional_with_excess"
? "Если цена сверх нормы пустая:\nПо баллам = ((Оклад + Премия) ÷ Норма × Баллы) − (Оклад + Премия)\n\nЕсли цена задана и Баллы > Норма:\nПо баллам = (Баллы − Норма) × Цена сверх нормы"
: pointPolicy === "excess_only"
? "По баллам = max(Баллы − Норма, 0) × Цена балла сверх нормы"
: "По баллам = 0";
return {
inNormPointRate: {
title: "Цена балла в норме",
logic: "Расчётная стоимость одного балла в исходной Excel-формуле. В балльную базу входят оклад и премия.",
formula: "Цена балла в норме = (Оклад + Премия) ÷ Норма",
manual: "Нет. Значение меняется через оклад, премию или норму баллов.",
},
excessPointRate: {
title: "Цена балла сверх нормы",
logic: pointPolicy === "disabled" ? "При текущем правиле балльная доплата отключена, поэтому цена не участвует в расчёте." : "Если отдельная цена не заполнена, система автоматически использует цену балла в норме. Можно задать индивидуальную цену.",
formula: "Цена сверх нормы = указанная цена\nесли пусто: (Оклад + Премия) ÷ Норма",
manual: "Да. Пустая ячейка возвращает автоматический расчёт по цене балла в норме.",
},
targetPoints: {
title: "Норма",
logic: "По умолчанию норма берётся из рабочего графика. Посещаемость определяет фактические рабочие дни, но часы не становятся баллами напрямую.",
formula: "Норма = Дневная норма баллов × Рабочие дни",
manual: "Да. Ручное значение заменяет автоматическую норму; очистка ячейки возвращает расчёт по графику.",
},
actualPoints: {
title: "Баллы",
logic: "Фактическую выработку можно ввести вручную или рассчитать из посещаемости. Полная смена даёт дневную норму баллов; неполная смена и переработка учитываются пропорционально.",
formula: "Баллы дня = Дневная норма × Отработанные часы ÷ Плановые часы\nБаллы периода = Σ баллов рабочих дней",
manual: "Да. Значение можно изменить в таблице или пересчитать из посещаемости.",
},
attendancePoints: {
title: "Баллы по посещаемости",
logic: "Вычисляются на ходу от нормы баллов сотрудника за период и доступных корректных записей посещаемости. Полная смена даёт дневную долю нормы, неполная смена и переработка учитываются пропорционально. Если данных посещаемости нет, показывается прочерк.",
formula: "Дневная доля нормы = Норма баллов ÷ Плановые рабочие дни\nБаллы дня = Дневная доля нормы × Отработанные часы ÷ Плановые часы\nБаллы по посещаемости = Σ баллов доступных рабочих дней",
manual: "Нет. Значение меняется через норму баллов или исходные данные посещаемости.",
},
personnelPoints: {
title: "Баллы по кадровым событиям",
logic: "Это накопительный показатель на начало текущего дня. Он вычисляется на ходу от нормы баллов сотрудника, рабочего графика и официальных кадровых событий. До первого приёма, после увольнения и до повторного приёма баллы не начисляются; день приёма или восстановления уже учитывается. Текущий и будущие дни не входят в расчёт. Отпуск, больничный, отгул и декрет дают 0 баллов; удалённая работа считается присутствием.",
formula: "Баллы по кадровым событиям = Норма баллов × Завершённые рабочие дни без подтверждённого отсутствия ÷ Все плановые рабочие дни периода",
manual: "Нет. Значение меняется через норму баллов, заявление, кадровое событие или рабочий график.",
},
pointDelta: {
title: "Отклонение",
logic: "Показывает, насколько фактическая выработка выше или ниже нормы.",
formula: "Отклонение = Баллы − Норма",
manual: "Нет. Изменяется автоматически после изменения нормы или баллов.",
},
pointAmount: {
title: "Перерасчёт по баллам",
logic: pointRecalculationLogic,
formula: pointRecalculationFormula,
manual: "Нет. Меняется через оклад, премию, норму и баллы; при перевыполнении также через цену сверх нормы.",
},
grossBeforeAdjustments: {
title: "До корректировок",
logic: "Сумма исходных начислений до положительных, отрицательных и балльной корректировок.",
formula: "До корректировок = Оклад + Отпускные + Премия + Разовые + Σ прочих начислений",
manual: "Нет. Итог пересчитывается из исходных ячеек и строк начислений.",
},
adjustments: {
title: "Корректировки",
logic: "Объединяет ручные корректировки и подписанный перерасчёт по баллам. Положительное значение увеличивает начисление, отрицательное — уменьшает.",
formula: "Корректировки = Σ ручных положительных − Σ ручных отрицательных + Перерасчёт по баллам",
manual: "Нет. Итог меняется через соответствующие строки корректировок.",
},
gross: {
title: "Начислено",
logic: "Общая сумма начисления до удержаний и учёта уже выплаченных сумм.",
formula: "Начислено = До корректировок + Корректировки",
manual: "Нет. Пересчитывается автоматически из начислений и корректировок.",
},
deductions: {
title: "Удержано",
logic: "Сумма всех действующих строк с типом «Удержание» за выбранный период.",
formula: "Удержано = Σ удержаний",
manual: "Нет. Итог меняется через строки удержаний.",
},
net: {
title: "После удержаний",
logic: "Сумма, оставшаяся после вычета удержаний, но до учёта уже записанных выплат.",
formula: "После удержаний = Начислено − Удержано",
manual: "Нет. Пересчитывается автоматически.",
},
payments: {
title: "Выплачено",
logic: "Сумма уже произведённых выплат за период, например авансов.",
formula: "Выплачено = Σ строк с типом «Выплата»",
manual: "Нет. Итог меняется через строки выплат.",
},
payable: {
title: "К выплате",
logic: "Остаток, который ещё нужно выплатить сотруднику.",
formula: "К выплате = После удержаний − Выплачено",
manual: "Нет. Пересчитывается автоматически из связанных сумм.",
},
status: {
title: "Состояние",
logic: "Система показывает готовность строки по наличию ставки, выработки, черновиков и сохранённого результата расчёта.",
formula: "Нет ставки или выработки → Не заполнено\nЕсть черновики → Есть черновики\nВсе входы утверждены → Готово\nЕсть результат расчёта → Рассчитано",
manual: "Нет. Состояние меняется автоматически вместе с данными и этапом расчёта.",
},
};
}
function componentCalculationHelp(component: PayrollPeriodTableComponent): CalculationHelp {
const effect = {
earning: "входит в сумму «До корректировок»",
adjustment_credit: "увеличивает сумму «Корректировки»",
adjustment_debit: "уменьшает сумму «Корректировки»",
deduction: "входит в сумму «Удержано»",
payment: "входит в сумму «Выплачено»",
}[component.kind];
return {
title: component.label,
logic: `Показана сумма всех действующих операций «${component.label}» сотрудника за период. Этот вид операции ${effect}.`,
formula: `${component.label} = Σ операций этого вида за период`,
manual: "Да. Ввод нового итога в ячейку создаёт или обновляет черновик операции на разницу. Утверждённые суммы исправляются новой операцией.",
};
}
function formatPoints(value: string | null): string {
if (value == null || value === "") return "—";
const numeric = Number(value);
if (!Number.isFinite(numeric)) return value;
return numeric.toLocaleString("ru-RU", { maximumFractionDigits: 4 });
}
function formatMoney(value: string | null, currency: string): string {
return value == null
? "—"
: formatPayrollMoney(value, currency).replace(/[\u00a0\u202f]/g, " ");
}
function formatSignedMoney(value: number | null, currency: string): string {
if (value === null) return "—";
const sign = value > 0 ? "+" : value < 0 ? "−" : "";
return `${sign}${formatMoney(String(Math.abs(value)), currency)}`;
}
function formatEmployeeName(employee: PayrollPeriodTableRow["employee"]): string {
return [employee.last_name, employee.first_name, employee.patronymic]
.map((part) => part?.trim())
.filter(Boolean)
.join(" ") || employee.display_name;
}
function CalculatedMoney({ value, currency, preliminary }: { value: string | null; currency: string; preliminary: boolean }) {
return (
<span
className="inline-block max-w-full"
title={preliminary ? "Предварительный расчёт по текущим данным" : undefined}
>
{formatMoney(value, currency)}
</span>
);
}
function CellCommentButton({
count,
label,
onClick,
}: {
count: number;
label: string;
onClick: () => void;
}) {
if (count) {
return (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onClick();
}}
className="absolute right-1 top-1 z-10 rounded border border-sky-500/60 bg-sky-500/15 px-1 text-[9px] font-semibold leading-3 text-sky-400 transition hover:bg-sky-500/25 hover:text-sky-300"
aria-label={`${label}: открыть комментарии, ${count}`}
title="Открыть комментарии"
>
К:{count}
</button>
);
}
return (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onClick();
}}
className="absolute bottom-1 left-1 z-10 inline-flex h-4 min-w-4 items-center justify-center rounded border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-1 text-[10px] leading-3 text-[var(--muted-foreground)] transition hover:border-[var(--accent-primary)] hover:text-[var(--foreground)]"
aria-label={`${label}: добавить комментарий`}
title="Добавить комментарий"
>
+
</button>
);
}
function CommentableValueCell({
children,
className = "",
commentCount,
commentLabel,
onOpenComment,
}: {
children: ReactNode;
className?: string;
commentCount: number;
commentLabel: string;
onOpenComment: () => void;
}) {
return (
<td className={`${bodyCellClass} relative ${className}`}>
<CellCommentButton count={commentCount} label={commentLabel} onClick={onOpenComment} />
{children}
</td>
);
}
const projectionDifferenceClasses: Record<PayrollProjectionDifference["color"], string> = {
green: "text-emerald-300 font-medium",
orange: "text-orange-400 font-medium",
red: "text-red-400 font-medium",
};
function ProjectionPointsCell({
value,
actualPoints,
label,
onOpen,
commentCount,
onOpenComment,
}: {
value: string | null;
actualPoints: string | null;
label: string;
onOpen: () => void;
commentCount: number;
onOpenComment: () => void;
}) {
const difference = comparePayrollProjection(value, actualPoints);
const className = difference
? projectionDifferenceClasses[difference.color]
: "text-[var(--muted-foreground)]";
const directionLabel = difference?.direction === "higher" ? "выше" : "ниже";
const differenceLabel = difference?.percentage == null
? "выработка равна нулю"
: `расхождение ${difference.percentage.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
const title = difference
? `${label}: значение ${directionLabel} выработки, ${differenceLabel}. Нажмите для детализации по дням.`
: `${label}. Нажмите для детализации по дням.`;
return (
<td className={`${bodyCellClass} relative p-0`} title={title}>
<CellCommentButton count={commentCount} label={label} onClick={onOpenComment} />
<button
type="button"
className={`min-h-10 w-full px-1.5 py-2 text-right text-[11px] tabular-nums transition-colors hover:bg-[var(--surface-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--accent-primary)] ${className}`}
onClick={onOpen}
aria-label={`${label}. Открыть детализацию по дням`}
>
{formatPoints(value)}
</button>
</td>
);
}
function editableNumber(value: string | null): string {
if (value == null || value === "") return "";
const numeric = Number(value);
return Number.isFinite(numeric) ? String(numeric) : value;
}
function sameNumber(left: string, right: string): boolean {
if (!left.trim() && !right.trim()) return true;
const leftNumber = Number(left.replace(",", "."));
const rightNumber = Number(right.replace(",", "."));
return Number.isFinite(leftNumber) && Number.isFinite(rightNumber) && leftNumber === rightNumber;
}
function InlineEditableCell({ value, formattedValue, onSave, label, automatic = false, commentCount, onOpenComment }: { value: string | null; formattedValue: string; onSave?: (value: string) => Promise<void>; label: string; automatic?: boolean; commentCount: number; onOpenComment: () => void }) {
const original = editableNumber(value);
const [draft, setDraft] = useState(original);
const [pending, setPending] = useState(false);
const [cellError, setCellError] = useState<string | null>(null);
const cancelledRef = useRef(false);
useEffect(() => {
if (!pending) setDraft(editableNumber(value));
}, [pending, value]);
if (!onSave) return (
<CommentableValueCell
className={automatic ? "text-[var(--muted-foreground)]" : ""}
commentCount={commentCount}
commentLabel={label}
onOpenComment={onOpenComment}
>
{formattedValue}
</CommentableValueCell>
);
const commit = async () => {
if (cancelledRef.current) {
cancelledRef.current = false;
return;
}
if (sameNumber(draft, original)) return;
setPending(true);
setCellError(null);
try {
await onSave(draft.trim().replace(",", "."));
} catch (error) {
setDraft(original);
setCellError(getPayrollAdminError(error, "Не удалось сохранить ячейку."));
} finally {
setPending(false);
}
};
return (
<td className={`${bodyCellClass} relative p-0`}>
<CellCommentButton count={commentCount} label={label} onClick={onOpenComment} />
<label className="relative block min-h-10 w-full max-w-full overflow-hidden">
<input
className={`min-h-10 w-full min-w-0 max-w-full bg-transparent px-1.5 py-2 text-right text-[11px] tabular-nums outline-none transition-colors placeholder:text-[var(--muted-foreground)] hover:bg-[var(--surface-tertiary)] focus:bg-[var(--surface-tertiary)] focus:text-[var(--foreground)] focus:ring-2 focus:ring-inset ${automatic ? "text-[var(--muted-foreground)]" : "text-[var(--foreground)]"} ${cellError ? "ring-2 ring-inset ring-red-500/70" : "focus:ring-[var(--accent-primary)]"}`}
inputMode="decimal"
size={1}
value={draft}
placeholder="—"
disabled={pending}
aria-label={label}
aria-invalid={Boolean(cellError)}
title={cellError || `${label}. Enter или выход из ячейки — сохранить, Esc — отменить.`}
onChange={(event) => setDraft(event.target.value)}
onBlur={() => void commit()}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
cancelledRef.current = true;
setDraft(original);
event.currentTarget.blur();
}
}}
/>
{pending ? <Loader2 className="app-accent-text pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 animate-spin" size={11} /> : null}
</label>
</td>
);
}
function sumRows(rows: PayrollPeriodTableRow[], value: (row: PayrollPeriodTableRow) => string | null): string | null {
let hasValue = false;
const total = rows.reduce((sum, row) => {
const raw = value(row);
if (raw == null || raw === "") return sum;
const numeric = Number(raw);
if (!Number.isFinite(numeric)) return sum;
hasValue = true;
return sum + numeric;
}, 0);
return hasValue ? String(total) : null;
}
function commentCellKey(employeeId: number, columnId: string): string {
return `${employeeId}:${columnId}`;
}
export function PayrollPeriodTableView({
data,
loading,
error,
search,
onSearch,
storageUserId,
canEdit = false,
onBulkEditRate,
onBulkEditPointRate,
onBulkEditTargetPoints,
onSaveRate,
onSaveWork,
onSaveComponent,
onOpenCarryovers,
}: {
data: PayrollPeriodTable | null;
loading: boolean;
error: string | null;
search: string;
onSearch: (value: string) => void;
storageUserId?: number | null;
canEdit?: boolean;
onBulkEditRate?: () => void;
onBulkEditPointRate?: () => void;
onBulkEditTargetPoints?: () => void;
onSaveRate?: (row: PayrollPeriodTableRow, field: "amount" | "point_rate", value: string) => Promise<void>;
onSaveWork?: (row: PayrollPeriodTableRow, field: "target_points" | "actual_points", value: string) => Promise<void>;
onSaveComponent?: (row: PayrollPeriodTableRow, componentCode: string, value: string) => Promise<void>;
onOpenCarryovers?: (employee: PayrollPeriodTableRow["employee"]) => void;
}) {
const searchedRows = useMemo(() => {
const needle = search.trim().toLocaleLowerCase("ru-RU");
if (!needle) return data?.rows || [];
return (data?.rows || []).filter((row) => (
`${formatEmployeeName(row.employee)} ${row.employee.display_name} ${row.employee.position || ""} ${row.employee.department || ""}`
.toLocaleLowerCase("ru-RU")
.includes(needle)
));
}, [data?.rows, search]);
const [activeHelp, setActiveHelp] = useState<CalculationHelp | null>(null);
const [activeCommentCell, setActiveCommentCell] = useState<PayrollCommentCell | null>(null);
const [commentCountOverrides, setCommentCountOverrides] = useState<Record<string, { count: number; periodId: number }>>({});
const [breakdownEmployee, setBreakdownEmployee] = useState<{
id: number;
displayName: string;
} | null>(null);
const [columnsMenuOpen, setColumnsMenuOpen] = useState(false);
const [rowsMenuOpen, setRowsMenuOpen] = useState(false);
const columnsMenuRef = useRef<HTMLDivElement>(null);
const rowsMenuRef = useRef<HTMLDivElement>(null);
const {
finishPan: finishTablePan,
movePan: moveTablePan,
scrollRef: tableScrollRef,
startPan: startTablePan,
} = useMiddleMousePan<HTMLDivElement>();
const [hiddenColumns, setHiddenColumns] = useHiddenPayrollColumns(storageUserId);
const [hiddenRows, setHiddenRows] = useHiddenPayrollRows(storageUserId);
const [storedColumnOrder, setStoredColumnOrder] = usePayrollColumnOrder(storageUserId);
const hiddenCurrentRows = useMemo(
() => (data?.rows || []).filter((row) => hiddenRows.has(String(row.employee.id))),
[data?.rows, hiddenRows],
);
const rows = useMemo(
() => searchedRows.filter((row) => !hiddenRows.has(String(row.employee.id))),
[hiddenRows, searchedRows],
);
const updateCommentCount = useCallback((cell: PayrollCommentCell, count: number) => {
setCommentCountOverrides((current) => ({
...current,
[commentCellKey(cell.employeeId, cell.columnId)]: {
count,
periodId: cell.periodId,
},
}));
}, []);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const componentColumns = useMemo(() => data?.component_columns || [], [data?.component_columns]);
const componentDefinitions = useMemo<ColumnDefinition[]>(() => (
componentColumns.map((component) => ({
id: componentColumnId(component.code),
label: component.label,
group: "Начисления и выплаты",
width: 75,
}))
), [componentColumns]);
const allColumns = useMemo(
() => [...salaryColumns, ...workColumns, ...componentDefinitions, ...calculationColumns, carryoversColumn, statusColumn],
[componentDefinitions],
);
const defaultColumnOrder = useMemo(() => allColumns.map((column) => column.id), [allColumns]);
const columnOrder = useMemo(
() => normalizePayrollColumnOrder(storedColumnOrder, defaultColumnOrder),
[defaultColumnOrder, storedColumnOrder],
);
const columnsById = useMemo(
() => new Map(allColumns.map((column) => [column.id, column])),
[allColumns],
);
const orderedColumns = columnOrder
.map((columnId) => columnsById.get(columnId))
.filter((column): column is ColumnDefinition => Boolean(column));
const hiddenCurrentColumns = orderedColumns.filter((column) => hiddenColumns.has(column.id));
const isVisible = (columnId: string) => !hiddenColumns.has(columnId);
const visibleColumns = orderedColumns.filter((column) => isVisible(column.id));
const visibleColumnIds = visibleColumns.map((column) => column.id);
const visibleColumnCount = 1 + visibleColumns.length;
const groupSegments = buildColumnGroupSegments(visibleColumns);
const hasCustomColumnOrder = columnOrder.some(
(columnId, index) => columnId !== defaultColumnOrder[index],
);
const hideColumn = (columnId: string) => {
const next = new Set(hiddenColumns);
next.add(columnId);
setHiddenColumns(next);
};
const showColumn = (columnId: string) => {
const next = new Set(hiddenColumns);
next.delete(columnId);
setHiddenColumns(next);
if (hiddenCurrentColumns.length === 1) setColumnsMenuOpen(false);
};
const hideRow = (employeeId: number) => {
const next = new Set(hiddenRows);
next.add(String(employeeId));
setHiddenRows(next);
};
const showRow = (employeeId: number) => {
const next = new Set(hiddenRows);
next.delete(String(employeeId));
setHiddenRows(next);
if (hiddenCurrentRows.length === 1) setRowsMenuOpen(false);
};
const handleColumnDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
const oldIndex = columnOrder.indexOf(String(active.id));
const newIndex = columnOrder.indexOf(String(over.id));
if (oldIndex < 0 || newIndex < 0) return;
setStoredColumnOrder(movePayrollColumn(columnOrder, String(active.id), String(over.id)));
};
useEffect(() => {
if (!columnsMenuOpen) return;
const closeOnOutsideClick = (event: MouseEvent) => {
if (!columnsMenuRef.current?.contains(event.target as Node)) setColumnsMenuOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setColumnsMenuOpen(false);
};
document.addEventListener("mousedown", closeOnOutsideClick);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("mousedown", closeOnOutsideClick);
document.removeEventListener("keydown", closeOnEscape);
};
}, [columnsMenuOpen]);
useEffect(() => {
if (!rowsMenuOpen) return;
const closeOnOutsideClick = (event: MouseEvent) => {
if (!rowsMenuRef.current?.contains(event.target as Node)) setRowsMenuOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setRowsMenuOpen(false);
};
document.addEventListener("mousedown", closeOnOutsideClick);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("mousedown", closeOnOutsideClick);
document.removeEventListener("keydown", closeOnEscape);
};
}, [rowsMenuOpen]);
if (loading && !data) {
return <div className="flex min-h-56 items-center justify-center"><Loader2 className="app-accent-text animate-spin" size={24} /></div>;
}
if (error) return <div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">{error}</div>;
if (!data) return null;
const tableWidth = 150 + visibleColumns.reduce((width, column) => width + column.width, 0);
const helps = calculationHelps(data.calculation_rules.point_policy);
const total = (field: keyof PayrollPeriodTableRow) => sumRows(rows, (row) => {
const value = row[field];
return typeof value === "string" ? value : null;
});
const componentsByColumnId = new Map(
componentColumns.map((component) => [componentColumnId(component.code), component]),
);
const carryoversByEmployeeId = new Map(
(data.carryovers || []).map((summary) => [summary.employee_id, summary]),
);
const commentCountByCell = new Map(
(data.cell_comments || []).map((summary) => [
commentCellKey(summary.employee_id, summary.column_id),
summary.count,
]),
);
Object.entries(commentCountOverrides).forEach(([key, override]) => {
if (override.periodId === data.period_id) commentCountByCell.set(key, override.count);
});
const commentProps = (row: PayrollPeriodTableRow, columnId: string, columnLabel: string) => ({
commentCount: commentCountByCell.get(commentCellKey(row.employee.id, columnId)) || 0,
onOpenComment: () => setActiveCommentCell({
periodId: data.period_id,
employeeId: row.employee.id,
employeeName: row.employee.display_name,
columnId,
columnLabel,
commentsCount: commentCountByCell.get(commentCellKey(row.employee.id, columnId)) || 0,
}),
});
const renderColumnHeader = (column: ColumnDefinition, dragHandle: ReactNode) => {
const common = {
onOpen: setActiveHelp,
onHide: () => hideColumn(column.id),
dragHandle,
};
const component = componentsByColumnId.get(column.id);
if (component) {
return (
<span title={`${component.label} · ${kindLabels[component.kind]}`}>
<ColumnHeader label={component.label} help={componentCalculationHelp(component)} {...common}>{component.label}</ColumnHeader>
</span>
);
}
switch (column.id) {
case columnIds.rateAmount:
return <ColumnHeader label="Оклад" onBulkEdit={onBulkEditRate} {...common}>Оклад</ColumnHeader>;
case columnIds.inNormPointRate:
return <ColumnHeader label="Цена балла в норме" help={helps.inNormPointRate} {...common}>Цена балла в норме</ColumnHeader>;
case columnIds.excessPointRate:
return <ColumnHeader label="Цена балла сверх нормы" help={helps.excessPointRate} onBulkEdit={onBulkEditPointRate} {...common}>Цена балла сверх нормы</ColumnHeader>;
case columnIds.targetPoints:
return <ColumnHeader label="Норма баллов" help={helps.targetPoints} onBulkEdit={onBulkEditTargetPoints} {...common}>Норма</ColumnHeader>;
case columnIds.attendancePoints:
return <ColumnHeader label="Баллы по посещаемости" help={helps.attendancePoints} {...common}>Баллы по посещаемости</ColumnHeader>;
case columnIds.personnelPoints:
return <ColumnHeader label="Баллы по кадровым событиям" help={helps.personnelPoints} {...common}>Баллы по кадровым событиям</ColumnHeader>;
case columnIds.actualPoints:
return <ColumnHeader label="Баллы" help={helps.actualPoints} {...common}>Баллы</ColumnHeader>;
case columnIds.pointDelta:
return <ColumnHeader label="Отклонение" help={helps.pointDelta} {...common}>Отклонение</ColumnHeader>;
case columnIds.pointAmount:
return <ColumnHeader label="По баллам" help={helps.pointAmount} {...common}>По баллам</ColumnHeader>;
case columnIds.grossBeforeAdjustments:
return <ColumnHeader label="До корректировок" help={helps.grossBeforeAdjustments} {...common}>До коррект.</ColumnHeader>;
case columnIds.adjustments:
return <ColumnHeader label="Корректировки" help={helps.adjustments} {...common}>Корректировки</ColumnHeader>;
case columnIds.gross:
return <ColumnHeader label="Начислено" help={helps.gross} {...common}>Начислено</ColumnHeader>;
case columnIds.deductions:
return <ColumnHeader label="Удержано" help={helps.deductions} {...common}>Удержано</ColumnHeader>;
case columnIds.net:
return <ColumnHeader label="После удержаний" help={helps.net} {...common}>После удерж.</ColumnHeader>;
case columnIds.payments:
return <ColumnHeader label="Выплачено" help={helps.payments} {...common}>Выплачено</ColumnHeader>;
case columnIds.payable:
return <ColumnHeader label="К выплате" help={helps.payable} {...common}>К выплате</ColumnHeader>;
case columnIds.carryovers:
return (
<ColumnHeader
label="Долги / доплаты"
help={{
title: "Долги / доплаты",
logic: "Положительное значение — компания должна доплатить сотруднику. Отрицательное — долг сотрудника перед компанией. В колонке показан итог записей, связанных с выбранным периодом.",
formula: "Баланс переноса = Доплаты сотруднику − Долги сотрудника",
manual: "Да. Нажмите сумму в строке сотрудника: откроется реестр, где можно добавить или отменить неутверждённую запись.",
}}
{...common}
>
Долги / доплаты
</ColumnHeader>
);
case columnIds.status:
return <ColumnHeader label="Состояние" help={helps.status} {...common}>Состояние</ColumnHeader>;
default:
return <span className="inline-flex items-center gap-0.5">{column.label}{dragHandle}</span>;
}
};
const renderBodyCell = (column: ColumnDefinition, row: PayrollPeriodTableRow) => {
const comments = commentProps(row, column.id, column.label);
const component = componentsByColumnId.get(column.id);
if (component) {
return <InlineEditableCell value={row.component_amounts[component.code] || null} formattedValue={formatMoney(row.component_amounts[component.code] || null, data.currency)} onSave={canEdit && onSaveComponent ? (value) => onSaveComponent(row, component.code, value) : undefined} label={`${component.label}: ${row.employee.display_name}`} {...comments} />;
}
switch (column.id) {
case columnIds.rateAmount:
return <InlineEditableCell value={row.rate_amount} formattedValue={formatMoney(row.rate_amount, data.currency)} onSave={canEdit && onSaveRate ? (value) => onSaveRate(row, "amount", value) : undefined} label={`Оклад: ${row.employee.display_name}`} {...comments} />;
case columnIds.inNormPointRate:
return <InlineEditableCell value={row.in_norm_point_rate} formattedValue={formatMoney(row.in_norm_point_rate, data.currency)} label={`Цена балла в пределах нормы: ${row.employee.display_name}`} automatic {...comments} />;
case columnIds.excessPointRate:
return <InlineEditableCell value={row.point_rate ?? row.in_norm_point_rate} formattedValue={formatMoney(row.point_rate ?? row.in_norm_point_rate, data.currency)} onSave={canEdit && onSaveRate ? (value) => onSaveRate(row, "point_rate", value) : undefined} label={`Цена балла сверх нормы: ${row.employee.display_name}`} automatic={row.point_rate === null} {...comments} />;
case columnIds.targetPoints:
return <InlineEditableCell value={row.target_points} formattedValue={formatPoints(row.target_points)} onSave={canEdit && onSaveWork ? (value) => onSaveWork(row, "target_points", value) : undefined} label={`Норма: ${row.employee.display_name}`} automatic={row.target_points_automatic} {...comments} />;
case columnIds.attendancePoints:
return <ProjectionPointsCell value={row.attendance_points} actualPoints={row.actual_points} label={`Баллы по посещаемости: ${row.employee.display_name}`} onOpen={() => setBreakdownEmployee({ id: row.employee.id, displayName: row.employee.display_name })} {...comments} />;
case columnIds.personnelPoints:
return <ProjectionPointsCell value={row.personnel_points} actualPoints={row.actual_points} label={`Баллы по кадровым событиям: ${row.employee.display_name}`} onOpen={() => setBreakdownEmployee({ id: row.employee.id, displayName: row.employee.display_name })} {...comments} />;
case columnIds.actualPoints:
return <InlineEditableCell value={row.actual_points} formattedValue={formatPoints(row.actual_points)} onSave={canEdit && onSaveWork ? (value) => onSaveWork(row, "actual_points", value) : undefined} label={`Баллы: ${row.employee.display_name}`} {...comments} />;
case columnIds.pointDelta:
return <CommentableValueCell commentLabel={`Отклонение: ${row.employee.display_name}`} {...comments}>{formatPoints(row.point_delta)}</CommentableValueCell>;
case columnIds.pointAmount:
return <CommentableValueCell commentLabel={`По баллам: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.point_amount} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
case columnIds.grossBeforeAdjustments:
return <CommentableValueCell commentLabel={`До корректировок: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.gross_before_adjustments} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
case columnIds.adjustments:
return <CommentableValueCell commentLabel={`Корректировки: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.adjustment_total} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
case columnIds.gross:
return <CommentableValueCell className="font-semibold" commentLabel={`Начислено: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.gross_total} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
case columnIds.deductions:
return <CommentableValueCell commentLabel={`Удержано: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.deduction_total} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
case columnIds.net:
return <CommentableValueCell commentLabel={`После удержаний: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.net_pay} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
case columnIds.payments:
return <CommentableValueCell commentLabel={`Выплачено: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.payment_total} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
case columnIds.payable:
return <CommentableValueCell className="font-semibold" commentLabel={`К выплате: ${row.employee.display_name}`} {...comments}><CalculatedMoney value={row.payable} currency={data.currency} preliminary={row.totals_preliminary} /></CommentableValueCell>;
case columnIds.carryovers: {
const summary = carryoversByEmployeeId.get(row.employee.id);
const count = summary?.count || 0;
const balance = summary
? Number(summary.company_owes) - Number(summary.employee_owes)
: null;
const formattedBalance = formatSignedMoney(balance, data.currency);
const details = summary
? [
Number(summary.company_owes) ? `доплаты ${formatMoney(summary.company_owes, data.currency)}` : "",
Number(summary.employee_owes) ? `долги ${formatMoney(summary.employee_owes, data.currency)}` : "",
].filter(Boolean).join("; ")
: "записей пока нет";
return (
<td className="relative overflow-hidden border-b border-r border-[var(--border-subtle)] p-0 text-center">
<CellCommentButton
count={comments.commentCount}
label={`Долги / доплаты: ${row.employee.display_name}`}
onClick={comments.onOpenComment}
/>
<button
type="button"
className="app-action-ghost inline-flex min-h-10 w-full items-center justify-center gap-1.5 px-1.5 py-2 text-[10px] font-semibold"
onClick={() => onOpenCarryovers?.(row.employee)}
disabled={!onOpenCarryovers}
title={`${row.employee.display_name}: ${details}. Открыть реестр.`}
aria-label={`${row.employee.display_name}: открыть долги и доплаты, записей ${count}`}
>
<span className={balance !== null && balance > 0 ? "text-emerald-400" : balance !== null && balance < 0 ? "text-amber-400" : "app-text-muted"}>
{formattedBalance}
</span>
{summary?.pending ? <span className="app-feedback-warning rounded px-1">{summary.pending}</span> : null}
</button>
</td>
);
}
case columnIds.status: {
const meta = statusMeta[row.status];
return <td className="relative overflow-hidden border-b border-r border-[var(--border-subtle)] px-1 py-2 text-center"><CellCommentButton count={comments.commentCount} label={`Состояние: ${row.employee.display_name}`} onClick={comments.onOpenComment} /><span className={`app-status-pill px-2 text-[10px] ${meta.className}`}>{meta.label}</span></td>;
}
default:
return <CommentableValueCell commentLabel={`${column.label}: ${row.employee.display_name}`} {...comments}>—</CommentableValueCell>;
}
};
const renderFooterCell = (column: ColumnDefinition) => {
const component = componentsByColumnId.get(column.id);
if (component) return <td className={bodyCellClass}>{formatMoney(sumRows(rows, (row) => row.component_amounts[component.code] || null), data.currency)}</td>;
switch (column.id) {
case columnIds.rateAmount:
return <td className={bodyCellClass}>{formatMoney(total("rate_amount"), data.currency)}</td>;
case columnIds.inNormPointRate:
case columnIds.excessPointRate:
return <td className={bodyCellClass}>—</td>;
case columnIds.targetPoints:
return <td className={bodyCellClass}>{formatPoints(total("target_points"))}</td>;
case columnIds.attendancePoints:
return <td className={bodyCellClass}>{formatPoints(total("attendance_points"))}</td>;
case columnIds.personnelPoints:
return <td className={bodyCellClass}>{formatPoints(total("personnel_points"))}</td>;
case columnIds.actualPoints:
return <td className={bodyCellClass}>{formatPoints(total("actual_points"))}</td>;
case columnIds.pointDelta:
return <td className={bodyCellClass}>{formatPoints(total("point_delta"))}</td>;
case columnIds.pointAmount:
return <td className={bodyCellClass}>{formatMoney(total("point_amount"), data.currency)}</td>;
case columnIds.grossBeforeAdjustments:
return <td className={bodyCellClass}>{formatMoney(total("gross_before_adjustments"), data.currency)}</td>;
case columnIds.adjustments:
return <td className={bodyCellClass}>{formatMoney(total("adjustment_total"), data.currency)}</td>;
case columnIds.gross:
return <td className={bodyCellClass}>{formatMoney(total("gross_total"), data.currency)}</td>;
case columnIds.deductions:
return <td className={bodyCellClass}>{formatMoney(total("deduction_total"), data.currency)}</td>;
case columnIds.net:
return <td className={bodyCellClass}>{formatMoney(total("net_pay"), data.currency)}</td>;
case columnIds.payments:
return <td className={bodyCellClass}>{formatMoney(total("payment_total"), data.currency)}</td>;
case columnIds.payable:
return <td className={bodyCellClass}>{formatMoney(total("payable"), data.currency)}</td>;
case columnIds.carryovers: {
const visibleEmployeeIds = new Set(rows.map((row) => row.employee.id));
const carryovers = (data.carryovers || []).filter((item) => visibleEmployeeIds.has(item.employee_id));
const balance = carryovers.length
? carryovers.reduce(
(sum, item) => sum + Number(item.company_owes) - Number(item.employee_owes),
0,
)
: null;
return <td className={`${bodyCellClass} text-center`}>{formatSignedMoney(balance, data.currency)}</td>;
}
case columnIds.status:
default:
return <td className="border-r border-[var(--border-subtle)] px-3 py-3" />;
}
};
return (
<div className="min-w-0 max-w-full">
<div className="mb-3 flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap gap-2">
<span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Сотрудников <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.employee_count}</span></span>
<span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Рассчитано <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.calculated_count}</span></span>
<span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Готово <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.ready_count}</span></span>
<span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Черновики <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.draft_count}</span></span>
<span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Не заполнено <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.incomplete_count}</span></span>
{data.summary.preliminary_count ? <span className="app-chip inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs">Предварительно <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{data.summary.preliminary_count}</span></span> : null}
{data.run ? <span className="app-selected inline-flex items-center rounded-full px-3 py-1.5 text-xs">Ревизия {data.run.revision}</span> : null}
</div>
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<label className="relative block min-w-0 sm:w-72">
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" size={15} />
<input
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
value={search}
placeholder="Поиск по сотрудникам"
onChange={(event) => onSearch(event.target.value)}
/>
</label>
<div ref={columnsMenuRef} className="relative shrink-0">
<button
type="button"
className="app-action-secondary inline-flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium disabled:cursor-default disabled:opacity-60 sm:w-auto"
onClick={() => setColumnsMenuOpen((open) => !open)}
aria-expanded={columnsMenuOpen}
aria-haspopup="menu"
>
<Eye size={15} />
Колонки
{hiddenCurrentColumns.length ? <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{hiddenCurrentColumns.length}</span> : null}
</button>
{columnsMenuOpen ? (
<div className="app-surface absolute right-0 top-full z-50 mt-2 w-72 overflow-hidden rounded-xl border border-[var(--border-subtle)] p-2 shadow-2xl" role="menu" aria-label="Скрытые колонки">
<p className="app-text-muted rounded-lg px-2 py-1.5 text-xs leading-relaxed">
Меняйте порядок перетаскиванием значка в заголовке таблицы.
</p>
{hiddenCurrentColumns.length ? (
<>
<p className="app-text-muted px-2 pb-2 pt-3 text-[10px] font-semibold uppercase tracking-wide">Скрытые колонки</p>
<div className="max-h-64 overflow-y-auto">
{hiddenCurrentColumns.map((column) => (
<button
key={column.id}
type="button"
className="app-action-ghost flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left"
onClick={() => showColumn(column.id)}
role="menuitem"
title={`Показать колонку «${column.label}»`}
>
<EyeOff className="app-text-muted shrink-0" size={15} />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-[var(--foreground)]">{column.label}</span>
<span className="app-text-muted block truncate text-[10px]">{column.group}</span>
</span>
<span className="app-accent-text text-[10px]">Показать</span>
</button>
))}
</div>
</>
) : (
<p className="app-text-muted px-2 py-3 text-xs">Все колонки отображаются.</p>
)}
<div className="mt-2 grid gap-2">
{hiddenCurrentColumns.length > 1 ? (
<button
type="button"
className="app-action-secondary inline-flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-xs font-medium"
onClick={() => setHiddenColumns(new Set())}
>
<Eye size={14} /> Показать все
</button>
) : null}
{hasCustomColumnOrder ? (
<button
type="button"
className="app-action-secondary inline-flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-xs font-medium"
onClick={() => {
setStoredColumnOrder(defaultColumnOrder);
setColumnsMenuOpen(false);
}}
>
<RotateCcw size={14} /> Вернуть исходный порядок
</button>
) : null}
</div>
</div>
) : null}
</div>
<div ref={rowsMenuRef} className="relative shrink-0">
<button
type="button"
className="app-action-secondary inline-flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium sm:w-auto"
onClick={() => setRowsMenuOpen((open) => !open)}
aria-expanded={rowsMenuOpen}
aria-haspopup="menu"
>
<Eye size={15} />
Строки
{hiddenCurrentRows.length ? <span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{hiddenCurrentRows.length}</span> : null}
</button>
{rowsMenuOpen ? (
<div className="app-surface absolute right-0 top-full z-50 mt-2 w-72 overflow-hidden rounded-xl border border-[var(--border-subtle)] p-2 shadow-2xl" role="menu" aria-label="Скрытые сотрудники">
{hiddenCurrentRows.length ? (
<>
<p className="app-text-muted px-2 pb-2 pt-1 text-[10px] font-semibold uppercase tracking-wide">Скрытые сотрудники</p>
<div className="max-h-64 overflow-y-auto">
{hiddenCurrentRows.map((row) => (
<button
key={row.employee.id}
type="button"
className="app-action-ghost flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left"
onClick={() => showRow(row.employee.id)}
role="menuitem"
title={`Показать сотрудника «${formatEmployeeName(row.employee)}»`}
>
<EyeOff className="app-text-muted shrink-0" size={15} />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--foreground)]">{formatEmployeeName(row.employee)}</span>
<span className="app-accent-text text-[10px]">Показать</span>
</button>
))}
</div>
{hiddenCurrentRows.length > 1 ? (
<button
type="button"
className="app-action-secondary mt-2 inline-flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-xs font-medium"
onClick={() => {
setHiddenRows(new Set());
setRowsMenuOpen(false);
}}
>
<Eye size={14} /> Показать всех
</button>
) : null}
</>
) : (
<p className="app-text-muted px-2 py-3 text-xs">Все сотрудники отображаются.</p>
)}
</div>
) : null}
</div>
</div>
</div>
{data.summary.preliminary_count ? (
<div className="app-feedback-warning mb-3 rounded-lg px-3 py-2.5 text-sm">
Значения рассчитаны предварительно по текущим данным. Они обновляются после сохранения ячеек и станут официальными после запуска расчёта.
</div>
) : null}
<div className="app-text-muted mb-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
{canEdit ? <span>Enter или переход в другую ячейку — сохранить, Esc — отменить.</span> : null}
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-[var(--muted-foreground)]" aria-hidden="true" />Серый текст — заполнено автоматически</span>
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-emerald-300" aria-hidden="true" />Зелёный — значение выше выработки больше чем на 25%</span>
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-orange-400" aria-hidden="true" />Оранжевый — ниже на 5–25%</span>
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-red-400" aria-hidden="true" />Красный — ниже больше чем на 25%</span>
<span className="inline-flex items-center gap-1.5"><CircleHelp className="app-accent-text" size={13} />Знак вопроса — логика и формула расчёта</span>
<span className="inline-flex items-center gap-1.5"><span className="inline-flex h-4 min-w-4 items-center justify-center rounded border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-1 text-[10px] leading-3">+</span>Плюс — добавить комментарий; К:N — открыть обсуждение</span>
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleColumnDragEnd}>
<div
ref={tableScrollRef}
className="app-table-scroll max-h-[min(70vh,760px)] w-full max-w-full overflow-auto overscroll-contain rounded-xl border border-[var(--border-subtle)]"
onPointerDown={startTablePan}
onPointerMove={moveTablePan}
onPointerUp={finishTablePan}
onPointerCancel={finishTablePan}
onAuxClick={(event) => {
if (event.button === 1) event.preventDefault();
}}
>
<table className="table-fixed border-separate border-spacing-0" style={{ width: `max(100%, ${tableWidth}px)` }} aria-label="Итоговая таблица расчётного периода">
<colgroup>
<col style={{ width: 150 }} />
{visibleColumns.map((column) => <col key={column.id} style={{ width: column.width }} />)}
</colgroup>
<thead className="sticky top-0 z-30">
<tr>
<th rowSpan={2} className={`${headerCellClass} sticky left-0 z-40 align-middle`}>Сотрудник</th>
{groupSegments.map((segment, index) => (
<th key={`${segment.group}-${index}`} colSpan={segment.columns.length} className={`${headerCellClass} text-center uppercase tracking-wide`}>
{segment.group}
</th>
))}
</tr>
<SortableContext items={visibleColumnIds} strategy={horizontalListSortingStrategy}>
<tr>
{visibleColumns.map((column) => (
<SortableColumnHeader key={column.id} column={column}>
{(dragHandle) => renderColumnHeader(column, dragHandle)}
</SortableColumnHeader>
))}
</tr>
</SortableContext>
</thead>
<tbody>
{rows.length ? rows.map((row) => (
<tr key={row.employee.id} className="bg-[var(--surface-primary)] hover:bg-[var(--surface-secondary)]">
<td className="sticky left-0 z-20 overflow-hidden border-b border-r border-[var(--border-subtle)] bg-[var(--surface-primary)] px-2 py-2.5">
<CellCommentButton
count={commentCountByCell.get(commentCellKey(row.employee.id, "employee")) || 0}
label={`Сотрудник: ${formatEmployeeName(row.employee)}`}
onClick={() => setActiveCommentCell({
periodId: data.period_id,
employeeId: row.employee.id,
employeeName: formatEmployeeName(row.employee),
columnId: "employee",
columnLabel: "Сотрудник",
commentsCount: commentCountByCell.get(commentCellKey(row.employee.id, "employee")) || 0,
})}
/>
<div className="flex min-w-0 items-center gap-1 pr-7">
<p className="min-w-0 flex-1 truncate text-xs font-semibold text-[var(--foreground)]" title={formatEmployeeName(row.employee)}>{formatEmployeeName(row.employee)}</p>
<button
type="button"
className="app-text-muted inline-flex h-4 w-4 shrink-0 items-center justify-center rounded transition hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
onClick={() => hideRow(row.employee.id)}
aria-label={`Скрыть строку сотрудника «${formatEmployeeName(row.employee)}»`}
title="Скрыть строку"
>
<Eye size={12} />
</button>
</div>
<p className="app-text-muted mt-0.5 max-w-full truncate text-[10px]">{row.employee.position || row.employee.department || "Сотрудник"}{row.employee.is_active ? "" : " · неактивен"}</p>
</td>
{visibleColumns.map((column) => (
<Fragment key={column.id}>{renderBodyCell(column, row)}</Fragment>
))}
</tr>
)) : (
<tr><td colSpan={visibleColumnCount} className="app-text-muted px-4 py-10 text-center text-sm">{searchedRows.length ? "Все найденные строки скрыты. Вернуть их можно через меню «Строки»." : "Сотрудники не найдены."}</td></tr>
)}
</tbody>
{rows.length ? (
<tfoot>
<tr className="app-surface-muted font-semibold">
<td className="sticky left-0 z-20 border-r border-[var(--border-subtle)] bg-[var(--surface-secondary)] px-3 py-3 text-xs text-[var(--foreground)]">Итого · {rows.length}</td>
{visibleColumns.map((column) => (
<Fragment key={column.id}>{renderFooterCell(column)}</Fragment>
))}
</tr>
</tfoot>
) : null}
</table>
</div>
</DndContext>
{activeHelp ? <CalculationHelpModal help={activeHelp} rules={data.calculation_rules} onClose={() => setActiveHelp(null)} /> : null}
{breakdownEmployee ? (
<PayrollPointBreakdownModal
periodId={data.period_id}
employeeId={breakdownEmployee.id}
employeeName={breakdownEmployee.displayName}
onClose={() => setBreakdownEmployee(null)}
/>
) : null}
<PayrollCellCommentsModal
cell={activeCommentCell?.periodId === data.period_id ? activeCommentCell : null}
currentUserId={storageUserId}
onClose={() => setActiveCommentCell(null)}
onCommentCountChange={updateCommentCount}
periodId={data.period_id}
/>
</div>
);
}