diff --git a/backend/api/v1/attendance/views.py b/backend/api/v1/attendance/views.py index 80f7cb2e..d3428ff1 100644 --- a/backend/api/v1/attendance/views.py +++ b/backend/api/v1/attendance/views.py @@ -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, ) diff --git a/backend/employees/services/personnel_state.py b/backend/employees/services/personnel_state.py index e68e62bb..cb67cbd2 100644 --- a/backend/employees/services/personnel_state.py +++ b/backend/employees/services/personnel_state.py @@ -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 diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index 2185a7de..873cea30 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -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, } diff --git a/backend/finance/payroll/work_norm.py b/backend/finance/payroll/work_norm.py index 8f3c1eb2..181d78a3 100644 --- a/backend/finance/payroll/work_norm.py +++ b/backend/finance/payroll/work_norm.py @@ -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 ] diff --git a/backend/tests/api/v1/attendance/test_attendance_storage.py b/backend/tests/api/v1/attendance/test_attendance_storage.py index 09b4d173..bb17a343 100644 --- a/backend/tests/api/v1/attendance/test_attendance_storage.py +++ b/backend/tests/api/v1/attendance/test_attendance_storage.py @@ -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, diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index d5989a15..b88387ee 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -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"]) diff --git a/frontend/src/components/attendance/AttendanceDayEventsModal.tsx b/frontend/src/components/attendance/AttendanceDayEventsModal.tsx index 1efa3f99..c1475abc 100644 --- a/frontend/src/components/attendance/AttendanceDayEventsModal.tsx +++ b/frontend/src/components/attendance/AttendanceDayEventsModal.tsx @@ -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([]); const [photoUrls, setPhotoUrls] = useState>({}); @@ -171,6 +173,7 @@ export function AttendanceDayEventsModal({ onClose={close} title="События посещения" size="xl" + stackLevel={stackLevel} > {record ? (
diff --git a/frontend/src/components/finance/admin/PayrollPointBreakdownModal.tsx b/frontend/src/components/finance/admin/PayrollPointBreakdownModal.tsx index 83a4efdb..77049d68 100644 --- a/frontend/src/components/finance/admin/PayrollPointBreakdownModal.tsx +++ b/frontend/src/components/finance/admin/PayrollPointBreakdownModal.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [attendanceDay, setAttendanceDay] = useState(null); + const [personnelDay, setPersonnelDay] = useState(null); + const [workDay, setWorkDay] = useState(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(() => { + 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 ( - +
+ Нажмите на значение, чтобы открыть исходные данные дня. Подсвечивается любое дневное расхождение с выработкой: выше — зелёный ниже до 25% — оранжевый @@ -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 ( - {formatPoints(day[row.id])} + {canOpen ? ( + + ) : ( + {formatPoints(day[row.id])} + )} ); })} {data.undated_work_points ? ( - {row.id === "work_points" ? formatPoints(data.undated_work_points) : "—"} + {row.id === "work_points" && data.undated_work_record ? ( + + ) : —} ) : null} @@ -256,8 +328,45 @@ export function PayrollPointBreakdownModal({
+
)} - + + {typeof document !== "undefined" && data ? createPortal( + <> + setAttendanceDay(null)} + record={attendancePreview} + stackLevel={1} + /> + {personnelDay ? ( + setPersonnelDay(null)} + /> + ) : null} + {workDay?.work_entry ? ( + setWorkDay(null)} + /> + ) : null} + {undatedWorkOpen && data.undated_work_record ? ( + setUndatedWorkOpen(false)} + /> + ) : null} + , + document.body, + ) : null} + ); } diff --git a/frontend/src/components/finance/admin/PayrollPointSourceDetailModals.tsx b/frontend/src/components/finance/admin/PayrollPointSourceDetailModals.tsx new file mode 100644 index 00000000..459f7ade --- /dev/null +++ b/frontend/src/components/finance/admin/PayrollPointSourceDetailModals.tsx @@ -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 ( + +
+
+
+
+

Состояние на {formatDate(day.date)}

+

+ {detail.state.label} +

+
+ + {detail.state.expects_attendance ? "Явка ожидается" : "Явка не ожидается"} + +
+
+ +
+
+ +

+ Кадровые события ({detail.actions.length}) +

+
+ {detail.actions.length ? ( +
+ {detail.actions.map((action) => ( +
+
+

{action.label}

+ #{action.id} +
+

+ {formatDate(action.date)}{action.date_to ? ` — ${formatDate(action.date_to)}` : ""} +

+ {action.comment ?

{action.comment}

: null} + {action.source_request_id ? ( +

Создано по заявлению #{action.source_request_id}

+ ) : null} +
+ ))} +
+ ) : ( +
+ Отдельных кадровых событий для этой даты нет. +
+ )} +
+ +
+
+ +

+ Заявления ({detail.requests.length}) +

+
+ {detail.requests.length ? ( +
+ {detail.requests.map((request) => ( +
+
+
+

{request.title || request.type_label}

+

{request.type_label} · заявление #{request.id}

+
+ {request.status_label} +
+

+ {formatDate(request.date_from)}{request.date_to ? ` — ${formatDate(request.date_to)}` : ""} +

+ {request.comment ?

{request.comment}

: null} +
+ ))} +
+ ) : ( +
+ Заявлений, относящихся к этой дате, нет. +
+ )} +
+
+
+ ); +} + +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 ( + +
+
+ {undated ? : } +
+

{undated ? "Без привязки к дате" : formatDate(date)}

+

+ {formatPoints(entry.actual_points)} балл. +

+ {entry.target_points != null ? ( +

Дневная норма: {formatPoints(entry.target_points)}

+ ) : null} +
+
+ +
+

Комментарий

+

+ {entry.note || "Комментарий не указан."} +

+
+ +
+

Создано: {formatDateTime(entry.created_at)}

+

Изменено: {formatDateTime(entry.updated_at)}

+
+
+
+ ); +} diff --git a/frontend/src/lib/api/finance.ts b/frontend/src/lib/api/finance.ts index 457786c5..cdd3cb16 100644 --- a/frontend/src/lib/api/finance.ts +++ b/frontend/src/lib/api/finance.ts @@ -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[]; };