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

3895 lines
156 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 {
AlertTriangle,
ChartNoAxesColumn,
CircleHelp,
Eye,
EyeOff,
GripVertical,
Loader2,
Pencil,
Plus,
RotateCcw,
Search,
Table2,
X,
} from "lucide-react";
import {
Fragment,
type FormEvent,
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 {
PayrollComponentPeriodScope,
PayrollColumnHighlightColor,
PayrollCustomComponentWrite,
PayrollLineKind,
PayrollPeriodTable,
PayrollPeriodTableComponent,
PayrollPeriodTableRow,
PayrollPeriodTableStatus,
} from "@/lib/api/finance";
import { formatPayrollMoney, formatPayrollPoints } from "@/lib/payroll";
import { getPayrollAdminError } from "@/lib/payroll-admin";
import { assessPayrollPointBaseChange } from "@/lib/payroll-point-base";
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: "Уже выплачено",
scheduled_change: "Изменить колонку в будущем периоде",
};
const kindHelpItems: Array<{
kind: PayrollLineKind;
description: string;
formula: string;
}> = [
{
kind: "earning",
description:
"Основная часть дохода: например, премия, отпускные или компенсация. Входит в сумму до корректировок.",
formula: "Увеличивает «Начислено» и «К выплате».",
},
{
kind: "adjustment_credit",
description:
"Доплата, которая исправляет или уточняет уже рассчитанные начисления.",
formula: "Прибавляется к «Корректировкам», «Начислено» и «К выплате».",
},
{
kind: "adjustment_debit",
description:
"Уменьшение, которое исправляет или уточняет уже рассчитанные начисления.",
formula: "Вычитается из «Корректировок», «Начислено» и «К выплате».",
},
{
kind: "deduction",
description:
"Сумма, которую удерживают из начисленного дохода. Само начисление при этом не меняется.",
formula: "Уменьшает «После удержаний» и «К выплате».",
},
{
kind: "payment",
description:
"Деньги, которые уже выданы сотруднику: например, аванс или фактическая выплата.",
formula:
"Уменьшает только остаток «К выплате», не меняя начисления и удержания.",
},
{
kind: "scheduled_change",
description:
"Внутренний маркер: сейчас ничего не меняет, а в следующем расчётном периоде один раз изменяет выбранную колонку.",
formula:
"Цель в следующем периоде = обычное значение цели + сумма всех маркеров.",
},
];
const periodScopeLabels: Record<PayrollComponentPeriodScope, string> = {
current_only: "Только текущий период",
current_and_future: "Текущий и будущие периоды",
all: "Все периоды",
};
const highlightClasses: Record<
PayrollColumnHighlightColor,
{ column: string; text: string }
> = {
"": { column: "", text: "" },
green: {
column: "!border-x-2 !border-emerald-500/80 bg-emerald-500/[0.035]",
text: "font-bold text-emerald-400",
},
red: {
column: "!border-x-2 !border-red-500/80 bg-red-500/[0.035]",
text: "font-bold text-red-400",
},
amber: {
column: "!border-x-2 !border-amber-500/80 bg-amber-500/[0.035]",
text: "font-bold text-amber-400",
},
blue: {
column: "!border-x-2 !border-blue-500/80 bg-blue-500/[0.035]",
text: "font-bold text-blue-400",
},
violet: {
column: "!border-x-2 !border-violet-500/80 bg-violet-500/[0.035]",
text: "font-bold text-violet-400",
},
};
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 REVIEW_MARKS_STORAGE_PREFIX =
"finances.payroll.period-table.review-marks";
const REVIEW_MARKS_CHANGE_EVENT = "payroll-period-table-review-marks-change";
const REVIEW_MARK_COLUMN_WIDTH = 36;
const EMPLOYEE_COLUMN_WIDTH = 150;
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",
paymentConfirmation: "payment_confirmation",
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,
};
const paymentConfirmationColumn: ColumnDefinition = {
id: columnIds.paymentConfirmation,
label: "Зарплата выдана",
group: "Факт выплаты",
width: 105,
};
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;
}
function usePayrollReviewMarks(
userId: number | null | undefined,
periodId: number | null | undefined,
) {
const storageKey = `${REVIEW_MARKS_STORAGE_PREFIX}.${userId || "default"}.${periodId || "unknown"}`;
const subscribe = useCallback(
(notify: () => void) => {
const handleStorage = (event: StorageEvent) => {
if (event.key === storageKey) notify();
};
window.addEventListener("storage", handleStorage);
window.addEventListener(REVIEW_MARKS_CHANGE_EVENT, notify);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(REVIEW_MARKS_CHANGE_EVENT, notify);
};
},
[storageKey],
);
const getSnapshot = useCallback(
() => window.localStorage.getItem(storageKey) || "[]",
[storageKey],
);
const serialized = useSyncExternalStore(subscribe, getSnapshot, () => "[]");
const reviewMarks = useMemo(
() => parseHiddenColumns(serialized),
[serialized],
);
const setReviewMarks = useCallback(
(next: Set<string>) => {
window.localStorage.setItem(storageKey, JSON.stringify([...next].sort()));
window.dispatchEvent(new Event(REVIEW_MARKS_CHANGE_EVENT));
},
[storageKey],
);
return [reviewMarks, setReviewMarks] as const;
}
type CalculationHelp = {
title: string;
logic: string;
formula: string;
manual: string;
};
function ColumnHeader({
children,
label,
help,
onOpen,
onBulkEdit,
onConfigure,
onHide,
dragHandle,
}: {
children: ReactNode;
label: string;
help?: CalculationHelp;
onOpen: (help: CalculationHelp) => void;
onBulkEdit?: () => void;
onConfigure?: () => 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="Заполнить колонку"
>
<Plus size={12} strokeWidth={2} />
</button>
) : null}
{onConfigure ? (
<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={onConfigure}
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,
className = "",
}: {
column: ColumnDefinition;
children: (dragHandle: ReactNode) => ReactNode;
className?: string;
}) {
const {
attributes,
isDragging,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: column.id });
return (
<th
ref={setNodeRef}
className={`${headerCellClass} whitespace-normal text-right ${className} ${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 whitespace-pre-line 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 whitespace-pre-line 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 PayrollComponentSettingsModal({
component,
rows,
components,
currency,
onSave,
onDelete,
onClose,
}: {
component?: PayrollPeriodTableComponent | null;
rows: PayrollPeriodTableRow[];
components: PayrollPeriodTableComponent[];
currency: string;
onSave: (payload: PayrollCustomComponentWrite) => Promise<void>;
onDelete?: (mode: "current" | "current_and_future") => Promise<void>;
onClose: () => void;
}) {
const [name, setName] = useState(component?.label || "");
const [kind, setKind] = useState<PayrollLineKind>(
component?.kind || "earning",
);
const [requiresReason, setRequiresReason] = useState(
component?.requires_reason || false,
);
const [includeInPointBase, setIncludeInPointBase] = useState(
component?.include_in_point_base || false,
);
const [includeInCarryover, setIncludeInCarryover] = useState(
component?.include_in_carryover || false,
);
const [visibleToEmployee, setVisibleToEmployee] = useState(
component?.visible_to_employee ?? true,
);
const [highlightColor, setHighlightColor] =
useState<PayrollColumnHighlightColor>(component?.highlight_color || "");
const [periodScope, setPeriodScope] = useState<PayrollComponentPeriodScope>(
component?.period_scope || "current_and_future",
);
const [scheduledTargetCode, setScheduledTargetCode] = useState(
component?.scheduled_target_component_code || "",
);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
const nameRef = useRef<HTMLInputElement>(null);
const helpOpenRef = useRef(false);
useEffect(() => {
helpOpenRef.current = helpOpen;
}, [helpOpen]);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
nameRef.current?.focus();
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
if (helpOpenRef.current) {
setHelpOpen(false);
return;
}
if (!pending) onClose();
};
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("keydown", closeOnEscape);
previouslyFocused?.focus();
};
}, [onClose, pending]);
const scheduledTargets = components.filter(
(item) => item.code !== component?.code && item.kind !== "scheduled_change",
);
const isScheduled = kind === "scheduled_change";
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const normalizedName = name.trim();
if (!normalizedName) {
setError("Укажите название колонки.");
return;
}
if (isScheduled && !scheduledTargetCode) {
setError("Выберите колонку, значение которой нужно изменить.");
return;
}
if (!isScheduled && pointBaseAssessment.negative.length) {
setError(
"Сохранение заблокировано: настройка создаёт отрицательную балльную базу.",
);
return;
}
setPending(true);
setError(null);
try {
await onSave({
name: normalizedName,
kind,
requires_reason: requiresReason,
include_in_point_base: isScheduled ? false : includeInPointBase,
include_in_carryover: isScheduled ? false : includeInCarryover,
visible_to_employee: isScheduled ? false : visibleToEmployee,
highlight_color: highlightColor,
period_scope: isScheduled ? "current_and_future" : periodScope,
scheduled_target_component_code: isScheduled ? scheduledTargetCode : "",
});
onClose();
} catch (createError) {
setError(
getPayrollAdminError(
createError,
component
? "Не удалось сохранить колонку."
: "Не удалось добавить колонку.",
),
);
} finally {
setPending(false);
}
};
const remove = async (mode: "current" | "current_and_future") => {
if (!onDelete) return;
setPending(true);
setError(null);
try {
await onDelete(mode);
onClose();
} catch (deleteError) {
setError(
getPayrollAdminError(deleteError, "Не удалось удалить колонку."),
);
} finally {
setPending(false);
}
};
const previewComponent: PayrollPeriodTableComponent = {
code: component?.code || "PREVIEW",
label: name.trim() || "Новая колонка",
kind,
display_order: component?.display_order || 0,
requires_reason: requiresReason,
include_in_point_base: isScheduled ? false : includeInPointBase,
include_in_carryover: isScheduled ? false : includeInCarryover,
visible_to_employee: isScheduled ? false : visibleToEmployee,
period_scope: isScheduled ? "current_and_future" : periodScope,
scope_period_id: component?.scope_period_id ?? null,
is_custom: component?.is_custom ?? true,
highlight_color: highlightColor,
scheduled_target_component_code: isScheduled ? scheduledTargetCode : "",
};
const calculationPreview = componentCalculationHelp(
previewComponent,
components,
);
const pointBaseAssessment = assessPayrollPointBaseChange(rows, components, {
code: component?.code || "PREVIEW",
kind,
include_in_point_base: includeInPointBase,
});
if (typeof document === "undefined") return null;
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={pending ? undefined : onClose}
aria-label={
component ? "Закрыть настройку колонки" : "Закрыть добавление колонки"
}
/>
<section
className="app-surface relative z-10 max-h-[calc(100vh-2rem)] w-full max-w-md overflow-y-auto rounded-2xl border border-[var(--border-subtle)] p-5 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="payroll-add-component-title"
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="app-text-muted text-xs uppercase tracking-wide">
Начисления и выплаты
</p>
<h3
id="payroll-add-component-title"
className="mt-1 inline-flex items-center gap-1.5 text-lg font-semibold text-[var(--foreground)]"
>
<span>
{component ? "Настроить колонку" : "Добавить колонку"}
</span>
<button
type="button"
onClick={() => setHelpOpen(true)}
className="app-accent-text inline-flex h-5 w-5 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)]"
aria-label="Открыть справку о типах колонок"
aria-haspopup="dialog"
aria-expanded={helpOpen}
title="Как тип колонки влияет на расчёт"
>
<CircleHelp size={14} strokeWidth={2} />
</button>
</h3>
</div>
<button
type="button"
className="app-action-ghost inline-flex h-8 w-8 items-center justify-center rounded-lg"
onClick={onClose}
disabled={pending}
aria-label="Закрыть"
>
<X size={17} />
</button>
</div>
<form className="mt-5 space-y-4" onSubmit={submit}>
<label className="block">
<span className="app-field-label">Название</span>
<input
ref={nameRef}
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={80}
className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm"
placeholder="Например, Компенсация питания"
disabled={pending}
/>
</label>
<label className="block">
<span className="app-field-label">Как влияет на расчёт</span>
<select
value={kind}
onChange={(event) =>
setKind(event.target.value as PayrollLineKind)
}
className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm"
disabled={pending}
>
{Object.entries(kindLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
{isScheduled ? (
<div className="app-surface-muted space-y-3 rounded-xl p-3">
<label className="block">
<span className="app-field-label">Какую колонку изменить</span>
<select
value={scheduledTargetCode}
onChange={(event) =>
setScheduledTargetCode(event.target.value)
}
className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm"
required
disabled={pending}
>
<option value="">Выберите колонку</option>
{scheduledTargets.map((item) => (
<option key={item.code} value={item.code}>
{item.label}
</option>
))}
</select>
</label>
<div className="app-surface rounded-lg px-3 py-2.5">
<span className="app-field-label">Когда применится</span>
<p className="mt-1 text-sm text-[var(--foreground)]">
Автоматически при создании следующего расчётного периода
</p>
<p className="app-text-muted mt-0.5 text-xs">
Создавать следующий период заранее не требуется.
</p>
</div>
<p className="app-text-muted text-xs leading-relaxed">
Маркер допускает плюс и минус, не участвует в текущем расчёте и
исчезает после применения. В целевой колонке останется отметка
об источнике изменения.
</p>
</div>
) : null}
<section
className="app-selected rounded-xl border border-[var(--border-subtle)] px-3 py-3"
aria-live="polite"
aria-label="Предпросмотр формулы колонки"
>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground)]">
Как работает сейчас
</p>
<button
type="button"
onClick={() => setHelpOpen(true)}
className="app-accent-text inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-xs font-medium transition hover:bg-[var(--surface-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
aria-label="Подробно показать текущую формулу"
>
<CircleHelp size={13} strokeWidth={2} />
Подробнее
</button>
</div>
<p className="app-text-muted mt-1.5 text-xs leading-relaxed">
{calculationPreview.logic}
</p>
<div className="app-surface mt-2 whitespace-pre-wrap rounded-lg px-2.5 py-2 font-mono text-[11px] leading-relaxed text-[var(--foreground)]">
{calculationPreview.formula}
</div>
</section>
<label className="block">
<span className="app-field-label">Цвет выделения колонки</span>
<select
value={highlightColor}
onChange={(event) =>
setHighlightColor(
event.target.value as PayrollColumnHighlightColor,
)
}
className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm"
disabled={pending}
>
<option value="">Без выделения</option>
<option value="green">Зелёный</option>
<option value="red">Красный</option>
<option value="amber">Янтарный</option>
<option value="blue">Синий</option>
<option value="violet">Фиолетовый</option>
</select>
</label>
<label className="block">
<span className="app-field-label">В каких периодах показывать</span>
<select
value={isScheduled ? "current_and_future" : periodScope}
onChange={(event) =>
setPeriodScope(
event.target.value as PayrollComponentPeriodScope,
)
}
className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm"
disabled={pending || isScheduled}
>
{Object.entries(periodScopeLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<span className="app-text-muted mt-1.5 block text-xs leading-relaxed">
Колонка появится согласно выбранной области; суммы между периодами
автоматически не копируются.
</span>
</label>
<label className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3">
<input
type="checkbox"
checked={requiresReason}
onChange={(event) => setRequiresReason(event.target.checked)}
className="mt-0.5 h-4 w-4 rounded"
disabled={pending}
/>
<span>
<span className="block text-sm font-medium text-[var(--foreground)]">
Требовать основание
</span>
<span className="app-text-muted mt-0.5 block text-xs">
При вводе операции пользователь должен будет указать причину.
</span>
</span>
</label>
<label className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3">
<input
type="checkbox"
checked={isScheduled ? false : includeInCarryover}
onChange={(event) => setIncludeInCarryover(event.target.checked)}
className="mt-0.5 h-4 w-4 rounded"
disabled={pending || isScheduled}
/>
<span>
<span className="block text-sm font-medium text-[var(--foreground)]">
Учитывать в переходящих остатках
</span>
<span className="app-text-muted mt-0.5 block text-xs">
Сумма войдёт в расчёт «Переносов». Значение ячейки в следующий
период не копируется.
</span>
</span>
</label>
<label className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3">
<input
type="checkbox"
checked={isScheduled ? false : includeInPointBase}
onChange={(event) => setIncludeInPointBase(event.target.checked)}
className="mt-0.5 h-4 w-4 rounded"
disabled={pending || isScheduled}
/>
<span>
<span className="block text-sm font-medium text-[var(--foreground)]">
Учитывать в балльной базе
</span>
<span className="app-text-muted mt-0.5 block text-xs">
Сумма изменяет цену балла и перерасчёт по фактическим баллам.
</span>
</span>
</label>
<label className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3">
<input
type="checkbox"
checked={isScheduled ? false : visibleToEmployee}
onChange={(event) => setVisibleToEmployee(event.target.checked)}
className="mt-0.5 h-4 w-4 rounded"
disabled={pending || isScheduled}
/>
<span>
<span className="block text-sm font-medium text-[var(--foreground)]">
Показывать сотруднику в расчётке
</span>
<span className="app-text-muted mt-0.5 block text-xs">
Отключите для внутренних служебных колонок.
</span>
</span>
</label>
{!isScheduled && pointBaseAssessment.negative.length ? (
<div
className="app-feedback-danger rounded-xl px-3 py-3 text-sm"
role="alert"
>
<p className="font-semibold">
Сохранение создаст отрицательную балльную базу у{" "}
{pointBaseAssessment.negative.length} сотрудника(ов).
</p>
<p className="mt-1 text-xs leading-relaxed">
При отрицательной базе цена балла и перерасчёт меняют смысл,
поэтому такая настройка недопустима.
</p>
<ul className="mt-2 space-y-1 text-xs">
{pointBaseAssessment.negative.slice(0, 5).map((item) => (
<li key={item.employeeId}>
{item.employeeName}: база{" "}
{formatMoney(item.pointBase, currency)}
</li>
))}
</ul>
</div>
) : !isScheduled && pointBaseAssessment.zero.length ? (
<div
className="app-feedback-warning rounded-xl px-3 py-3 text-sm"
role="status"
>
<p className="font-semibold">
У {pointBaseAssessment.zero.length} сотрудника(ов) балльная база
станет нулевой.
</p>
<p className="mt-1 text-xs leading-relaxed">
Сохранение разрешено, но цена балла и балльный перерасчёт для
них будут равны нулю.
</p>
</div>
) : null}
{error ? (
<div className="app-feedback-danger rounded-lg px-3 py-2.5 text-sm">
{error}
</div>
) : null}
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
className="app-action-secondary rounded-lg px-4 py-2.5 text-sm font-medium"
onClick={onClose}
disabled={pending}
>
Отмена
</button>
<button
type="submit"
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold"
disabled={
pending ||
(!isScheduled && pointBaseAssessment.negative.length > 0)
}
>
{pending ? (
<Loader2 className="animate-spin" size={15} />
) : component ? (
<Pencil size={15} />
) : (
<Plus size={15} />
)}
{component ? "Сохранить" : "Добавить"}
</button>
</div>
{component?.is_custom && onDelete ? (
<div className="border-t border-[var(--border-subtle)] pt-4">
<p className="app-text-muted mb-2 text-xs">
Удаление безопасно скрывает колонку, сохраняя прошлые расчёты.
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="app-action-secondary rounded-lg px-3 py-2 text-xs font-medium"
disabled={pending}
onClick={() => void remove("current")}
>
Удалить в текущем
</button>
<button
type="button"
className="app-feedback-danger rounded-lg px-3 py-2 text-xs font-medium"
disabled={pending}
onClick={() => void remove("current_and_future")}
>
Удалить в текущем и будущих
</button>
</div>
</div>
) : null}
</form>
</section>
{helpOpen ? (
<PayrollComponentKindsHelpModal
preview={calculationPreview}
onClose={() => setHelpOpen(false)}
/>
) : null}
</div>,
document.body,
);
}
function PayrollComponentKindsHelpModal({
preview,
onClose,
}: {
preview: CalculationHelp;
onClose: () => void;
}) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
closeButtonRef.current?.focus();
return () => previouslyFocused?.focus();
}, []);
return (
<div
className="fixed inset-0 z-[1010] 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 max-h-[calc(100vh-2rem)] w-full max-w-lg overflow-y-auto rounded-2xl border border-[var(--border-subtle)] p-5 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="payroll-component-kinds-help-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-component-kinds-help-title"
className="mt-1 text-lg font-semibold text-[var(--foreground)]"
>
Текущая формула «{preview.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>
<section
className="app-selected mt-5 rounded-xl border border-[var(--border-subtle)] p-3"
aria-live="polite"
>
<h4 className="text-sm font-semibold text-[var(--foreground)]">
Что изменится в расчёте
</h4>
<p className="app-text-muted mt-1 text-xs leading-relaxed">
{preview.logic}
</p>
<div className="app-surface mt-2 whitespace-pre-wrap rounded-lg px-2.5 py-2 font-mono text-[11px] leading-relaxed text-[var(--foreground)]">
{preview.formula}
</div>
</section>
<p className="app-text-muted mt-5 text-xs font-semibold uppercase tracking-wide">
Справочник типов
</p>
<div className="mt-2.5 space-y-2.5">
{kindHelpItems.map((item) => (
<section
key={item.kind}
className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3"
>
<h4 className="text-sm font-semibold text-[var(--foreground)]">
{kindLabels[item.kind]}
</h4>
<p className="app-text-muted mt-1 text-xs leading-relaxed">
{item.description}
</p>
<p className="mt-1.5 text-xs font-medium text-[var(--foreground)]">
{item.formula}
</p>
</section>
))}
</div>
<div className="app-surface-muted mt-4 rounded-xl px-3 py-2.5 text-xs leading-relaxed">
<span className="font-semibold">Итоговая формула: </span>К выплате =
Начислено − Удержано − Выплачено.
</div>
</section>
</div>
);
}
function calculationHelps(
pointPolicy: PayrollPeriodTable["calculation_rules"]["point_policy"],
): Record<string, CalculationHelp> {
const pointRecalculationLogic =
pointPolicy === "proportional_with_excess"
? "Баллы изменяют настроенную балльную базу. По умолчанию в неё входят оклад и премия; обе ручные корректировки изменяют итог фиксированной суммой. Участие любой колонки можно изменить в её настройках."
: pointPolicy === "excess_only"
? "Недовыполнение нормы не уменьшает начисление; оплачивается только превышение."
: "Перерасчёт по баллам отключён текущими правилами.";
const pointRecalculationFormula =
pointPolicy === "proportional_with_excess"
? "База = Оклад + Σ колонок с настройкой «Учитывать в балльной базе»\n\nЕсли цена сверх нормы пустая:\nПо баллам = (База ÷ Норма × Баллы) − База\n\nЕсли цена задана и Баллы > Норма:\nПо баллам = (Баллы − Норма) × Цена сверх нормы"
: pointPolicy === "excess_only"
? "По баллам = max(Баллы − Норма, 0) × Цена балла сверх нормы"
: "По баллам = 0";
return {
inNormPointRate: {
title: "Цена балла в норме",
logic:
"Расчётная стоимость одного балла. По умолчанию в балльную базу входят оклад и премия; состав базы изменяется в настройках колонок.",
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,
components: PayrollPeriodTableComponent[] = [],
): CalculationHelp {
if (component.kind === "scheduled_change") {
const target = components.find(
(item) => item.code === component.scheduled_target_component_code,
);
const targetLabel = target?.label || "выбранная колонка";
const periodLabel = "следующий период";
return {
title: component.label,
logic: `Это внутренний маркер. В текущем периоде он не меняет расчёт и не показывается сотруднику. В периоде «${periodLabel}» сумма один раз прибавится к колонке «${targetLabel}»; отрицательная сумма уменьшит её. Несколько маркеров складываются.`,
formula: `Текущий период: ${component.label} → без влияния\n\n${periodLabel}:\n${targetLabel} = обычное значение ${targetLabel} + Σ «${component.label}»\n\nПри штатном переносе в следующие периоды переносится уже полученное значение ${targetLabel}, сам маркер не переносится`,
manual:
"Да. В ячейке маркера задаётся итоговая знаковая сумма для сотрудника. После применения в целевой ячейке отображается отметка с источником изменения.",
};
}
const flow = {
earning: {
effect:
"увеличивает «До корректировок», затем «Начислено», «После удержаний» и остаток «К выплате»",
formula: `${component.label} → До корректировок (+) → Начислено → После удержаний → К выплате`,
},
adjustment_credit: {
effect:
"увеличивает «Корректировки», затем «Начислено», «После удержаний» и остаток «К выплате»",
formula: `${component.label} → Корректировки (+) → Начислено → После удержаний → К выплате`,
},
adjustment_debit: {
effect:
"уменьшает «Корректировки», затем «Начислено», «После удержаний» и остаток «К выплате»",
formula: `${component.label} → Корректировки (−) → Начислено → После удержаний → К выплате`,
},
deduction: {
effect:
"увеличивает «Удержано» и тем самым уменьшает «После удержаний» и остаток «К выплате»",
formula: `${component.label} → Удержано (+) → После удержаний (−) → К выплате`,
},
payment: {
effect:
"увеличивает «Выплачено» и тем самым уменьшает остаток «К выплате»; на начисленную зарплату не влияет",
formula: `${component.label} → Выплачено (+) → К выплате (−)`,
},
scheduled_change: {
effect: "не влияет на текущий период",
formula: `${component.label} → без влияния`,
},
}[component.kind];
const decreasesCarryover = ["earning", "adjustment_credit"].includes(
component.kind,
);
const decreasesPointBase = [
"adjustment_debit",
"deduction",
"payment",
].includes(component.kind);
const pointBaseEffect = component.include_in_point_base
? decreasesPointBase
? "В балльной базе вычитается: меняет цену балла и перерасчёт по фактическим баллам."
: "В балльной базе прибавляется: меняет цену балла и перерасчёт по фактическим баллам."
: "На балльную базу, цену балла и перерасчёт по баллам не влияет.";
const pointBaseFormula = component.include_in_point_base
? `Вклад в балльную базу: ${decreasesPointBase ? "−" : "+"} сумма «${component.label}»`
: `Балльная база: «${component.label}» не участвует`;
const carryoverEffect = component.include_in_carryover
? decreasesCarryover
? "Уменьшает переходящий остаток следующего периода; значение ячейки не копируется."
: "Увеличивает переходящий остаток следующего периода; значение ячейки не копируется."
: "Переходящий остаток следующего периода не меняет.";
const carryoverFormula = component.include_in_carryover
? decreasesCarryover
? `Остаток следующего периода = остаток без этой колонки − сумма «${component.label}»`
: `Остаток следующего периода = остаток без этой колонки + сумма «${component.label}»`
: `Остаток следующего периода: «${component.label}» не участвует`;
const employeeEffect = component.visible_to_employee
? "В расчётке сотрудника колонка видна."
: "Колонка внутренняя и в расчётке сотрудника скрыта; итоговые суммы сохраняются.";
const reasonEffect = component.requires_reason
? "При вводе обязательно основание."
: "Основание при вводе необязательно.";
return {
title: component.label,
logic: `Показана сумма всех действующих операций «${component.label}» сотрудника за период. Она ${flow.effect}. ${pointBaseEffect} ${carryoverEffect} ${employeeEffect} ${reasonEffect}`,
formula: `${component.label} = Σ операций этого вида за период\n\nИтоги текущего периода:\n${flow.formula}\n\nБалльный расчёт:\n${pointBaseFormula}\n\nПереходящие остатки:\n${carryoverFormula}`,
manual:
"Да. Введите итоговую сумму в ячейку: система создаст новый черновик или изменит существующий на нужную разницу. Утверждённая операция не переписывается — для исправления создаётся новая операция.",
};
}
function formatPoints(value: string | null): string {
return formatPayrollPoints(value);
}
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 absenceDaysLabel(count: number): string {
const mod100 = count % 100;
const mod10 = count % 10;
if (mod100 >= 11 && mod100 <= 14) return `${count} дней`;
if (mod10 === 1) return `${count} день`;
if (mod10 >= 2 && mod10 <= 4) return `${count} дня`;
return `${count} дней`;
}
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,
cellClassName = "",
}: {
value: string | null;
actualPoints: string | null;
label: string;
onOpen: () => void;
commentCount: number;
onOpenComment: () => void;
cellClassName?: string;
}) {
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 ${cellClassName}`}
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,
cellClassName = "",
annotation,
}: {
value: string | null;
formattedValue: string;
onSave?: (value: string) => Promise<void>;
label: string;
automatic?: boolean;
commentCount: number;
onOpenComment: () => void;
cellClassName?: string;
annotation?: { label: string; title: string };
}) {
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);
if (!onSave)
return (
<CommentableValueCell
className={`${automatic ? "text-[var(--muted-foreground)]" : ""} ${cellClassName}`}
commentCount={commentCount}
commentLabel={label}
onOpenComment={onOpenComment}
>
<span>{formattedValue}</span>
{annotation ? (
<span
className="app-accent-text ml-1 inline-block text-[9px] font-semibold"
title={annotation.title}
>
{annotation.label}
</span>
) : null}
</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 ${cellClassName}`}>
<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();
}
}}
/>
{annotation ? (
<span
className="app-accent-text pointer-events-none absolute bottom-0.5 left-1 text-[8px] font-semibold"
title={annotation.title}
>
{annotation.label}
</span>
) : null}
{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}`;
}
type CompactPeriodPanel = "summary" | "table" | "help";
export function PayrollPeriodTableView({
data,
loading,
error,
search,
onSearch,
storageUserId,
canEdit = false,
onBulkEditRate,
onBulkEditPointRate,
onBulkEditTargetPoints,
onSaveRate,
onSaveWork,
onSaveComponent,
onAddComponent,
onUpdateComponent,
onDeleteComponent,
onOpenCarryovers,
onSetPaymentConfirmed,
onSetReviewMark,
onClearReviewMarks,
compact = false,
}: {
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>;
onAddComponent?: (payload: PayrollCustomComponentWrite) => Promise<void>;
onUpdateComponent?: (
component: PayrollPeriodTableComponent,
payload: PayrollCustomComponentWrite,
) => Promise<void>;
onDeleteComponent?: (
component: PayrollPeriodTableComponent,
mode: "current" | "current_and_future",
) => Promise<void>;
onOpenCarryovers?: (employee?: PayrollPeriodTableRow["employee"]) => void;
onSetPaymentConfirmed?: (
row: PayrollPeriodTableRow,
confirmed: boolean,
) => Promise<void>;
onSetReviewMark?: (employeeId: number, checked: boolean) => Promise<void>;
onClearReviewMarks?: () => Promise<void>;
compact?: boolean;
}) {
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 [addComponentOpen, setAddComponentOpen] = useState(false);
const [editingComponent, setEditingComponent] =
useState<PayrollPeriodTableComponent | null>(null);
const [rowsMenuOpen, setRowsMenuOpen] = useState(false);
const [compactPanel, setCompactPanel] = useState<CompactPeriodPanel | null>(
"table",
);
const [paymentConfirmationBusyIds, setPaymentConfirmationBusyIds] = useState<
Set<number>
>(new Set());
const [paymentConfirmationError, setPaymentConfirmationError] = useState<
string | null
>(null);
const [reviewMarkBusyIds, setReviewMarkBusyIds] = useState<Set<number>>(
new Set(),
);
const [reviewMarkError, setReviewMarkError] = useState<string | null>(null);
const [clearingReviewMarks, setClearingReviewMarks] = 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 [cachedReviewMarks, setCachedReviewMarks] = usePayrollReviewMarks(
storageUserId,
data?.period_id,
);
const reviewMarks = data?.review_mark_employee_ids
? new Set(data.review_mark_employee_ids.map(String))
: cachedReviewMarks;
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,
...(data?.period_status === "closed" ? [paymentConfirmationColumn] : []),
carryoversColumn,
statusColumn,
],
[componentDefinitions, data?.period_status],
);
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 = 2 + 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 toggleReviewMark = async (employeeId: number) => {
if (reviewMarkBusyIds.has(employeeId)) return;
const key = String(employeeId);
const next = new Set(reviewMarks);
const checked = !next.has(key);
if (checked) next.add(key);
else next.delete(key);
setCachedReviewMarks(next);
if (!onSetReviewMark) return;
setReviewMarkError(null);
setReviewMarkBusyIds((current) => new Set(current).add(employeeId));
try {
await onSetReviewMark(employeeId, checked);
} catch (error) {
setCachedReviewMarks(reviewMarks);
setReviewMarkError(
error instanceof Error
? error.message
: "Не удалось сохранить контрольную отметку.",
);
} finally {
setReviewMarkBusyIds((current) => {
const nextBusy = new Set(current);
nextBusy.delete(employeeId);
return nextBusy;
});
}
};
const clearReviewMarks = async () => {
const previous = reviewMarks;
setCachedReviewMarks(new Set<string>());
if (!onClearReviewMarks) return;
setReviewMarkError(null);
setClearingReviewMarks(true);
try {
await onClearReviewMarks();
} catch (error) {
setCachedReviewMarks(previous);
setReviewMarkError(
error instanceof Error
? error.message
: "Не удалось очистить контрольные отметки.",
);
} finally {
setClearingReviewMarks(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 showSummary = !compact || compactPanel === "summary";
const showTable = !compact || compactPanel === "table";
const showHelp = !compact || compactPanel === "help";
const calculationIssueRows = data.rows.filter(
(row) => row.calculation_issue !== null,
);
const toggleCompactPanel = (panel: CompactPeriodPanel) => {
const nextPanel = compactPanel === panel ? null : panel;
if (nextPanel !== "table") {
setColumnsMenuOpen(false);
setRowsMenuOpen(false);
}
setCompactPanel(nextPanel);
};
const tableWidth =
REVIEW_MARK_COLUMN_WIDTH +
EMPLOYEE_COLUMN_WIDTH +
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 groupBoundaryColumnIds = new Set(
groupSegments
.slice(1)
.map((segment) => segment.columns[0]?.id)
.filter(Boolean),
);
const columnVisualClass = (
column: ColumnDefinition,
section: "header" | "body" | "footer",
) => {
const component = componentsByColumnId.get(column.id);
const sectionEdge =
section === "header"
? "!border-t-2"
: section === "footer"
? "!border-b-2"
: "";
if (component?.highlight_color)
return `${highlightClasses[component.highlight_color].column} ${sectionEdge}`;
return groupBoundaryColumnIds.has(column.id)
? "!border-l-2 !border-l-[var(--border-strong)]"
: "";
};
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) {
const accentClass = highlightClasses[component.highlight_color].text;
return (
<span
className={accentClass}
title={`${component.label} · ${kindLabels[component.kind]}`}
>
<ColumnHeader
label={component.label}
help={componentCalculationHelp(component, data.component_columns)}
onConfigure={
onUpdateComponent
? () => {
setEditingComponent(component);
setAddComponentOpen(true);
}
: undefined
}
{...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.paymentConfirmation:
return (
<ColumnHeader
label="Зарплата выдана"
help={{
title: "Зарплата выдана",
logic:
"Ручная отметка о том, что рассчитанная сумма «К выплате» фактически выдана сотруднику. Доступна только после закрытия периода.",
formula:
"Не участвует в расчёте зарплаты, выплат, корректировок или переходящих остатков.",
manual:
"Установите галочку после фактической выдачи денег. Система сохранит сотрудника, подтвердившего выплату, и время отметки.",
}}
{...common}
>
Зарплата выдана
</ColumnHeader>
);
case columnIds.carryovers:
return (
<ColumnHeader
label="Остаток к переносу"
onConfigure={
onOpenCarryovers ? () => onOpenCarryovers(undefined) : undefined
}
help={{
title: "Остаток к переносу",
logic:
"Накопленная разница из строк колонок с включённой настройкой «Учитывать в переходящих остатках». После публикации итог становится начальным остатком следующего периода той же валюты.\n\nЗначения ячеек при этом не копируются в колонки следующего периода. Плюс — накопленная недоплата, минус — накопленная переплата.",
formula:
"Остаток на конец = Остаток на начало\n+ учитываемые отрицательные корректировки, удержания и выплаты\n− учитываемые начисления и положительные корректировки\n\nОстаток следующего периода учитывает утверждённые строки предыдущих периодов",
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);
const cellVisualClass = columnVisualClass(column, "body");
if (component) {
const scheduledChanges = (row.applied_scheduled_changes || []).filter(
(item) => item.target_component_code === component.code,
);
const scheduledDelta = scheduledChanges.reduce(
(sum, item) => sum + Number(item.amount),
0,
);
const annotation = scheduledChanges.length
? {
label: `изм. ${scheduledDelta > 0 ? "+" : ""}${scheduledDelta}`,
title: scheduledChanges
.map(
(item) =>
`${item.source_component_name} (${item.source_period_code}): ${Number(item.amount) > 0 ? "+" : ""}${item.amount}`,
)
.join("\n"),
}
: undefined;
return (
<InlineEditableCell
key={`${row.employee.id}:${column.id}:${row.component_amounts[component.code] || ""}`}
cellClassName={cellVisualClass}
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}`}
annotation={annotation}
{...comments}
/>
);
}
switch (column.id) {
case columnIds.rateAmount:
return (
<InlineEditableCell
key={`${row.employee.id}:${column.id}:${row.rate_amount || ""}`}
cellClassName={cellVisualClass}
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
key={`${row.employee.id}:${column.id}:${row.in_norm_point_rate || ""}`}
cellClassName={cellVisualClass}
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
key={`${row.employee.id}:${column.id}:${row.point_rate ?? row.in_norm_point_rate ?? ""}`}
cellClassName={cellVisualClass}
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
key={`${row.employee.id}:${column.id}:${row.target_points || ""}`}
cellClassName={cellVisualClass}
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
cellClassName={cellVisualClass}
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
cellClassName={cellVisualClass}
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
key={`${row.employee.id}:${column.id}:${row.actual_points || ""}`}
cellClassName={cellVisualClass}
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
className={cellVisualClass}
commentLabel={`Отклонение: ${row.employee.display_name}`}
{...comments}
>
{formatPoints(row.point_delta)}
</CommentableValueCell>
);
case columnIds.pointAmount:
return (
<CommentableValueCell
className={cellVisualClass}
commentLabel={`По баллам: ${row.employee.display_name}`}
{...comments}
>
<CalculatedMoney
value={row.point_amount}
currency={data.currency}
preliminary={row.totals_preliminary}
/>
</CommentableValueCell>
);
case columnIds.grossBeforeAdjustments:
return (
<CommentableValueCell
className={cellVisualClass}
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
className={cellVisualClass}
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 ${cellVisualClass}`}
commentLabel={`Начислено: ${row.employee.display_name}`}
{...comments}
>
<CalculatedMoney
value={row.gross_total}
currency={data.currency}
preliminary={row.totals_preliminary}
/>
</CommentableValueCell>
);
case columnIds.deductions:
return (
<CommentableValueCell
className={cellVisualClass}
commentLabel={`Удержано: ${row.employee.display_name}`}
{...comments}
>
<CalculatedMoney
value={row.deduction_total}
currency={data.currency}
preliminary={row.totals_preliminary}
/>
</CommentableValueCell>
);
case columnIds.net:
return (
<CommentableValueCell
className={cellVisualClass}
commentLabel={`После удержаний: ${row.employee.display_name}`}
{...comments}
>
<CalculatedMoney
value={row.net_pay}
currency={data.currency}
preliminary={row.totals_preliminary}
/>
</CommentableValueCell>
);
case columnIds.payments:
return (
<CommentableValueCell
className={cellVisualClass}
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 ${cellVisualClass}`}
commentLabel={`К выплате: ${row.employee.display_name}`}
{...comments}
>
<CalculatedMoney
value={row.payable}
currency={data.currency}
preliminary={row.totals_preliminary}
/>
</CommentableValueCell>
);
case columnIds.paymentConfirmation: {
if (row.status !== "calculated" || row.totals_preliminary) {
return (
<td
className={`app-text-muted border-b border-r border-[var(--border-subtle)] px-2 py-2 text-center ${cellVisualClass}`}
title="Для сотрудника нет итогового расчётного листка"
>
—
</td>
);
}
const confirmation = row.payment_confirmation;
const paidThroughRegistry =
Number(row.off_cycle_settlement?.paid_outside_period || 0) > 0;
const fullyPaidThroughRegistry =
paidThroughRegistry &&
Number(row.off_cycle_settlement?.remaining) === 0;
const busy = paymentConfirmationBusyIds.has(row.employee.id);
const title =
fullyPaidThroughRegistry && !confirmation
? "Погашено через реестр выплат вне периода. Для исправления отмените запись в реестре."
: confirmation
? `Подтвердил: ${confirmation.confirmed_by.display_name} · ${new Date(confirmation.confirmed_at).toLocaleString("ru-RU")}`
: `Отметить фактическую выплату сотруднику ${row.employee.display_name}`;
return (
<td
className={`border-b border-r border-[var(--border-subtle)] px-2 py-2 text-center ${cellVisualClass}`}
>
<label
className={`inline-flex items-center justify-center gap-1.5 text-[10px] font-semibold ${onSetPaymentConfirmed ? "cursor-pointer" : "cursor-default"}`}
title={title}
>
{busy ? (
<Loader2 className="app-accent-text animate-spin" size={14} />
) : (
<input
type="checkbox"
checked={Boolean(confirmation) || fullyPaidThroughRegistry}
disabled={
!onSetPaymentConfirmed ||
(fullyPaidThroughRegistry && !confirmation)
}
onChange={(event) => {
if (!onSetPaymentConfirmed) return;
const confirmed = event.target.checked;
setPaymentConfirmationError(null);
setPaymentConfirmationBusyIds((current) =>
new Set(current).add(row.employee.id),
);
void onSetPaymentConfirmed(row, confirmed)
.catch((confirmationError: unknown) => {
setPaymentConfirmationError(
getPayrollAdminError(
confirmationError,
"Не удалось изменить отметку о выплате.",
),
);
})
.finally(() => {
setPaymentConfirmationBusyIds((current) => {
const next = new Set(current);
next.delete(row.employee.id);
return next;
});
});
}}
aria-label={`${confirmation ? "Снять" : "Установить"} отметку о выплате для ${row.employee.display_name}`}
/>
)}
<span
className={
confirmation || fullyPaidThroughRegistry
? "text-emerald-400"
: "app-text-muted"
}
>
{confirmation || fullyPaidThroughRegistry
? "Выдана"
: paidThroughRegistry
? "Частично"
: "Не выдана"}
</span>
</label>
{row.off_cycle_settlement ? (
<p className="app-text-muted mt-1 text-[10px]">
Через реестр:{" "}
{formatMoney(
row.off_cycle_settlement.paid_outside_period,
data.currency,
)}
<br />
Осталось:{" "}
{formatMoney(row.off_cycle_settlement.remaining, data.currency)}
</p>
) : null}
</td>
);
}
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 ${cellVisualClass}`}
>
<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: {
if (row.calculation_issue) {
return (
<td
className={`relative overflow-hidden border-b border-r border-[var(--border-subtle)] px-1 py-2 text-center ${cellVisualClass}`}
>
<CellCommentButton
count={comments.commentCount}
label={`Состояние: ${row.employee.display_name}`}
onClick={comments.onOpenComment}
/>
<span
className="app-status-pill app-feedback-danger px-2 text-[10px]"
title={`${row.calculation_issue.message} Балльная база: ${formatMoney(row.calculation_issue.point_base, data.currency)}.`}
>
Ошибка формулы
</span>
</td>
);
}
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 ${cellVisualClass}`}
>
<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
className={cellVisualClass}
commentLabel={`${column.label}: ${row.employee.display_name}`}
{...comments}
>
—
</CommentableValueCell>
);
}
};
const renderFooterCell = (column: ColumnDefinition) => {
const component = componentsByColumnId.get(column.id);
const footerCellClass = `${bodyCellClass} ${columnVisualClass(column, "footer")}`;
if (component)
return (
<td className={footerCellClass}>
{formatMoney(
sumRows(
rows,
(row) => row.component_amounts[component.code] || null,
),
data.currency,
)}
</td>
);
switch (column.id) {
case columnIds.rateAmount:
return (
<td className={footerCellClass}>
{formatMoney(total("rate_amount"), data.currency)}
</td>
);
case columnIds.inNormPointRate:
case columnIds.excessPointRate:
return <td className={footerCellClass}>—</td>;
case columnIds.targetPoints:
return (
<td className={footerCellClass}>
{formatPoints(total("target_points"))}
</td>
);
case columnIds.attendancePoints:
return (
<td className={footerCellClass}>
{formatPoints(total("attendance_points"))}
</td>
);
case columnIds.personnelPoints:
return (
<td className={footerCellClass}>
{formatPoints(total("personnel_points"))}
</td>
);
case columnIds.actualPoints:
return (
<td className={footerCellClass}>
{formatPoints(total("actual_points"))}
</td>
);
case columnIds.pointDelta:
return (
<td className={footerCellClass}>
{formatPoints(total("point_delta"))}
</td>
);
case columnIds.pointAmount:
return (
<td className={footerCellClass}>
{formatMoney(total("point_amount"), data.currency)}
</td>
);
case columnIds.grossBeforeAdjustments:
return (
<td className={footerCellClass}>
{formatMoney(total("gross_before_adjustments"), data.currency)}
</td>
);
case columnIds.adjustments:
return (
<td className={footerCellClass}>
{formatMoney(total("adjustment_total"), data.currency)}
</td>
);
case columnIds.gross:
return (
<td className={footerCellClass}>
{formatMoney(total("gross_total"), data.currency)}
</td>
);
case columnIds.deductions:
return (
<td className={footerCellClass}>
{formatMoney(total("deduction_total"), data.currency)}
</td>
);
case columnIds.net:
return (
<td className={footerCellClass}>
{formatMoney(total("net_pay"), data.currency)}
</td>
);
case columnIds.payments:
return (
<td className={footerCellClass}>
{formatMoney(total("payment_total"), data.currency)}
</td>
);
case columnIds.payable:
return (
<td className={footerCellClass}>
{formatMoney(total("payable"), data.currency)}
</td>
);
case columnIds.paymentConfirmation:
return (
<td className={`${footerCellClass} text-center`}>
{
rows.filter(
(row) =>
row.payment_confirmation ||
(Number(row.off_cycle_settlement?.paid_outside_period || 0) >
0 &&
Number(row.off_cycle_settlement?.remaining) === 0),
).length
}
/
{
rows.filter(
(row) => row.status === "calculated" && !row.totals_preliminary,
).length
}
</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={`${footerCellClass} text-center`}>
{formatSignedMoney(balance, data.currency)}
</td>
);
}
case columnIds.status:
default:
return (
<td
className={`border-r border-[var(--border-subtle)] px-3 py-3 ${columnVisualClass(column, "footer")}`}
/>
);
}
};
return (
<div className="min-w-0 max-w-full">
{compact ? (
<div
className="mb-3 grid grid-cols-3 gap-2"
aria-label="Разделы итоговой таблицы"
>
<button
type="button"
className={`${compactPanel === "summary" ? "app-selected app-accent-text" : "app-action-secondary"} inline-flex min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 py-2 text-xs font-medium`}
onClick={() => toggleCompactPanel("summary")}
aria-pressed={compactPanel === "summary"}
>
<ChartNoAxesColumn size={14} />
<span className="truncate">Сводка</span>
</button>
<button
type="button"
className={`${compactPanel === "table" ? "app-selected app-accent-text" : "app-action-secondary"} inline-flex min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 py-2 text-xs font-medium`}
onClick={() => toggleCompactPanel("table")}
aria-pressed={compactPanel === "table"}
>
<Table2 size={14} />
<span className="truncate">Таблица</span>
</button>
<button
type="button"
className={`${compactPanel === "help" ? "app-selected app-accent-text" : "app-action-secondary"} inline-flex min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 py-2 text-xs font-medium`}
onClick={() => toggleCompactPanel("help")}
aria-pressed={compactPanel === "help"}
>
<CircleHelp size={14} />
<span className="truncate">Справка</span>
</button>
</div>
) : null}
{(showSummary || showTable) && data.recorded_payments ? (
<div className="app-surface-muted mb-4 grid gap-3 rounded-xl p-4 sm:grid-cols-3">
{[
[
"Выплаты в расчёте периода",
data.recorded_payments.payroll_recorded,
],
["Выдано вне периода", data.recorded_payments.outside_period],
["Всего учтено выплат", data.recorded_payments.total_recorded],
].map(([label, value]) => (
<div key={label}>
<p className="app-text-muted text-xs">{label}</p>
<p className="mt-1 text-lg font-semibold">
{formatMoney(value, data.currency)}
</p>
</div>
))}
<p className="app-text-muted text-xs sm:col-span-3">
Общий учёт за весь период, без фильтров таблицы. Выдачи вне периода
учтены по фактической дате и не меняют начисленную зарплату.
</p>
</div>
) : null}
{showSummary || showTable ? (
<div
className={`${compact ? "" : "flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between"} mb-3`}
>
{showSummary ? (
<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>
) : null}
{showTable ? (
<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>
) : null}
</div>
) : null}
{paymentConfirmationError ? (
<div className="app-feedback-danger mb-3 rounded-lg px-3 py-2.5 text-sm">
{paymentConfirmationError}
</div>
) : null}
{reviewMarkError ? (
<div className="app-feedback-danger mb-3 rounded-lg px-3 py-2.5 text-sm">
{reviewMarkError}
</div>
) : null}
{calculationIssueRows.length ? (
<div
className="app-feedback-danger mb-3 flex items-start gap-2 rounded-lg px-3 py-2.5 text-sm"
role="alert"
>
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
<div>
<p className="font-semibold">
Балльный перерасчёт остановлен для {calculationIssueRows.length}{" "}
сотрудника(ов).
</p>
<p className="mt-1 text-xs leading-relaxed">
Исправьте значения или параметры участвующих в балльной базе
колонок. Остальная таблица доступна, проблемные строки отмечены
«Ошибка формулы».
</p>
</div>
</div>
) : null}
{showHelp && data.summary.preliminary_count ? (
<div className="app-feedback-warning mb-3 rounded-lg px-3 py-2.5 text-sm">
Значения рассчитаны предварительно по текущим данным. Они обновляются
после сохранения ячеек и станут официальными после запуска расчёта.
</div>
) : null}
{showHelp ? (
<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">
<span
className="h-3 w-3 rounded-sm border-2 border-emerald-500"
aria-hidden="true"
/>
Цветная рамка — выделение, выбранное в настройках колонки
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="h-3 border-l-2 border-[var(--border-strong)]"
aria-hidden="true"
/>
Толстая линия — граница блока
</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>
) : null}
{showTable ? (
<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: REVIEW_MARK_COLUMN_WIDTH }} />
<col style={{ width: EMPLOYEE_COLUMN_WIDTH }} />
{visibleColumns.map((column) => (
<col key={column.id} style={{ width: column.width }} />
))}
</colgroup>
<thead className="sticky top-0 z-40">
<tr>
<th
rowSpan={2}
className={`${headerCellClass} sticky left-0 z-50 !px-0 text-center align-middle`}
>
<button
type="button"
className="app-action-ghost inline-flex h-7 w-7 items-center justify-center rounded-md disabled:cursor-default disabled:opacity-35"
onClick={() => void clearReviewMarks()}
disabled={reviewMarks.size === 0 || clearingReviewMarks}
aria-label="Очистить все контрольные отметки"
title="Очистить все"
>
<RotateCcw size={13} />
</button>
</th>
<th
rowSpan={2}
className={`${headerCellClass} sticky z-40 align-middle`}
style={{ left: REVIEW_MARK_COLUMN_WIDTH }}
>
Сотрудник
</th>
{groupSegments.map((segment, index) => (
<th
key={`${segment.group}-${index}`}
colSpan={segment.columns.length}
className={`${headerCellClass} text-center uppercase tracking-wide ${index > 0 ? "!border-l-2 !border-l-[var(--border-strong)]" : ""}`}
>
<span className="inline-flex items-center justify-center gap-1.5">
<span>{segment.group}</span>
{onAddComponent &&
segment.group === "Начисления и выплаты" &&
groupSegments.findIndex(
(item) => item.group === segment.group,
) === index ? (
<button
type="button"
className="app-action-ghost inline-flex h-5 w-5 items-center justify-center rounded-md text-[var(--accent-primary)]"
onClick={() => {
setEditingComponent(null);
setAddComponentOpen(true);
}}
aria-label="Добавить колонку начисления или выплаты"
title="Добавить колонку"
>
<Plus size={13} strokeWidth={2.5} />
</button>
) : null}
</span>
</th>
))}
</tr>
<SortableContext
items={visibleColumnIds}
strategy={horizontalListSortingStrategy}
>
<tr>
{visibleColumns.map((column) => (
<SortableColumnHeader
key={column.id}
column={column}
className={columnVisualClass(column, "header")}
>
{(dragHandle) => renderColumnHeader(column, dragHandle)}
</SortableColumnHeader>
))}
</tr>
</SortableContext>
</thead>
<tbody>
{rows.length ? (
rows.map((row) => (
<tr
key={row.employee.id}
className={
row.payment_confirmation
? "bg-emerald-500/[0.08] hover:bg-emerald-500/[0.12]"
: "bg-[var(--surface-primary)] hover:bg-[var(--surface-secondary)]"
}
>
<td
className={`sticky left-0 z-30 border-b border-r border-[var(--border-subtle)] px-0 text-center ${row.payment_confirmation ? "bg-emerald-500/[0.12] backdrop-blur-xl" : "bg-[var(--surface-primary)]"}`}
>
<input
type="checkbox"
checked={reviewMarks.has(String(row.employee.id))}
onChange={() =>
void toggleReviewMark(row.employee.id)
}
disabled={
clearingReviewMarks ||
reviewMarkBusyIds.has(row.employee.id)
}
className="h-4 w-4 cursor-pointer rounded border-[var(--border-strong)] accent-[var(--accent-primary)]"
aria-label={`Контрольная отметка: ${formatEmployeeName(row.employee)}`}
title="Контрольная отметка"
/>
</td>
<td
className={`sticky z-20 overflow-hidden border-b border-r border-[var(--border-subtle)] px-2 py-2.5 ${row.payment_confirmation ? "bg-emerald-500/[0.12] backdrop-blur-xl" : "bg-[var(--surface-primary)]"}`}
style={{ left: REVIEW_MARK_COLUMN_WIDTH }}
>
<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>
{row.attendance_alert ? (
<button
type="button"
className={`mt-1 inline-flex max-w-full items-center gap-1 rounded-md border px-1.5 py-0.5 text-[9px] font-medium leading-3 transition-colors ${row.attendance_alert.points_in_norm ? "border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20" : "border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20"}`}
onClick={() =>
setBreakdownEmployee({
id: row.employee.id,
displayName: row.employee.display_name,
})
}
title={
row.attendance_alert.points_in_norm
? `Баллы за период в норме, но обнаружено неподтверждённое отсутствие: ${absenceDaysLabel(row.attendance_alert.count)}. Открыть даты.`
: `Обнаружено неподтверждённое отсутствие: ${absenceDaysLabel(row.attendance_alert.count)}. Открыть даты.`
}
aria-label={`Неподтверждённое отсутствие, ${absenceDaysLabel(row.attendance_alert.count)}. Открыть детализацию`}
>
<AlertTriangle className="shrink-0" size={10} />
<span className="truncate">
{absenceDaysLabel(row.attendance_alert.count)} без
посещения
</span>
</button>
) : null}
</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-30 border-r border-[var(--border-subtle)] bg-[var(--surface-secondary)]" />
<td
className="sticky z-20 border-r border-[var(--border-subtle)] bg-[var(--surface-secondary)] px-3 py-3 text-xs text-[var(--foreground)]"
style={{ left: REVIEW_MARK_COLUMN_WIDTH }}
>
Итого · {rows.length}
</td>
{visibleColumns.map((column) => (
<Fragment key={column.id}>
{renderFooterCell(column)}
</Fragment>
))}
</tr>
</tfoot>
) : null}
</table>
</div>
</DndContext>
) : null}
{activeHelp ? (
<CalculationHelpModal
help={activeHelp}
rules={data.calculation_rules}
onClose={() => setActiveHelp(null)}
/>
) : null}
{addComponentOpen && onAddComponent ? (
<PayrollComponentSettingsModal
component={editingComponent}
rows={data.rows}
components={data.component_columns}
currency={data.currency}
onSave={(payload) =>
editingComponent && onUpdateComponent
? onUpdateComponent(editingComponent, payload)
: onAddComponent(payload)
}
onDelete={
editingComponent?.is_custom && onDeleteComponent
? (mode) => onDeleteComponent(editingComponent, mode)
: undefined
}
onClose={() => {
setAddComponentOpen(false);
setEditingComponent(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>
);
}