feat(finance): add payroll point source details

This commit is contained in:
lizerk 2026-07-21 13:33:31 +03:00
parent f7ebc7b00f
commit 5946173a07
10 changed files with 643 additions and 24 deletions

View File

@ -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,
)

View File

@ -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

View File

@ -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,
}

View File

@ -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
]

View File

@ -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,

View File

@ -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"])

View File

@ -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">

View File

@ -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}
</>
);
}

View File

@ -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>
);
}

View File

@ -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[];
};