3895 lines
156 KiB
TypeScript
3895 lines
156 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 {
|
||
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>
|
||
);
|
||
}
|