feat(finance): add payroll point source details
This commit is contained in:
parent
f7ebc7b00f
commit
5946173a07
@ -87,6 +87,19 @@ def _records_visible_to(user):
|
||||
return queryset.filter(employee_id=user.id)
|
||||
|
||||
|
||||
def _day_event_records_visible_to(user):
|
||||
"""Allow payroll viewers to inspect evidence used by point projections."""
|
||||
|
||||
queryset = AttendanceRecord.objects.all()
|
||||
if (
|
||||
user.is_staff
|
||||
or user.is_superuser
|
||||
or user.has_perm("finance.view_all_payroll")
|
||||
):
|
||||
return queryset
|
||||
return queryset.filter(employee_id=user.id)
|
||||
|
||||
|
||||
def _check_staff_access(user) -> None:
|
||||
if not (user and user.is_authenticated and (user.is_staff or user.is_superuser)):
|
||||
raise PermissionDenied("You don't have permission to manage attendance")
|
||||
@ -662,7 +675,7 @@ class AttendanceRecordDayEventsAPIView(APIView):
|
||||
|
||||
def get_record(self, request, record_id):
|
||||
return get_object_or_404(
|
||||
_records_visible_to(request.user).select_related("employee"),
|
||||
_day_event_records_visible_to(request.user).select_related("employee"),
|
||||
pk=record_id,
|
||||
)
|
||||
|
||||
@ -708,7 +721,7 @@ class AttendanceRecordDayEventPhotoAPIView(APIView):
|
||||
|
||||
def get_record(self, request, record_id):
|
||||
return get_object_or_404(
|
||||
_records_visible_to(request.user).select_related("employee"),
|
||||
_day_event_records_visible_to(request.user).select_related("employee"),
|
||||
pk=record_id,
|
||||
)
|
||||
|
||||
|
||||
@ -55,7 +55,7 @@ def resolve_employee_personnel_state(
|
||||
relevant_actions = [
|
||||
action
|
||||
for action in employee_actions
|
||||
if action.date and action.date.date() <= target_date
|
||||
if _action_date(action) is not None and _action_date(action) <= target_date
|
||||
]
|
||||
if not relevant_actions:
|
||||
return EmployeePersonnelState()
|
||||
@ -66,7 +66,7 @@ def resolve_employee_personnel_state(
|
||||
status=ACTION_DISMISSED,
|
||||
label=ACTION_LABELS[ACTION_DISMISSED],
|
||||
action_id=permanent_action.id,
|
||||
date_from=permanent_action.date.date(),
|
||||
date_from=_action_date(permanent_action),
|
||||
expects_attendance=False,
|
||||
)
|
||||
|
||||
@ -77,7 +77,7 @@ def resolve_employee_personnel_state(
|
||||
status=action.action,
|
||||
label=action.get_action_display(),
|
||||
action_id=action.id,
|
||||
date_from=action.date.date(),
|
||||
date_from=_action_date(action),
|
||||
date_to=date_to,
|
||||
expects_attendance=False,
|
||||
)
|
||||
@ -88,7 +88,7 @@ def resolve_employee_personnel_state(
|
||||
status=marker_action.action,
|
||||
label=marker_action.get_action_display(),
|
||||
action_id=marker_action.id,
|
||||
date_from=marker_action.date.date(),
|
||||
date_from=_action_date(marker_action),
|
||||
expects_attendance=True,
|
||||
)
|
||||
|
||||
@ -97,7 +97,7 @@ def resolve_employee_personnel_state(
|
||||
status=permanent_action.action,
|
||||
label=permanent_action.get_action_display(),
|
||||
action_id=permanent_action.id,
|
||||
date_from=permanent_action.date.date(),
|
||||
date_from=_action_date(permanent_action),
|
||||
)
|
||||
|
||||
return EmployeePersonnelState(
|
||||
@ -209,7 +209,7 @@ def _active_marker_action(
|
||||
for action in actions
|
||||
if action.action in MARKER_ACTIONS
|
||||
and action.date
|
||||
and action.date.date() == target_date
|
||||
and _action_date(action) == target_date
|
||||
]
|
||||
return marker_actions[-1] if marker_actions else None
|
||||
|
||||
@ -223,7 +223,9 @@ def _active_temporary_actions(
|
||||
if action.action not in TEMPORARY_START_ACTIONS or not action.date:
|
||||
continue
|
||||
|
||||
date_from = action.date.date()
|
||||
date_from = _action_date(action)
|
||||
if date_from is None:
|
||||
continue
|
||||
if date_from > target_date:
|
||||
continue
|
||||
|
||||
@ -247,7 +249,7 @@ def _resolve_temporary_action_date_to(action: EmployeeAction) -> date | None:
|
||||
if action.date_to:
|
||||
return action.date_to
|
||||
if action.date:
|
||||
return action.date.date()
|
||||
return _action_date(action)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@ -9,11 +9,15 @@ from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Prefetch, Q
|
||||
from django.utils import timezone
|
||||
from payroll_core import DeterministicPayrollCalculator
|
||||
|
||||
from attendance.models import AttendanceRecord
|
||||
from employees.constants import ACTIVATING_MARKER_ACTIONS
|
||||
from employees.models import EmployeeAction
|
||||
from employees.services.personnel_state import resolve_employee_personnel_state
|
||||
from requests_app.enums import RequestStatus
|
||||
from requests_app.models import Request
|
||||
|
||||
from finance.enums import ApprovalStatus
|
||||
from finance.models import (
|
||||
@ -491,6 +495,33 @@ def build_payroll_employee_point_breakdown(
|
||||
.select_related("source_request")
|
||||
.order_by("date", "id")
|
||||
)
|
||||
employment_start_dates = [
|
||||
timezone.localtime(action.date).date()
|
||||
for action in actions
|
||||
if action.action in ACTIVATING_MARKER_ACTIONS and action.date
|
||||
]
|
||||
first_employment_date = (
|
||||
min(employment_start_dates) if employment_start_dates else None
|
||||
)
|
||||
requests = list(
|
||||
Request.objects.filter(employee=employee)
|
||||
.filter(
|
||||
Q(
|
||||
date_from__lte=period.date_to,
|
||||
date_to__gte=period.date_from,
|
||||
)
|
||||
| Q(
|
||||
date_from__range=(period.date_from, period.date_to),
|
||||
date_to__isnull=True,
|
||||
)
|
||||
| Q(
|
||||
date_from__isnull=True,
|
||||
created_at__date__range=(period.date_from, period.date_to),
|
||||
)
|
||||
)
|
||||
.select_related("approver")
|
||||
.order_by("date_from", "created_at", "id")
|
||||
)
|
||||
schedule, _ = resolve_employee_schedule(employee)
|
||||
workdates = period_workdates(period, schedule)
|
||||
workdate_set = set(workdates)
|
||||
@ -532,13 +563,16 @@ def build_payroll_employee_point_breakdown(
|
||||
schedule=schedule,
|
||||
)
|
||||
work_entries = {
|
||||
entry.work_date: entry.actual_points
|
||||
entry.work_date: entry
|
||||
for entry in PayrollDailyWorkEntry.objects.filter(
|
||||
period=period,
|
||||
employee=employee,
|
||||
)
|
||||
}
|
||||
dated_work_points_total = sum(work_entries.values(), Decimal("0"))
|
||||
dated_work_points_total = sum(
|
||||
(entry.actual_points for entry in work_entries.values()),
|
||||
Decimal("0"),
|
||||
)
|
||||
undated_work_points = (
|
||||
(actual_points - dated_work_points_total).quantize(Decimal("0.0001"))
|
||||
if actual_points is not None
|
||||
@ -548,7 +582,10 @@ def build_payroll_employee_point_breakdown(
|
||||
undated_work_points = None
|
||||
if work_entries:
|
||||
work_points_mode = "daily_entries"
|
||||
work_points = work_entries
|
||||
work_points = {
|
||||
workdate: entry.actual_points
|
||||
for workdate, entry in work_entries.items()
|
||||
}
|
||||
else:
|
||||
work_points_mode = "unavailable"
|
||||
work_points = {}
|
||||
@ -565,6 +602,99 @@ def build_payroll_employee_point_breakdown(
|
||||
if attendance_record is not None
|
||||
else None
|
||||
)
|
||||
personnel_state = resolve_employee_personnel_state(
|
||||
employee,
|
||||
current,
|
||||
actions=actions,
|
||||
)
|
||||
related_actions = []
|
||||
for action in actions:
|
||||
action_date = (
|
||||
timezone.localtime(action.date).date() if action.date else None
|
||||
)
|
||||
if action_date is None:
|
||||
continue
|
||||
action_is_related = action_date == current or (
|
||||
action.date_to is not None
|
||||
and action_date <= current <= action.date_to
|
||||
)
|
||||
if personnel_state.action_id == action.pk:
|
||||
action_is_related = True
|
||||
if not action_is_related:
|
||||
continue
|
||||
related_actions.append(
|
||||
{
|
||||
"id": action.pk,
|
||||
"action": action.action,
|
||||
"label": action.get_action_display(),
|
||||
"date": action_date.isoformat(),
|
||||
"date_to": action.date_to.isoformat() if action.date_to else None,
|
||||
"comment": action.comment,
|
||||
"source_request_id": action.source_request_id,
|
||||
}
|
||||
)
|
||||
related_requests = []
|
||||
for personnel_request in requests:
|
||||
request_date_from = personnel_request.date_from
|
||||
request_date_to = personnel_request.date_to or request_date_from
|
||||
request_is_related = bool(
|
||||
request_date_from
|
||||
and request_date_to
|
||||
and request_date_from <= current <= request_date_to
|
||||
)
|
||||
if request_date_from is None:
|
||||
request_is_related = (
|
||||
timezone.localtime(personnel_request.created_at).date() == current
|
||||
)
|
||||
if not request_is_related:
|
||||
continue
|
||||
related_requests.append(
|
||||
{
|
||||
"id": personnel_request.pk,
|
||||
"type": personnel_request.type,
|
||||
"type_label": personnel_request.get_type_display(),
|
||||
"title": personnel_request.display_title,
|
||||
"status": personnel_request.status,
|
||||
"status_label": personnel_request.get_status_display(),
|
||||
"date_from": (
|
||||
personnel_request.date_from.isoformat()
|
||||
if personnel_request.date_from
|
||||
else None
|
||||
),
|
||||
"date_to": (
|
||||
personnel_request.date_to.isoformat()
|
||||
if personnel_request.date_to
|
||||
else None
|
||||
),
|
||||
"comment": personnel_request.comment,
|
||||
}
|
||||
)
|
||||
attendance_payload = None
|
||||
if attendance_record is not None:
|
||||
attendance_payload = {
|
||||
"id": attendance_record.pk,
|
||||
"arrival_time": attendance_record.arrival_time,
|
||||
"departure_time": attendance_record.departure_time,
|
||||
"work_hours": attendance_record.work_hours,
|
||||
"expected_hours": attendance_record.expected_hours,
|
||||
"status_label": (
|
||||
attendance_record.personnel_status_label
|
||||
or ("Отсутствие" if attendance_record.is_absent else "Рабочий день")
|
||||
),
|
||||
"issues": list(
|
||||
dict.fromkeys(
|
||||
str(item)
|
||||
for item in [
|
||||
*(attendance_record.statuses or []),
|
||||
*(attendance_record.employee_issues or []),
|
||||
*(attendance_record.technical_issues or []),
|
||||
]
|
||||
if str(item).strip()
|
||||
)
|
||||
),
|
||||
"is_manually_edited": attendance_record.is_manually_edited,
|
||||
}
|
||||
work_entry = work_entries.get(current)
|
||||
dates.append(
|
||||
{
|
||||
"date": current.isoformat(),
|
||||
@ -572,6 +702,38 @@ def build_payroll_employee_point_breakdown(
|
||||
"attendance_points": _decimal_text(attendance_day_points),
|
||||
"personnel_points": _decimal_text(personnel_points.get(current)),
|
||||
"work_points": _decimal_text(work_points.get(current)),
|
||||
"attendance_record": attendance_payload,
|
||||
"personnel_detail": {
|
||||
"state": (
|
||||
{
|
||||
"status": "before_employment",
|
||||
"label": "До приёма на работу",
|
||||
"expects_attendance": False,
|
||||
}
|
||||
if first_employment_date is not None
|
||||
and current < first_employment_date
|
||||
else {
|
||||
"status": personnel_state.status,
|
||||
"label": personnel_state.label
|
||||
or "Обычный рабочий день",
|
||||
"expects_attendance": personnel_state.expects_attendance,
|
||||
}
|
||||
),
|
||||
"actions": related_actions,
|
||||
"requests": related_requests,
|
||||
},
|
||||
"work_entry": (
|
||||
{
|
||||
"id": work_entry.pk,
|
||||
"target_points": _decimal_text(work_entry.target_points),
|
||||
"actual_points": _decimal_text(work_entry.actual_points),
|
||||
"note": work_entry.note,
|
||||
"created_at": work_entry.created_at.isoformat(),
|
||||
"updated_at": work_entry.updated_at.isoformat(),
|
||||
}
|
||||
if work_entry is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
current += timedelta(days=1)
|
||||
@ -587,6 +749,17 @@ def build_payroll_employee_point_breakdown(
|
||||
"target_points": _decimal_text(target_points),
|
||||
"actual_points": _decimal_text(actual_points),
|
||||
"undated_work_points": _decimal_text(undated_work_points),
|
||||
"undated_work_record": (
|
||||
{
|
||||
"id": work.pk,
|
||||
"actual_points": _decimal_text(undated_work_points),
|
||||
"note": work.reason,
|
||||
"created_at": work.created_at.isoformat(),
|
||||
"updated_at": work.updated_at.isoformat(),
|
||||
}
|
||||
if undated_work_points is not None and work is not None
|
||||
else None
|
||||
),
|
||||
"work_points_mode": work_points_mode,
|
||||
"dates": dates,
|
||||
}
|
||||
|
||||
@ -115,7 +115,7 @@ def calculate_period_personnel_points(
|
||||
schedule, _ = resolve_employee_schedule(employee)
|
||||
personnel_actions = list(actions)
|
||||
employment_start_dates = [
|
||||
action.date.date()
|
||||
timezone.localtime(action.date).date()
|
||||
for action in personnel_actions
|
||||
if action.action in ACTIVATING_MARKER_ACTIONS and action.date
|
||||
]
|
||||
@ -162,7 +162,7 @@ def calculate_period_personnel_daily_points(
|
||||
schedule, _ = resolve_employee_schedule(employee)
|
||||
personnel_actions = list(actions)
|
||||
employment_start_dates = [
|
||||
action.date.date()
|
||||
timezone.localtime(action.date).date()
|
||||
for action in personnel_actions
|
||||
if action.action in ACTIVATING_MARKER_ACTIONS and action.date
|
||||
]
|
||||
|
||||
@ -3,6 +3,7 @@ from io import BytesIO
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
@ -994,6 +995,39 @@ def test_user_cannot_load_other_employee_attendance_day_events(
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_payroll_viewer_can_load_source_attendance_day_events(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
):
|
||||
staff = user_factory(staff=True)
|
||||
payroll_viewer = user_factory(staff=False)
|
||||
employee = user_factory()
|
||||
permission = Permission.objects.get(
|
||||
content_type__app_label="finance",
|
||||
codename="view_all_payroll",
|
||||
)
|
||||
payroll_viewer.user_permissions.add(permission)
|
||||
staff_client = auth_client_factory(staff)
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value=_logstorm_result(),
|
||||
):
|
||||
staff_client.post(_analyze_url(), _payload(employee.id), format="json")
|
||||
|
||||
record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20")
|
||||
with patch(
|
||||
"api.v1.attendance.views.LogStormClient.get_attendance_day_events",
|
||||
return_value=[],
|
||||
):
|
||||
response = auth_client_factory(payroll_viewer).get(
|
||||
_record_day_events_url(record.id)
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
def test_attendance_day_events_logstorm_error_returns_502(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
|
||||
@ -497,21 +497,53 @@ def test_period_point_breakdown_returns_daily_sources(
|
||||
june_first = next(item for item in body["dates"] if item["date"] == "2026-06-01")
|
||||
june_second = next(item for item in body["dates"] if item["date"] == "2026-06-02")
|
||||
weekend = next(item for item in body["dates"] if item["date"] == "2026-06-06")
|
||||
assert june_first == {
|
||||
assert {key: june_first[key] for key in (
|
||||
"date",
|
||||
"is_workday",
|
||||
"attendance_points",
|
||||
"personnel_points",
|
||||
"work_points",
|
||||
)} == {
|
||||
"date": "2026-06-01",
|
||||
"is_workday": True,
|
||||
"attendance_points": "10.0000",
|
||||
"personnel_points": "10.0000",
|
||||
"work_points": "15.0000",
|
||||
}
|
||||
assert june_second == {
|
||||
assert june_first["attendance_record"]["id"] == AttendanceRecord.objects.get(
|
||||
employee=employee,
|
||||
date="2026-06-01",
|
||||
).pk
|
||||
assert june_first["work_entry"]["actual_points"] == "15.0000"
|
||||
assert june_first["work_entry"]["note"] == ""
|
||||
assert [item["action"] for item in june_first["personnel_detail"]["actions"]] == [
|
||||
ACTION_HIRED
|
||||
]
|
||||
assert {key: june_second[key] for key in (
|
||||
"date",
|
||||
"is_workday",
|
||||
"attendance_points",
|
||||
"personnel_points",
|
||||
"work_points",
|
||||
)} == {
|
||||
"date": "2026-06-02",
|
||||
"is_workday": True,
|
||||
"attendance_points": "5.0000",
|
||||
"personnel_points": "0.0000",
|
||||
"work_points": None,
|
||||
}
|
||||
assert weekend == {
|
||||
assert june_second["work_entry"] is None
|
||||
assert [item["id"] for item in june_second["personnel_detail"]["requests"]] == [
|
||||
vacation.pk
|
||||
]
|
||||
assert june_second["personnel_detail"]["requests"][0]["status"] == "approved"
|
||||
assert {key: weekend[key] for key in (
|
||||
"date",
|
||||
"is_workday",
|
||||
"attendance_points",
|
||||
"personnel_points",
|
||||
"work_points",
|
||||
)} == {
|
||||
"date": "2026-06-06",
|
||||
"is_workday": False,
|
||||
"attendance_points": None,
|
||||
@ -538,6 +570,7 @@ def test_period_point_breakdown_returns_daily_sources(
|
||||
assert undated_response.status_code == 200, undated_response.content
|
||||
undated_body = undated_response.json()
|
||||
assert undated_body["undated_work_points"] == "42.0000"
|
||||
assert undated_body["undated_work_record"]["actual_points"] == "42.0000"
|
||||
assert undated_body["work_points_mode"] == "unavailable"
|
||||
assert all(item["work_points"] is None for item in undated_body["dates"])
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ type AttendanceDayEventsModalProps = {
|
||||
onClose: () => void;
|
||||
onOpenComments?: (record: AttendanceDayEventsPreview) => void;
|
||||
record: AttendanceDayEventsPreview | null;
|
||||
stackLevel?: number;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
@ -81,6 +82,7 @@ export function AttendanceDayEventsModal({
|
||||
onClose,
|
||||
onOpenComments,
|
||||
record,
|
||||
stackLevel = 0,
|
||||
}: AttendanceDayEventsModalProps) {
|
||||
const [events, setEvents] = useState<AttendanceDayEvent[]>([]);
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>({});
|
||||
@ -171,6 +173,7 @@ export function AttendanceDayEventsModal({
|
||||
onClose={close}
|
||||
title="События посещения"
|
||||
size="xl"
|
||||
stackLevel={stackLevel}
|
||||
>
|
||||
{record ? (
|
||||
<div className="space-y-4">
|
||||
|
||||
@ -2,7 +2,16 @@
|
||||
|
||||
import { CalendarDays, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import {
|
||||
AttendanceDayEventsModal,
|
||||
type AttendanceDayEventsPreview,
|
||||
} from "@/components/attendance/AttendanceDayEventsModal";
|
||||
import {
|
||||
PayrollPersonnelDayModal,
|
||||
PayrollWorkEntryDayModal,
|
||||
} from "@/components/finance/admin/PayrollPointSourceDetailModals";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type {
|
||||
@ -55,6 +64,10 @@ function dateParts(value: string): { date: string; weekday: string } {
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(`${value}T00:00:00`).toLocaleDateString("ru-RU");
|
||||
}
|
||||
|
||||
function differenceTitle(
|
||||
row: PointRow,
|
||||
day: PayrollPointBreakdownDay,
|
||||
@ -77,6 +90,10 @@ export function PayrollPointBreakdownModal({
|
||||
const [data, setData] = useState<PayrollPointBreakdown | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attendanceDay, setAttendanceDay] = useState<PayrollPointBreakdownDay | null>(null);
|
||||
const [personnelDay, setPersonnelDay] = useState<PayrollPointBreakdownDay | null>(null);
|
||||
const [workDay, setWorkDay] = useState<PayrollPointBreakdownDay | null>(null);
|
||||
const [undatedWorkOpen, setUndatedWorkOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@ -118,9 +135,39 @@ export function PayrollPointBreakdownModal({
|
||||
() => 190 + ((data?.dates.length || 0) + (data?.undated_work_points ? 1 : 0)) * 68,
|
||||
[data?.dates.length, data?.undated_work_points],
|
||||
);
|
||||
const attendancePreview = useMemo<AttendanceDayEventsPreview | null>(() => {
|
||||
const record = attendanceDay?.attendance_record;
|
||||
if (!attendanceDay || !record) return null;
|
||||
const time = (value: string | null) => value || "—";
|
||||
return {
|
||||
recordId: record.id,
|
||||
employeeName,
|
||||
date: formatDate(attendanceDay.date),
|
||||
statusLabel: record.status_label,
|
||||
displayText: `Приход ${time(record.arrival_time)} · уход ${time(record.departure_time)}`,
|
||||
detailLines: [
|
||||
`Отработано: ${record.work_hours ?? "—"} ч.`,
|
||||
`План: ${record.expected_hours ?? "—"} ч.`,
|
||||
`Баллы: ${formatPoints(attendanceDay.attendance_points)}`,
|
||||
],
|
||||
issues: record.issues,
|
||||
isManuallyEdited: record.is_manually_edited,
|
||||
};
|
||||
}, [attendanceDay, employeeName]);
|
||||
|
||||
const openDayDetail = (row: PointRow, day: PayrollPointBreakdownDay) => {
|
||||
if (row.id === "attendance_points" && day.attendance_record) {
|
||||
setAttendanceDay(day);
|
||||
} else if (row.id === "personnel_points") {
|
||||
setPersonnelDay(day);
|
||||
} else if (row.id === "work_points" && day.work_entry) {
|
||||
setWorkDay(day);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<>
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={`Детализация баллов · ${employeeName}`}
|
||||
@ -177,6 +224,7 @@ export function PayrollPointBreakdownModal({
|
||||
</div>
|
||||
|
||||
<div className="app-text-muted flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
|
||||
<span className="text-[var(--foreground)]">Нажмите на значение, чтобы открыть исходные данные дня.</span>
|
||||
<span>Подсвечивается любое дневное расхождение с выработкой:</span>
|
||||
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-emerald-400" />выше — зелёный</span>
|
||||
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-orange-400" />ниже до 25% — оранжевый</span>
|
||||
@ -233,22 +281,46 @@ export function PayrollPointBreakdownModal({
|
||||
const difference = row.id === "work_points"
|
||||
? null
|
||||
: compareDailyPayrollProjection(day[row.id], day.work_points);
|
||||
const canOpen = row.id === "attendance_points"
|
||||
? Boolean(day.attendance_record)
|
||||
: row.id === "personnel_points"
|
||||
? day.personnel_points != null || day.personnel_detail.actions.length > 0 || day.personnel_detail.requests.length > 0
|
||||
: Boolean(day.work_entry);
|
||||
return (
|
||||
<td
|
||||
key={day.date}
|
||||
className={`border-b border-r border-[var(--border-subtle)] px-1 py-3 text-center text-[11px] tabular-nums ${difference ? differenceClasses[difference.color] : day.is_workday ? "text-[var(--foreground)]" : "bg-[var(--surface-secondary)] text-[var(--muted-foreground)]"}`}
|
||||
className={`border-b border-r border-[var(--border-subtle)] p-0 text-center text-[11px] tabular-nums ${difference ? differenceClasses[difference.color] : day.is_workday ? "text-[var(--foreground)]" : "bg-[var(--surface-secondary)] text-[var(--muted-foreground)]"}`}
|
||||
title={differenceTitle(row, day, difference)}
|
||||
>
|
||||
{formatPoints(day[row.id])}
|
||||
{canOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-10 w-full cursor-pointer px-1 py-3 text-center 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)]"
|
||||
onClick={() => openDayDetail(row, day)}
|
||||
aria-label={`${row.label}, ${formatDate(day.date)}. Открыть исходную запись`}
|
||||
>
|
||||
{formatPoints(day[row.id])}
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-block px-1 py-3">{formatPoints(day[row.id])}</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
{data.undated_work_points ? (
|
||||
<td
|
||||
className="app-surface-muted border-b border-r border-[var(--border-subtle)] px-1 py-3 text-center text-[11px] font-medium text-[var(--foreground)] tabular-nums"
|
||||
className="app-surface-muted border-b border-r border-[var(--border-subtle)] p-0 text-center text-[11px] font-medium text-[var(--foreground)] tabular-nums"
|
||||
title={row.id === "work_points" ? "Выработка без привязки к дате внутри периода" : undefined}
|
||||
>
|
||||
{row.id === "work_points" ? formatPoints(data.undated_work_points) : "—"}
|
||||
{row.id === "work_points" && data.undated_work_record ? (
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-10 w-full cursor-pointer px-1 py-3 text-center 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)]"
|
||||
onClick={() => setUndatedWorkOpen(true)}
|
||||
>
|
||||
{formatPoints(data.undated_work_points)}
|
||||
</button>
|
||||
) : <span className="inline-block px-1 py-3">—</span>}
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
@ -256,8 +328,45 @@ export function PayrollPointBreakdownModal({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Modal>
|
||||
{typeof document !== "undefined" && data ? createPortal(
|
||||
<>
|
||||
<AttendanceDayEventsModal
|
||||
isOpen={Boolean(attendancePreview)}
|
||||
onClose={() => setAttendanceDay(null)}
|
||||
record={attendancePreview}
|
||||
stackLevel={1}
|
||||
/>
|
||||
{personnelDay ? (
|
||||
<PayrollPersonnelDayModal
|
||||
day={personnelDay}
|
||||
employeeName={employeeName}
|
||||
onClose={() => setPersonnelDay(null)}
|
||||
/>
|
||||
) : null}
|
||||
{workDay?.work_entry ? (
|
||||
<PayrollWorkEntryDayModal
|
||||
entry={workDay.work_entry}
|
||||
date={workDay.date}
|
||||
employeeName={employeeName}
|
||||
onClose={() => setWorkDay(null)}
|
||||
/>
|
||||
) : null}
|
||||
{undatedWorkOpen && data.undated_work_record ? (
|
||||
<PayrollWorkEntryDayModal
|
||||
entry={data.undated_work_record}
|
||||
date={null}
|
||||
employeeName={employeeName}
|
||||
undated
|
||||
onClose={() => setUndatedWorkOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>,
|
||||
document.body,
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { BriefcaseBusiness, CalendarDays, FileText, UserRoundCog } from "lucide-react";
|
||||
|
||||
import { Modal } from "@/components/ui";
|
||||
import type { PayrollPointBreakdownDay } from "@/lib/api/finance";
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return "—";
|
||||
return new Date(`${value}T00:00:00`).toLocaleDateString("ru-RU");
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Date(value).toLocaleString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatPoints(value: string | null): string {
|
||||
if (value == null || value === "") return "—";
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number)
|
||||
? number.toLocaleString("ru-RU", { maximumFractionDigits: 4 })
|
||||
: value;
|
||||
}
|
||||
|
||||
function requestStatusClass(status: string): string {
|
||||
if (status === "approved") return "app-feedback-success";
|
||||
if (status === "rejected" || status === "cancelled") return "app-feedback-danger";
|
||||
if (status === "pending") return "app-feedback-warning";
|
||||
return "app-surface-muted";
|
||||
}
|
||||
|
||||
export function PayrollPersonnelDayModal({
|
||||
day,
|
||||
employeeName,
|
||||
onClose,
|
||||
}: {
|
||||
day: PayrollPointBreakdownDay;
|
||||
employeeName: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const detail = day.personnel_detail;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={`Кадровые события · ${employeeName}`}
|
||||
size="lg"
|
||||
stackLevel={1}
|
||||
closeOnClickOutside
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="app-card-caption">Состояние на {formatDate(day.date)}</p>
|
||||
<p className="mt-2 text-base font-semibold text-[var(--foreground)]">
|
||||
{detail.state.label}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`app-status-pill ${detail.state.expects_attendance ? "app-feedback-success" : "app-feedback-warning"}`}>
|
||||
{detail.state.expects_attendance ? "Явка ожидается" : "Явка не ожидается"}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<UserRoundCog className="app-text-muted" size={16} />
|
||||
<h4 className="text-sm font-semibold text-[var(--foreground)]">
|
||||
Кадровые события ({detail.actions.length})
|
||||
</h4>
|
||||
</div>
|
||||
{detail.actions.length ? (
|
||||
<div className="space-y-2">
|
||||
{detail.actions.map((action) => (
|
||||
<article key={action.id} className="app-surface rounded-xl border border-[var(--border-subtle)] p-3.5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">{action.label}</p>
|
||||
<span className="app-status-pill app-surface-muted">#{action.id}</span>
|
||||
</div>
|
||||
<p className="app-text-muted mt-2 text-xs">
|
||||
{formatDate(action.date)}{action.date_to ? ` — ${formatDate(action.date_to)}` : ""}
|
||||
</p>
|
||||
{action.comment ? <p className="mt-2 whitespace-pre-wrap text-sm text-[var(--foreground)]">{action.comment}</p> : null}
|
||||
{action.source_request_id ? (
|
||||
<p className="app-accent-text mt-2 text-xs">Создано по заявлению #{action.source_request_id}</p>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl px-4 py-6 text-center text-sm text-[var(--muted-foreground)]">
|
||||
Отдельных кадровых событий для этой даты нет.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<FileText className="app-text-muted" size={16} />
|
||||
<h4 className="text-sm font-semibold text-[var(--foreground)]">
|
||||
Заявления ({detail.requests.length})
|
||||
</h4>
|
||||
</div>
|
||||
{detail.requests.length ? (
|
||||
<div className="space-y-2">
|
||||
{detail.requests.map((request) => (
|
||||
<article key={request.id} className="app-surface rounded-xl border border-[var(--border-subtle)] p-3.5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">{request.title || request.type_label}</p>
|
||||
<p className="app-text-muted mt-1 text-xs">{request.type_label} · заявление #{request.id}</p>
|
||||
</div>
|
||||
<span className={`app-status-pill ${requestStatusClass(request.status)}`}>{request.status_label}</span>
|
||||
</div>
|
||||
<p className="app-text-muted mt-2 text-xs">
|
||||
{formatDate(request.date_from)}{request.date_to ? ` — ${formatDate(request.date_to)}` : ""}
|
||||
</p>
|
||||
{request.comment ? <p className="mt-2 whitespace-pre-wrap text-sm text-[var(--foreground)]">{request.comment}</p> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl px-4 py-6 text-center text-sm text-[var(--muted-foreground)]">
|
||||
Заявлений, относящихся к этой дате, нет.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
type WorkEntryDetail = {
|
||||
id: number;
|
||||
target_points?: string | null;
|
||||
actual_points: string | null;
|
||||
note: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export function PayrollWorkEntryDayModal({
|
||||
entry,
|
||||
date,
|
||||
employeeName,
|
||||
undated = false,
|
||||
onClose,
|
||||
}: {
|
||||
entry: WorkEntryDetail;
|
||||
date: string | null;
|
||||
employeeName: string;
|
||||
undated?: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={`Запись выработки · ${employeeName}`}
|
||||
size="md"
|
||||
stackLevel={1}
|
||||
closeOnClickOutside
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<section className="app-selected flex items-start gap-3 rounded-xl p-4">
|
||||
{undated ? <BriefcaseBusiness className="app-accent-text mt-0.5 shrink-0" size={19} /> : <CalendarDays className="app-accent-text mt-0.5 shrink-0" size={19} />}
|
||||
<div className="min-w-0">
|
||||
<p className="app-card-caption">{undated ? "Без привязки к дате" : formatDate(date)}</p>
|
||||
<p className="mt-2 text-xl font-semibold text-[var(--foreground)]">
|
||||
{formatPoints(entry.actual_points)} балл.
|
||||
</p>
|
||||
{entry.target_points != null ? (
|
||||
<p className="app-text-muted mt-1 text-xs">Дневная норма: {formatPoints(entry.target_points)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<p className="app-card-caption">Комментарий</p>
|
||||
<p className={`mt-2 whitespace-pre-wrap text-sm ${entry.note ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)]"}`}>
|
||||
{entry.note || "Комментарий не указан."}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-2 text-xs text-[var(--muted-foreground)] sm:grid-cols-2">
|
||||
<p>Создано: {formatDateTime(entry.created_at)}</p>
|
||||
<p>Изменено: {formatDateTime(entry.updated_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -292,6 +292,51 @@ export type PayrollPointBreakdownDay = {
|
||||
attendance_points: string | null;
|
||||
personnel_points: string | null;
|
||||
work_points: string | null;
|
||||
attendance_record: {
|
||||
id: number;
|
||||
arrival_time: string | null;
|
||||
departure_time: string | null;
|
||||
work_hours: number | null;
|
||||
expected_hours: number | null;
|
||||
status_label: string;
|
||||
issues: string[];
|
||||
is_manually_edited: boolean;
|
||||
} | null;
|
||||
personnel_detail: {
|
||||
state: {
|
||||
status: string;
|
||||
label: string;
|
||||
expects_attendance: boolean;
|
||||
};
|
||||
actions: Array<{
|
||||
id: number;
|
||||
action: string;
|
||||
label: string;
|
||||
date: string;
|
||||
date_to: string | null;
|
||||
comment: string;
|
||||
source_request_id: number | null;
|
||||
}>;
|
||||
requests: Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
type_label: string;
|
||||
title: string;
|
||||
status: string;
|
||||
status_label: string;
|
||||
date_from: string | null;
|
||||
date_to: string | null;
|
||||
comment: string;
|
||||
}>;
|
||||
};
|
||||
work_entry: {
|
||||
id: number;
|
||||
target_points: string | null;
|
||||
actual_points: string | null;
|
||||
note: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type PayrollPointBreakdown = {
|
||||
@ -305,6 +350,13 @@ export type PayrollPointBreakdown = {
|
||||
target_points: string | null;
|
||||
actual_points: string | null;
|
||||
undated_work_points: string | null;
|
||||
undated_work_record: {
|
||||
id: number;
|
||||
actual_points: string | null;
|
||||
note: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
} | null;
|
||||
work_points_mode: "daily_entries" | "unavailable";
|
||||
dates: PayrollPointBreakdownDay[];
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user