Add LogStorm attendance UI
This commit is contained in:
parent
5bc0a33b50
commit
78e758aedf
1
backend/api/v1/attendance/__init__.py
Normal file
1
backend/api/v1/attendance/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Attendance API integration endpoints."""
|
||||
35
backend/api/v1/attendance/serializers.py
Normal file
35
backend/api/v1/attendance/serializers.py
Normal file
@ -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
|
||||
50
backend/api/v1/attendance/views.py
Normal file
50
backend/api/v1/attendance/views.py
Normal file
@ -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)
|
||||
@ -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")),
|
||||
|
||||
139
backend/tests/integration/test_logstorm_attendance_api.py
Normal file
139
backend/tests/integration/test_logstorm_attendance_api.py
Normal file
@ -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"
|
||||
514
frontend/src/app/attendance/page.tsx
Normal file
514
frontend/src/app/attendance/page.tsx
Normal file
@ -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<User[]>([]);
|
||||
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<string[]>(defaultWorkdays);
|
||||
const [loadingEmployees, setLoadingEmployees] = useState(true);
|
||||
const [loadingAnalysis, setLoadingAnalysis] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<LogStormAttendanceResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadEmployees() {
|
||||
try {
|
||||
setLoadingEmployees(true);
|
||||
setError(null);
|
||||
const nextEmployees = await loadAllPages<User>((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<HTMLFormElement>) {
|
||||
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 (
|
||||
<AppShell>
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-4">
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-[var(--foreground)]">Посещаемость</h1>
|
||||
<p className="app-text-muted text-sm">Анализ проходов LogStorm</p>
|
||||
</div>
|
||||
{selectedEmployee ? (
|
||||
<div className="app-surface-muted rounded-lg px-3 py-2 text-sm text-[var(--foreground)]">
|
||||
{getFullName(selectedEmployee)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<form onSubmit={(event) => void analyzeAttendance(event)} className="grid gap-4 xl:grid-cols-[20rem_minmax(0,1fr)]">
|
||||
<div className="app-surface-muted rounded-xl p-3 xl:sticky xl:top-20 xl:self-start">
|
||||
<div className="relative">
|
||||
<Search size={18} className="app-text-muted pointer-events-none absolute left-4 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
value={employeeSearch}
|
||||
onChange={(event) => setEmployeeSearch(event.target.value)}
|
||||
className="app-input w-full rounded-xl py-3 pl-11 pr-4 text-sm"
|
||||
placeholder="Выберите сотрудника для анализа"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3 px-1 text-xs">
|
||||
<span className="app-text-muted">
|
||||
Найдено: {loadingEmployees ? "..." : filteredEmployees.length}
|
||||
</span>
|
||||
<span className="text-[var(--accent-primary)]">
|
||||
Выбрано: {employeeId ? 1 : 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 max-h-[21rem] space-y-2 overflow-y-auto pr-1">
|
||||
{loadingEmployees ? (
|
||||
<div className="app-surface flex items-center justify-center gap-2 rounded-xl p-5 text-sm text-[var(--muted-foreground)]">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Загрузка сотрудников
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loadingEmployees && filteredEmployees.length === 0 ? (
|
||||
<div className="app-surface rounded-xl p-5 text-center text-sm text-[var(--muted-foreground)]">
|
||||
Сотрудники не найдены
|
||||
</div>
|
||||
) : 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 (
|
||||
<button
|
||||
key={employee.id}
|
||||
type="button"
|
||||
onClick={() => setEmployeeId(String(employee.id))}
|
||||
className={`flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left transition ${
|
||||
active
|
||||
? "border-[var(--accent-primary)] bg-[var(--accent-soft)]"
|
||||
: "border-[var(--border-subtle)] bg-[var(--surface-primary)] hover:border-[var(--border-strong)] hover:bg-[var(--surface-elevated)]"
|
||||
}`}
|
||||
>
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-[var(--accent-primary)] bg-[var(--accent-soft)] text-sm font-semibold text-[var(--accent-primary)]">
|
||||
{getInitials(employee).toUpperCase()}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">{fullName}</span>
|
||||
<span className="app-text-muted block truncate text-xs">{subtitle}</span>
|
||||
</span>
|
||||
<span className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full border ${
|
||||
active
|
||||
? "border-[var(--accent-primary)] bg-[var(--accent-primary)] text-white"
|
||||
: "border-[var(--border-strong)]"
|
||||
}`}>
|
||||
{active ? <Check size={15} /> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_17rem]">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="app-card-caption mb-2 block">Начало периода</span>
|
||||
<input
|
||||
type="date"
|
||||
value={periodStart}
|
||||
onChange={(event) => setPeriodStart(event.target.value)}
|
||||
className="app-input w-full rounded-lg px-3 py-2.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="app-card-caption mb-2 block">Конец периода</span>
|
||||
<input
|
||||
type="date"
|
||||
value={periodEnd}
|
||||
onChange={(event) => setPeriodEnd(event.target.value)}
|
||||
className="app-input w-full rounded-lg px-3 py-2.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="app-surface-muted rounded-xl p-3">
|
||||
<label className="flex items-center gap-3 text-sm font-medium text-[var(--foreground)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useManualSchedule}
|
||||
onChange={(event) => setUseManualSchedule(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-[var(--border-subtle)]"
|
||||
/>
|
||||
Ручной график
|
||||
</label>
|
||||
|
||||
{useManualSchedule ? (
|
||||
<div className="mt-3 grid gap-3">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="block">
|
||||
<span className="app-card-caption mb-2 block">Начало</span>
|
||||
<input
|
||||
type="time"
|
||||
value={scheduleStart}
|
||||
onChange={(event) => setScheduleStart(event.target.value)}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="app-card-caption mb-2 block">Конец</span>
|
||||
<input
|
||||
type="time"
|
||||
value={scheduleEnd}
|
||||
onChange={(event) => setScheduleEnd(event.target.value)}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="app-card-caption mb-2 block">Часы</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.25"
|
||||
value={expectedHours}
|
||||
onChange={(event) => setExpectedHours(event.target.value)}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{weekdays.map((day) => {
|
||||
const active = workdays.includes(day.value);
|
||||
return (
|
||||
<button
|
||||
key={day.value}
|
||||
type="button"
|
||||
onClick={() => toggleWorkday(day.value)}
|
||||
className={`h-9 min-w-10 rounded-lg px-3 text-sm font-medium transition ${
|
||||
active
|
||||
? "app-selected text-[var(--accent-primary)]"
|
||||
: "app-surface text-[var(--muted-foreground)] hover:bg-[var(--surface-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
{day.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-surface-muted flex flex-col justify-between gap-4 rounded-xl p-4 lg:self-start">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--foreground)]">
|
||||
<CalendarCheck size={18} />
|
||||
LogStorm
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-1">
|
||||
<div className="app-surface rounded-lg px-3 py-2">
|
||||
<p className="app-card-caption">Записей</p>
|
||||
<p className="text-lg font-semibold text-[var(--foreground)]">{summary.total}</p>
|
||||
</div>
|
||||
<div className="app-surface rounded-lg px-3 py-2">
|
||||
<p className="app-card-caption">Рабочих</p>
|
||||
<p className="text-lg font-semibold text-[var(--foreground)]">{summary.workdays}</p>
|
||||
</div>
|
||||
<div className="app-surface rounded-lg px-3 py-2">
|
||||
<p className="app-card-caption">Проблем</p>
|
||||
<p className="text-lg font-semibold text-[var(--foreground)]">{summary.issueDays}</p>
|
||||
</div>
|
||||
<div className="app-surface rounded-lg px-3 py-2">
|
||||
<p className="app-card-caption">Переработок</p>
|
||||
<p className="text-lg font-semibold text-[var(--foreground)]">{summary.overtime}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loadingAnalysis || loadingEmployees}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-[var(--accent-primary)] px-4 text-sm font-semibold text-white transition hover:bg-[var(--accent-hover)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loadingAnalysis ? <Loader2 size={16} className="animate-spin" /> : <CalendarCheck size={16} />}
|
||||
Анализировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
|
||||
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
{result && records.length === 0 ? (
|
||||
<div className="app-surface-muted rounded-xl p-8 text-center">
|
||||
<p className="app-text-muted text-sm">Записей за период нет</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!result ? (
|
||||
<div className="app-surface-muted rounded-xl p-8 text-center">
|
||||
<p className="app-text-muted text-sm">Выберите сотрудника и период</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{records.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border-subtle)] text-xs uppercase text-[var(--muted-foreground)]">
|
||||
<th className="whitespace-nowrap px-3 py-3 font-semibold">Дата</th>
|
||||
<th className="whitespace-nowrap px-3 py-3 font-semibold">День</th>
|
||||
<th className="whitespace-nowrap px-3 py-3 font-semibold">Приход</th>
|
||||
<th className="whitespace-nowrap px-3 py-3 font-semibold">Уход</th>
|
||||
<th className="whitespace-nowrap px-3 py-3 font-semibold">Часы</th>
|
||||
<th className="min-w-48 px-3 py-3 font-semibold">Статусы</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record, index) => {
|
||||
const labels = issueLabels(record);
|
||||
return (
|
||||
<tr key={`${record.date || "record"}-${index}`} className="border-b border-[var(--border-subtle)] last:border-0">
|
||||
<td className="whitespace-nowrap px-3 py-3 text-[var(--foreground)]">{record.date || "Нет даты"}</td>
|
||||
<td className="whitespace-nowrap px-3 py-3">
|
||||
<span className={`rounded-lg px-2 py-1 text-xs font-medium ${
|
||||
record.is_workday
|
||||
? "bg-emerald-500/10 text-emerald-300"
|
||||
: "bg-slate-500/10 text-slate-300"
|
||||
}`}>
|
||||
{record.is_workday ? "Рабочий" : "Нерабочий"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 text-[var(--foreground)]">{formatTime(record.arrival_time)}</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 text-[var(--foreground)]">{formatTime(record.departure_time)}</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 text-[var(--foreground)]">
|
||||
{formatHours(record.work_hours)}
|
||||
{record.expected_hours !== undefined ? (
|
||||
<span className="app-text-muted"> / {formatHours(record.expected_hours)}</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
{labels.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{labels.map((label) => (
|
||||
<span key={label} className="rounded-lg bg-amber-500/10 px-2 py-1 text-xs font-medium text-amber-300">
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="app-text-muted text-xs">Нет</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@ -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: "Закупки" },
|
||||
|
||||
64
frontend/src/lib/api/attendance.ts
Normal file
64
frontend/src/lib/api/attendance.ts
Normal file
@ -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<LogStormAttendanceResponse>,
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user