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