From 78e758aedf4418971680d1ac00b6738bb47ef66e Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 21 Apr 2026 15:56:10 +0300 Subject: [PATCH] Add LogStorm attendance UI --- backend/api/v1/attendance/__init__.py | 1 + backend/api/v1/attendance/serializers.py | 35 ++ backend/api/v1/attendance/views.py | 50 ++ backend/api/v1/urls.py | 6 + .../test_logstorm_attendance_api.py | 139 +++++ frontend/src/app/attendance/page.tsx | 514 ++++++++++++++++++ frontend/src/components/AppShell.tsx | 3 +- frontend/src/lib/api/attendance.ts | 64 +++ frontend/src/lib/api/index.ts | 2 + 9 files changed, 813 insertions(+), 1 deletion(-) create mode 100644 backend/api/v1/attendance/__init__.py create mode 100644 backend/api/v1/attendance/serializers.py create mode 100644 backend/api/v1/attendance/views.py create mode 100644 backend/tests/integration/test_logstorm_attendance_api.py create mode 100644 frontend/src/app/attendance/page.tsx create mode 100644 frontend/src/lib/api/attendance.ts diff --git a/backend/api/v1/attendance/__init__.py b/backend/api/v1/attendance/__init__.py new file mode 100644 index 00000000..755e57dd --- /dev/null +++ b/backend/api/v1/attendance/__init__.py @@ -0,0 +1 @@ +"""Attendance API integration endpoints.""" diff --git a/backend/api/v1/attendance/serializers.py b/backend/api/v1/attendance/serializers.py new file mode 100644 index 00000000..83904ea5 --- /dev/null +++ b/backend/api/v1/attendance/serializers.py @@ -0,0 +1,35 @@ +from rest_framework import serializers + + +class DateOverrideSerializer(serializers.Serializer): + date = serializers.DateField() + is_workday = serializers.BooleanField() + reason = serializers.CharField(required=False, allow_blank=True) + start_time = serializers.CharField(required=False, allow_blank=True) + end_time = serializers.CharField(required=False, allow_blank=True) + expected_hours = serializers.FloatField(required=False) + + +class LogStormScheduleSerializer(serializers.Serializer): + start_time = serializers.CharField() + end_time = serializers.CharField() + expected_hours = serializers.FloatField() + workdays = serializers.ListField( + child=serializers.CharField(), + required=False, + ) + date_overrides = DateOverrideSerializer(many=True, required=False) + + +class LogStormAttendanceAnalyzeSerializer(serializers.Serializer): + employee_id = serializers.IntegerField() + period_start = serializers.DateField() + period_end = serializers.DateField() + schedule = LogStormScheduleSerializer(required=False) + + def validate(self, attrs): + if attrs["period_start"] > attrs["period_end"]: + raise serializers.ValidationError( + "period_start must be less than or equal to period_end" + ) + return attrs diff --git a/backend/api/v1/attendance/views.py b/backend/api/v1/attendance/views.py new file mode 100644 index 00000000..52df4dd6 --- /dev/null +++ b/backend/api/v1/attendance/views.py @@ -0,0 +1,50 @@ +import logging + +from common.logstorm_attendance import analyze_employee_attendance +from common.logstorm_client import LogStormClientError +from employees.models import Employee +from rest_framework import status +from rest_framework.permissions import IsAdminUser +from rest_framework.response import Response +from rest_framework.views import APIView + +from .serializers import LogStormAttendanceAnalyzeSerializer + +logger = logging.getLogger(__name__) + + +class LogStormAttendanceAnalyzeAPIView(APIView): + permission_classes = [IsAdminUser] + + def post(self, request): + serializer = LogStormAttendanceAnalyzeSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + try: + employee = Employee.objects.get(pk=data["employee_id"]) + except Employee.DoesNotExist: + return Response( + {"detail": "Employee not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # TODO: когда schedule UI сможет явно хранить рабочие/выходные дни, + # формировать schedule/date_overrides здесь из EUSRR-календаря, а не + # полагаться на ручной payload или fallback LogStorm. + try: + result = analyze_employee_attendance( + employee=employee, + period_start=data["period_start"], + period_end=data["period_end"], + schedule=data.get("schedule"), + client=None, + ) + except LogStormClientError as exc: + logger.warning("LogStorm attendance analysis failed: %s", exc) + return Response( + {"detail": str(exc), "error": "logstorm_unavailable"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + return Response(result) diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 68682177..c8ca4e5d 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -29,6 +29,7 @@ from .employees.views import ( from .feed.views import PostViewSet from .requests_app.views import RequestViewSet from .search.views import search_api_view +from .attendance.views import LogStormAttendanceAnalyzeAPIView app_name = "v1" @@ -88,6 +89,11 @@ router.register(r"communications/polls", PollViewSet, basename="polls") urlpatterns = [ path("auth/", include("api.auth.urls")), + path( + "attendance/logstorm/analyze/", + LogStormAttendanceAnalyzeAPIView.as_view(), + name="logstorm-attendance-analyze", + ), path("directory/", include("api.v1.directory.urls")), # Notifications API (из самого модуля notifications) path("notifications/", include("notifications.api.urls")), diff --git a/backend/tests/integration/test_logstorm_attendance_api.py b/backend/tests/integration/test_logstorm_attendance_api.py new file mode 100644 index 00000000..8cc9d0e4 --- /dev/null +++ b/backend/tests/integration/test_logstorm_attendance_api.py @@ -0,0 +1,139 @@ +from unittest.mock import patch + +import pytest +from django.urls import reverse + +from common.logstorm_client import LogStormClientError + +pytestmark = pytest.mark.django_db + + +def _url(): + return reverse("api:v1:logstorm-attendance-analyze") + + +def _payload(employee_id, **overrides): + payload = { + "employee_id": employee_id, + "period_start": "2026-04-20", + "period_end": "2026-04-20", + } + payload.update(overrides) + return payload + + +def test_logstorm_attendance_api_requires_staff( + auth_client_factory, + user_factory, +): + user = user_factory(staff=False) + client = auth_client_factory(user) + + response = client.post(_url(), _payload(user.id), format="json") + + assert response.status_code == 403 + + +def test_logstorm_attendance_api_calls_builder_without_schedule( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory(first_name="Ivan", last_name="Petrov") + client = auth_client_factory(staff) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value={"records": []}, + ) as analyze: + response = client.post(_url(), _payload(employee.id), format="json") + + assert response.status_code == 200 + assert response.json() == {"records": []} + kwargs = analyze.call_args.kwargs + assert kwargs["employee"] == employee + assert kwargs["schedule"] is None + + +def test_logstorm_attendance_api_passes_schedule( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory() + client = auth_client_factory(staff) + schedule = { + "start_time": "09:00", + "end_time": "18:00", + "expected_hours": 9, + "workdays": ["Monday"], + "date_overrides": [ + {"date": "2026-04-20", "is_workday": False, "reason": "holiday"} + ], + } + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value={"records": []}, + ) as analyze: + response = client.post( + _url(), + _payload(employee.id, schedule=schedule), + format="json", + ) + + assert response.status_code == 200 + assert analyze.call_args.kwargs["schedule"]["start_time"] == "09:00" + assert analyze.call_args.kwargs["schedule"]["date_overrides"][0][ + "is_workday" + ] is False + + +def test_logstorm_attendance_api_rejects_invalid_period( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + client = auth_client_factory(staff) + + response = client.post( + _url(), + { + "employee_id": staff.id, + "period_start": "2026-04-21", + "period_end": "2026-04-20", + }, + format="json", + ) + + assert response.status_code == 400 + + +def test_logstorm_attendance_api_returns_404_for_unknown_employee( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + client = auth_client_factory(staff) + + response = client.post(_url(), _payload(999999), format="json") + + assert response.status_code == 404 + + +def test_logstorm_attendance_api_maps_logstorm_errors_to_502( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory() + client = auth_client_factory(staff) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + side_effect=LogStormClientError("timeout"), + ): + response = client.post(_url(), _payload(employee.id), format="json") + + assert response.status_code == 502 + assert response.json()["error"] == "logstorm_unavailable" diff --git a/frontend/src/app/attendance/page.tsx b/frontend/src/app/attendance/page.tsx new file mode 100644 index 00000000..b078d790 --- /dev/null +++ b/frontend/src/app/attendance/page.tsx @@ -0,0 +1,514 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { AlertTriangle, CalendarCheck, Check, Loader2, Search } from "lucide-react"; + +import { AppShell } from "@/components/AppShell"; +import { apiClient } from "@/lib/api"; +import { loadAllPages } from "@/lib/shared"; +import type { + LogStormAttendanceRecord, + LogStormAttendanceResponse, + LogStormSchedulePayload, +} from "@/lib/api/attendance"; +import type { User } from "@/types/api"; + +const weekdays = [ + { value: "Monday", label: "Пн" }, + { value: "Tuesday", label: "Вт" }, + { value: "Wednesday", label: "Ср" }, + { value: "Thursday", label: "Чт" }, + { value: "Friday", label: "Пт" }, + { value: "Saturday", label: "Сб" }, + { value: "Sunday", label: "Вс" }, +]; + +const defaultWorkdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]; + +function toDateInputValue(date: Date) { + return date.toISOString().slice(0, 10); +} + +function getDefaultPeriodStart() { + const date = new Date(); + date.setDate(date.getDate() - 6); + return toDateInputValue(date); +} + +function getFullName(employee: User) { + return `${employee.last_name || ""} ${employee.first_name || ""} ${employee.patronymic || ""}`.trim() || employee.email || `#${employee.id}`; +} + +function getInitials(employee: User) { + const first = employee.first_name?.[0] || ""; + const last = employee.last_name?.[0] || ""; + const fallback = employee.email?.[0] || String(employee.id)[0] || ""; + return `${last}${first}` || fallback; +} + +function formatTime(value: unknown) { + if (!value) return "Нет"; + return String(value); +} + +function formatHours(value: unknown) { + if (value === null || value === undefined || value === "") return "0"; + const numeric = Number(value); + if (Number.isFinite(numeric)) { + return numeric.toLocaleString("ru-RU", { maximumFractionDigits: 2 }); + } + return String(value); +} + +function issueLabels(record: LogStormAttendanceRecord) { + const labels = [ + ...(record.statuses || []), + ...(record.employee_issues || []), + ...(record.technical_issues || []), + ]; + + if (labels.length > 0) return labels.map(String); + + const fallback = []; + if (record.is_absent) fallback.push("absence"); + if (record.is_late) fallback.push("late"); + if (record.is_early_leave) fallback.push("early_leave"); + if (record.is_underwork) fallback.push("underwork"); + if (record.is_overtime) fallback.push("overtime"); + return fallback; +} + +function getErrorMessage(error: unknown, fallback: string) { + return String((error as Error)?.message || fallback); +} + +export default function AttendancePage() { + const [employees, setEmployees] = useState([]); + const [employeeSearch, setEmployeeSearch] = useState(""); + const [employeeId, setEmployeeId] = useState(""); + const [periodStart, setPeriodStart] = useState(getDefaultPeriodStart); + const [periodEnd, setPeriodEnd] = useState(() => toDateInputValue(new Date())); + const [useManualSchedule, setUseManualSchedule] = useState(false); + const [scheduleStart, setScheduleStart] = useState("09:00"); + const [scheduleEnd, setScheduleEnd] = useState("18:00"); + const [expectedHours, setExpectedHours] = useState("9"); + const [workdays, setWorkdays] = useState(defaultWorkdays); + const [loadingEmployees, setLoadingEmployees] = useState(true); + const [loadingAnalysis, setLoadingAnalysis] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function loadEmployees() { + try { + setLoadingEmployees(true); + setError(null); + const nextEmployees = await loadAllPages((params) => + apiClient.getEmployees({ + ...params, + is_active: true, + ordering: "last_name", + }), + ); + if (!cancelled) setEmployees(nextEmployees); + } catch (loadError) { + if (!cancelled) { + setError(getErrorMessage(loadError, "Не удалось загрузить сотрудников")); + } + } finally { + if (!cancelled) setLoadingEmployees(false); + } + } + + void loadEmployees(); + + return () => { + cancelled = true; + }; + }, []); + + const filteredEmployees = useMemo(() => { + const query = employeeSearch.trim().toLowerCase(); + if (!query) return employees; + return employees.filter((employee) => { + const fullName = getFullName(employee).toLowerCase(); + const position = (employee.position?.name || "").toLowerCase(); + return fullName.includes(query) || position.includes(query) || String(employee.id).includes(query); + }); + }, [employees, employeeSearch]); + + const records = useMemo(() => { + if (!result?.records || !Array.isArray(result.records)) return []; + return result.records; + }, [result]); + + const summary = useMemo(() => { + const issueDays = records.filter((record) => issueLabels(record).length > 0).length; + return { + total: records.length, + workdays: records.filter((record) => record.is_workday).length, + overtime: records.filter((record) => record.is_overtime).length, + issueDays, + }; + }, [records]); + + const selectedEmployee = employees.find((employee) => String(employee.id) === employeeId); + + function toggleWorkday(day: string) { + setWorkdays((current) => + current.includes(day) + ? current.filter((currentDay) => currentDay !== day) + : [...current, day], + ); + } + + async function analyzeAttendance(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setResult(null); + + if (!employeeId) { + setError("Выберите сотрудника"); + return; + } + + if (!periodStart || !periodEnd || periodStart > periodEnd) { + setError("Проверьте период анализа"); + return; + } + + try { + setLoadingAnalysis(true); + const payload: { + employee_id: number; + period_start: string; + period_end: string; + schedule?: LogStormSchedulePayload; + } = { + employee_id: Number(employeeId), + period_start: periodStart, + period_end: periodEnd, + }; + + if (useManualSchedule) { + payload.schedule = { + start_time: scheduleStart, + end_time: scheduleEnd, + expected_hours: Number(expectedHours), + workdays, + // TODO: Send date_overrides from EUSRR when schedule UI can store working/weekend days. + date_overrides: [], + }; + } + + const response = await apiClient.analyzeLogStormAttendance(payload); + setResult(response); + } catch (analysisError) { + setError(getErrorMessage(analysisError, "Не удалось выполнить анализ")); + } finally { + setLoadingAnalysis(false); + } + } + + return ( + +
+
+
+
+

Посещаемость

+

Анализ проходов LogStorm

+
+ {selectedEmployee ? ( +
+ {getFullName(selectedEmployee)} +
+ ) : null} +
+ +
void analyzeAttendance(event)} className="grid gap-4 xl:grid-cols-[20rem_minmax(0,1fr)]"> +
+
+ + setEmployeeSearch(event.target.value)} + className="app-input w-full rounded-xl py-3 pl-11 pr-4 text-sm" + placeholder="Выберите сотрудника для анализа" + /> +
+ +
+ + Найдено: {loadingEmployees ? "..." : filteredEmployees.length} + + + Выбрано: {employeeId ? 1 : 0} + +
+ +
+ {loadingEmployees ? ( +
+ + Загрузка сотрудников +
+ ) : null} + + {!loadingEmployees && filteredEmployees.length === 0 ? ( +
+ Сотрудники не найдены +
+ ) : null} + + {!loadingEmployees && filteredEmployees.map((employee) => { + const active = String(employee.id) === employeeId; + const fullName = getFullName(employee); + const subtitle = employee.email || employee.position?.name || `ID ${employee.id}`; + + return ( + + ); + })} +
+
+ +
+
+
+ + + +
+ +
+ + + {useManualSchedule ? ( +
+
+ + + +
+ +
+ {weekdays.map((day) => { + const active = workdays.includes(day.value); + return ( + + ); + })} +
+
+ ) : null} +
+
+ +
+
+
+ + LogStorm +
+
+
+

Записей

+

{summary.total}

+
+
+

Рабочих

+

{summary.workdays}

+
+
+

Проблем

+

{summary.issueDays}

+
+
+

Переработок

+

{summary.overtime}

+
+
+
+ + +
+
+
+ + {error ? ( +
+ + {error} +
+ ) : null} +
+ +
+ {result && records.length === 0 ? ( +
+

Записей за период нет

+
+ ) : null} + + {!result ? ( +
+

Выберите сотрудника и период

+
+ ) : null} + + {records.length > 0 ? ( +
+ + + + + + + + + + + + + {records.map((record, index) => { + const labels = issueLabels(record); + return ( + + + + + + + + + ); + })} + +
ДатаДеньПриходУходЧасыСтатусы
{record.date || "Нет даты"} + + {record.is_workday ? "Рабочий" : "Нерабочий"} + + {formatTime(record.arrival_time)}{formatTime(record.departure_time)} + {formatHours(record.work_hours)} + {record.expected_hours !== undefined ? ( + / {formatHours(record.expected_hours)} + ) : null} + + {labels.length > 0 ? ( +
+ {labels.map((label) => ( + + {label} + + ))} +
+ ) : ( + Нет + )} +
+
+ ) : null} +
+
+
+ ); +} diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index a97900fb..522473e2 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,6 +1,6 @@ "use client"; -import { Building2, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users } from "lucide-react"; +import { Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { ReactNode, startTransition, useEffect, useRef, useState, useMemo } from "react"; @@ -43,6 +43,7 @@ const navItems = [ { href: "/messages", label: "Сообщения", icon: MessageSquare, category: "Сообщения" }, { href: "/employees", label: "Сотрудники", icon: Users }, { href: "/departments", label: "Отделы", icon: Building2 }, + { href: "/attendance", label: "Посещаемость", icon: CalendarCheck }, { href: "/requests", label: "Заявления", icon: FileSignature, category: "Заявки" }, { href: "/equipment", label: "Оборудование", icon: Monitor }, { href: "/procurement", label: "Закупки", icon: ShoppingCart, category: "Закупки" }, diff --git a/frontend/src/lib/api/attendance.ts b/frontend/src/lib/api/attendance.ts new file mode 100644 index 00000000..25dad9a5 --- /dev/null +++ b/frontend/src/lib/api/attendance.ts @@ -0,0 +1,64 @@ +import { type RequestFn } from './utils'; + +export type LogStormDateOverridePayload = { + date: string; + is_workday: boolean; + reason?: string; + start_time?: string; + end_time?: string; + expected_hours?: number; +}; + +export type LogStormSchedulePayload = { + start_time: string; + end_time: string; + expected_hours: number; + workdays: string[]; + date_overrides?: LogStormDateOverridePayload[]; +}; + +export type LogStormAttendanceAnalyzePayload = { + employee_id: number; + period_start: string; + period_end: string; + schedule?: LogStormSchedulePayload; +}; + +export type LogStormAttendanceRecord = { + date?: string; + employee_id?: string; + display_name?: string; + arrival_time?: string | null; + departure_time?: string | null; + work_hours?: number | string | null; + expected_hours?: number | string | null; + is_workday?: boolean; + is_late?: boolean; + late_minutes?: number | null; + is_early_leave?: boolean; + early_leave_minutes?: number | null; + is_underwork?: boolean; + underwork_hours?: number | string | null; + is_overtime?: boolean; + overtime_hours?: number | string | null; + is_absent?: boolean; + employee_issues?: string[]; + technical_issues?: string[]; + statuses?: string[]; + [key: string]: unknown; +}; + +export type LogStormAttendanceResponse = { + records?: LogStormAttendanceRecord[]; + [key: string]: unknown; +}; + +export function createAttendanceApi(request: RequestFn) { + return { + analyzeLogStormAttendance: (data: LogStormAttendanceAnalyzePayload) => + request('/api/v1/attendance/logstorm/analyze/', { + method: 'POST', + body: JSON.stringify(data), + }) as Promise, + }; +} diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts index 34c1ccd1..bfdf5969 100644 --- a/frontend/src/lib/api/index.ts +++ b/frontend/src/lib/api/index.ts @@ -13,6 +13,7 @@ import { createFeedApi } from './feed'; import { createRequestsApi } from './requests'; import { createEquipmentApi } from './equipment'; import { createProcurementApi } from './procurement'; +import { createAttendanceApi } from './attendance'; const base = new ApiClientBase(); const req = base.request.bind(base); @@ -29,6 +30,7 @@ export const apiClient = Object.assign(base, { ...createRequestsApi(req, tok), ...createEquipmentApi(req, tok), ...createProcurementApi(req), + ...createAttendanceApi(req), }); export default apiClient;