Add LogStorm attendance UI

This commit is contained in:
lizerk 2026-04-21 15:56:10 +03:00
parent 5bc0a33b50
commit 78e758aedf
9 changed files with 813 additions and 1 deletions

View File

@ -0,0 +1 @@
"""Attendance API integration endpoints."""

View 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

View 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)

View File

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

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

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

View File

@ -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: "Закупки" },

View 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>,
};
}

View File

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