Merge branch 'develop'

This commit is contained in:
lizerk 2026-04-22 12:48:34 +03:00
commit 77cb8ef0cb
13 changed files with 785 additions and 181 deletions

View File

@ -1,6 +1,11 @@
from rest_framework import serializers
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
from attendance.models import (
AttendanceAnalysisRun,
AttendanceRecord,
EmployeeWorkSchedule,
)
from attendance.services import get_default_work_schedule_payload
from api.v1.employees.serializers import EmployeeBriefSerializer
@ -38,6 +43,71 @@ class LogStormAttendanceAnalyzeSerializer(serializers.Serializer):
return attrs
class EmployeeWorkScheduleSerializer(serializers.ModelSerializer):
employee_id = serializers.IntegerField(source="employee.id", read_only=True)
is_default = serializers.BooleanField(read_only=True, default=False)
class Meta:
model = EmployeeWorkSchedule
fields = (
"id",
"employee_id",
"start_time",
"end_time",
"expected_hours",
"workdays",
"date_overrides",
"is_active",
"is_default",
"updated_by",
"created_at",
"updated_at",
)
read_only_fields = (
"id",
"employee_id",
"is_default",
"updated_by",
"created_at",
"updated_at",
)
def validate_workdays(self, value):
allowed = set(EmployeeWorkSchedule.DEFAULT_WORKDAYS) | {"Saturday", "Sunday"}
if not isinstance(value, list):
raise serializers.ValidationError("workdays must be a list")
normalized = []
for item in value:
if item not in allowed:
raise serializers.ValidationError(f"Unknown weekday: {item}")
if item not in normalized:
normalized.append(item)
return normalized
def validate_expected_hours(self, value):
if value <= 0 or value > 24:
raise serializers.ValidationError("expected_hours must be between 0 and 24")
return value
def default_work_schedule_response(employee_id: int) -> dict:
payload = get_default_work_schedule_payload()
return {
"id": None,
"employee_id": employee_id,
"start_time": payload["start_time"],
"end_time": payload["end_time"],
"expected_hours": payload["expected_hours"],
"workdays": payload["workdays"],
"date_overrides": payload["date_overrides"],
"is_active": False,
"is_default": True,
"updated_by": None,
"created_at": None,
"updated_at": None,
}
class AttendanceMonthlyMatrixQuerySerializer(serializers.Serializer):
employee_ids = serializers.CharField()
month = serializers.RegexField(r"^\d{4}-\d{2}$")

View File

@ -2,9 +2,10 @@ import logging
from calendar import monthrange
from datetime import date
from attendance.models import AttendanceRecord
from attendance.models import AttendanceRecord, EmployeeWorkSchedule
from attendance.services import (
build_monthly_attendance_matrix,
get_employee_work_schedule_payload,
normalize_attendance_record_manual_issues,
save_logstorm_attendance_result,
)
@ -33,7 +34,9 @@ from .serializers import (
AttendanceRecordCommentSerializer,
AttendanceRecordSerializer,
AttendanceRecordUpdateSerializer,
EmployeeWorkScheduleSerializer,
LogStormAttendanceAnalyzeSerializer,
default_work_schedule_response,
)
logger = logging.getLogger(__name__)
@ -104,15 +107,16 @@ class LogStormAttendanceAnalyzeAPIView(APIView):
status=status.HTTP_404_NOT_FOUND,
)
# TODO: когда schedule UI сможет явно хранить рабочие/выходные дни,
# формировать schedule/date_overrides здесь из EUSRR-календаря, а не
# полагаться на ручной payload или fallback LogStorm.
schedule_payload = data.get("schedule")
if schedule_payload is None:
schedule_payload = get_employee_work_schedule_payload(employee)
try:
result = analyze_employee_attendance(
employee=employee,
period_start=data["period_start"],
period_end=data["period_end"],
schedule=data.get("schedule"),
schedule=schedule_payload,
client=None,
)
except LogStormClientError as exc:
@ -126,7 +130,7 @@ class LogStormAttendanceAnalyzeAPIView(APIView):
employee=employee,
period_start=data["period_start"],
period_end=data["period_end"],
schedule=data.get("schedule"),
schedule=schedule_payload,
)
save_logstorm_attendance_result(
employee=employee,
@ -141,6 +145,41 @@ class LogStormAttendanceAnalyzeAPIView(APIView):
return Response(result)
class EmployeeWorkScheduleAPIView(APIView):
permission_classes = [IsAuthenticated]
def get_employee(self, employee_id):
_check_employee_access(self.request.user, employee_id)
return get_object_or_404(Employee, pk=employee_id)
def get(self, request, employee_id):
employee = self.get_employee(employee_id)
try:
schedule = employee.work_schedule
except EmployeeWorkSchedule.DoesNotExist:
schedule = None
if schedule is None:
return Response(default_work_schedule_response(employee.id))
return Response(EmployeeWorkScheduleSerializer(schedule).data)
def patch(self, request, employee_id):
if not request.user.is_staff:
raise PermissionDenied("You don't have permission to edit schedules")
employee = get_object_or_404(Employee, pk=employee_id)
try:
schedule = employee.work_schedule
except EmployeeWorkSchedule.DoesNotExist:
schedule = None
serializer = EmployeeWorkScheduleSerializer(
schedule,
data=request.data,
partial=True,
)
serializer.is_valid(raise_exception=True)
instance = serializer.save(employee=employee, updated_by=request.user)
return Response(EmployeeWorkScheduleSerializer(instance).data)
class AttendanceRecordListAPIView(ListAPIView):
permission_classes = [IsAuthenticated]
serializer_class = AttendanceRecordSerializer

View File

@ -35,6 +35,7 @@ from .attendance.views import (
AttendanceRecordCommentsAPIView,
AttendanceRecordDetailAPIView,
AttendanceRecordListAPIView,
EmployeeWorkScheduleAPIView,
LogStormAttendanceAnalyzeAPIView,
)
@ -111,6 +112,11 @@ urlpatterns = [
AttendanceMonthlyMatrixAPIView.as_view(),
name="attendance-monthly-matrix",
),
path(
"attendance/work-schedules/<int:employee_id>/",
EmployeeWorkScheduleAPIView.as_view(),
name="attendance-work-schedule",
),
path(
"attendance/records/<int:record_id>/",
AttendanceRecordDetailAPIView.as_view(),

View File

@ -1,6 +1,10 @@
from django.contrib import admin
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
from attendance.models import (
AttendanceAnalysisRun,
AttendanceRecord,
EmployeeWorkSchedule,
)
@admin.register(AttendanceAnalysisRun)
@ -42,3 +46,22 @@ class AttendanceRecordAdmin(admin.ModelAdmin):
)
search_fields = ("employee__email", "employee__first_name", "employee__last_name")
readonly_fields = ("updated_at",)
@admin.register(EmployeeWorkSchedule)
class EmployeeWorkScheduleAdmin(admin.ModelAdmin):
list_display = (
"employee",
"start_time",
"end_time",
"expected_hours",
"is_active",
"updated_at",
)
list_filter = ("is_active",)
search_fields = (
"employee__last_name",
"employee__first_name",
"employee__email",
)
readonly_fields = ("created_at", "updated_at")

View File

@ -0,0 +1,37 @@
# Generated by Django 5.2.4 on 2026-04-22 09:27
import django.db.models.deletion
import datetime
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('attendance', '0003_attendancerecord_manual_edit'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EmployeeWorkSchedule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('start_time', models.TimeField(default=datetime.time(8, 0), verbose_name='Начало рабочего дня')),
('end_time', models.TimeField(default=datetime.time(17, 0), verbose_name='Конец рабочего дня')),
('expected_hours', models.FloatField(default=9, verbose_name='Норма часов')),
('workdays', models.JSONField(blank=True, default=list, verbose_name='Рабочие дни')),
('date_overrides', models.JSONField(blank=True, default=list, verbose_name='Календарные исключения')),
('is_active', models.BooleanField(default=True, verbose_name='Использовать в анализе')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
('employee', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='work_schedule', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_work_schedules', to=settings.AUTH_USER_MODEL, verbose_name='Изменил')),
],
options={
'verbose_name': 'График работы сотрудника',
'verbose_name_plural': 'Графики работы сотрудников',
},
),
]

View File

@ -1,5 +1,6 @@
from django.conf import settings
from django.db import models
from datetime import time
class AttendanceAnalysisRun(models.Model):
@ -51,6 +52,64 @@ class AttendanceAnalysisRun(models.Model):
return f"{self.employee_id}: {self.period_start} - {self.period_end}"
class EmployeeWorkSchedule(models.Model):
DEFAULT_WORKDAYS = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
]
employee = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="work_schedule",
verbose_name="Сотрудник",
)
start_time = models.TimeField("Начало рабочего дня", default=time(8, 0))
end_time = models.TimeField("Конец рабочего дня", default=time(17, 0))
expected_hours = models.FloatField("Норма часов", default=9)
workdays = models.JSONField("Рабочие дни", default=list, blank=True)
date_overrides = models.JSONField(
"Календарные исключения",
default=list,
blank=True,
)
is_active = models.BooleanField("Использовать в анализе", default=True)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="updated_work_schedules",
verbose_name="Изменил",
)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "График работы сотрудника"
verbose_name_plural = "Графики работы сотрудников"
def save(self, *args, **kwargs):
if not self.workdays:
self.workdays = list(self.DEFAULT_WORKDAYS)
super().save(*args, **kwargs)
def __str__(self) -> str:
return f"{self.employee_id}: {self.start_time} - {self.end_time}"
def to_logstorm_payload(self) -> dict:
return {
"start_time": self.start_time.strftime("%H:%M"),
"end_time": self.end_time.strftime("%H:%M"),
"expected_hours": self.expected_hours,
"workdays": list(self.workdays or self.DEFAULT_WORKDAYS),
"date_overrides": list(self.date_overrides or []),
}
class AttendanceRecord(models.Model):
analysis_run = models.ForeignKey(
AttendanceAnalysisRun,

View File

@ -8,7 +8,11 @@ from typing import Any
from django.db import transaction
from django.utils.dateparse import parse_date
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
from attendance.models import (
AttendanceAnalysisRun,
AttendanceRecord,
EmployeeWorkSchedule,
)
from employees.constants import (
ACTION_DISMISSED,
ACTION_ON_DAY_OFF,
@ -65,6 +69,14 @@ SUPPRESSED_EMPLOYEE_ISSUE_MARKERS = (
"недоработ",
)
DEFAULT_WORK_SCHEDULE_PAYLOAD = {
"start_time": "08:00",
"end_time": "17:00",
"expected_hours": 9,
"workdays": list(EmployeeWorkSchedule.DEFAULT_WORKDAYS),
"date_overrides": [],
}
@dataclass(frozen=True)
class PersonnelDayState:
@ -152,6 +164,26 @@ def save_logstorm_attendance_result(
return run
def get_employee_work_schedule_payload(
employee: Employee,
) -> dict[str, Any] | None:
try:
schedule = employee.work_schedule
except EmployeeWorkSchedule.DoesNotExist:
return None
if schedule is None or not schedule.is_active:
return None
return schedule.to_logstorm_payload()
def get_default_work_schedule_payload() -> dict[str, Any]:
return {
**DEFAULT_WORK_SCHEDULE_PAYLOAD,
"workdays": list(DEFAULT_WORK_SCHEDULE_PAYLOAD["workdays"]),
"date_overrides": list(DEFAULT_WORK_SCHEDULE_PAYLOAD["date_overrides"]),
}
def build_monthly_attendance_matrix(
*,
employees,

View File

@ -5,7 +5,11 @@ import pytest
from django.urls import reverse
from django.utils import timezone
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
from attendance.models import (
AttendanceAnalysisRun,
AttendanceRecord,
EmployeeWorkSchedule,
)
from employees.constants import ACTION_ON_LEAVE, ACTION_RETURNED_FROM_LEAVE
from employees.models import EmployeeAction
@ -24,6 +28,10 @@ def _monthly_matrix_url():
return reverse("api:v1:attendance-monthly-matrix")
def _work_schedule_url(employee_id):
return reverse("api:v1:attendance-work-schedule", args=[employee_id])
def _record_detail_url(record_id):
return reverse("api:v1:attendance-record-detail", args=[record_id])
@ -131,6 +139,105 @@ def test_analyze_saves_run_and_records(auth_client_factory, user_factory):
assert record.raw_data["employee_id"] == "42"
def test_work_schedule_endpoint_returns_default_for_self(
auth_client_factory,
user_factory,
):
employee = user_factory()
client = auth_client_factory(employee)
response = client.get(_work_schedule_url(employee.id))
assert response.status_code == 200
assert response.data["employee_id"] == employee.id
assert response.data["is_default"] is True
assert response.data["start_time"] == "08:00"
assert response.data["workdays"] == EmployeeWorkSchedule.DEFAULT_WORKDAYS
def test_staff_can_save_employee_work_schedule(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
employee = user_factory()
client = auth_client_factory(staff)
response = client.patch(
_work_schedule_url(employee.id),
{
"start_time": "09:00",
"end_time": "18:00",
"expected_hours": 8,
"workdays": ["Monday", "Wednesday", "Friday"],
"is_active": True,
},
format="json",
)
assert response.status_code == 200
assert response.data["is_default"] is False
assert response.data["start_time"] == "09:00:00"
assert response.data["workdays"] == ["Monday", "Wednesday", "Friday"]
schedule = EmployeeWorkSchedule.objects.get(employee=employee)
assert schedule.updated_by == staff
assert schedule.expected_hours == 8
def test_non_staff_cannot_save_other_employee_work_schedule(
auth_client_factory,
user_factory,
):
user = user_factory()
employee = user_factory()
client = auth_client_factory(user)
response = client.patch(
_work_schedule_url(employee.id),
{"start_time": "09:00"},
format="json",
)
assert response.status_code == 403
assert not EmployeeWorkSchedule.objects.filter(employee=employee).exists()
def test_analyze_uses_saved_work_schedule_when_payload_omits_schedule(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
employee = user_factory()
EmployeeWorkSchedule.objects.create(
employee=employee,
start_time="10:00",
end_time="19:00",
expected_hours=8,
workdays=["Tuesday"],
)
client = auth_client_factory(staff)
with patch(
"api.v1.attendance.views.analyze_employee_attendance",
return_value=_logstorm_result(),
) as analyze:
response = client.post(_analyze_url(), _payload(employee.id), format="json")
assert response.status_code == 200
schedule = analyze.call_args.kwargs["schedule"]
assert schedule == {
"start_time": "10:00",
"end_time": "19:00",
"expected_hours": 8,
"workdays": ["Tuesday"],
"date_overrides": [],
}
run = AttendanceAnalysisRun.objects.get()
assert run.schedule_payload == schedule
def test_analyze_updates_existing_daily_record(auth_client_factory, user_factory):
staff = user_factory(staff=True)
employee = user_factory()

View File

@ -13,6 +13,7 @@ import {
ProfileHeroCard,
ProfileInfoCard,
ProfileSkillsCard,
ProfileWorkScheduleCard,
type ProfileContactRow,
} from "@/components/users/ProfileSections";
import {
@ -172,6 +173,12 @@ export default function ProfilePage() {
<ProfileInfoCard items={infoItems} />
<ProfileWorkScheduleCard
canEdit={Boolean(user.auth?.is_staff || user.auth?.is_superuser)}
currentAction={currentAction}
employeeId={user.id}
/>
{user.departments?.length ? (
<ProfileDepartmentsCard departments={user.departments} />
) : null}

View File

@ -27,6 +27,7 @@ import {
ProfileHeroCard,
ProfileInfoCard,
ProfileSkillsCard,
ProfileWorkScheduleCard,
type ProfileContactRow,
type ProfileInfoItem,
} from "@/components/users/ProfileSections";
@ -508,6 +509,12 @@ export default function UserDetailPage() {
<ProfileInfoCard items={infoItems} />
<ProfileWorkScheduleCard
canEdit={Boolean(currentUser?.auth?.is_staff || currentUser?.auth?.is_superuser)}
currentAction={latestAction}
employeeId={person.id}
/>
{person.departments?.length ? (
<ProfileDepartmentsCard departments={person.departments} />
) : null}

View File

@ -4,9 +4,7 @@ import { useEffect, useMemo, useState, type FormEvent } from "react";
import {
AlertTriangle,
CalendarCheck,
CalendarClock,
ChevronDown,
ChevronRight,
Clock,
Edit3,
Loader2,
@ -43,18 +41,6 @@ const attendancePeriods = [
type AttendancePeriod = (typeof attendancePeriods)[number]["value"];
const scheduleWeekdays = [
{ 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);
}
@ -441,15 +427,8 @@ export default function EmployeeAttendanceCard({
const [commentsMap, setCommentsMap] = useState<Record<number, AttendanceRecordComment[]>>({});
const [commentsLoadingMap, setCommentsLoadingMap] = useState<Record<number, boolean>>({});
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
const [actionsOpen, setActionsOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [scheduleModalOpen, setScheduleModalOpen] = useState(false);
const [editForm, setEditForm] = useState<AttendanceRecordEditForm | null>(null);
const [useCustomSchedule, setUseCustomSchedule] = useState(false);
const [scheduleStart, setScheduleStart] = useState("08:00");
const [scheduleEnd, setScheduleEnd] = useState("17:00");
const [scheduleExpectedHours, setScheduleExpectedHours] = useState("9");
const [scheduleWorkdays, setScheduleWorkdays] = useState<string[]>(defaultWorkdays);
const canManageAttendance = Boolean(
currentUser?.auth?.is_staff || currentUser?.auth?.is_superuser,
@ -496,15 +475,6 @@ export default function EmployeeAttendanceCard({
employee_id: employeeId,
period_start: periodStart,
period_end: periodEnd,
schedule: useCustomSchedule
? {
start_time: scheduleStart,
end_time: scheduleEnd,
expected_hours: Number(scheduleExpectedHours) || 0,
workdays: scheduleWorkdays,
date_overrides: [],
}
: undefined,
});
const savedRecords = await apiClient.getLogStormAttendanceRecords({
employee_id: employeeId,
@ -671,18 +641,11 @@ export default function EmployeeAttendanceCard({
if (!form) {
setError("Нет сохраненных записей для редактирования");
setActionsOpen(false);
return;
}
setEditForm(form);
setEditModalOpen(true);
setActionsOpen(false);
}
function openScheduleModal() {
setScheduleModalOpen(true);
setActionsOpen(false);
}
function handleEditRecordSelect(recordId: string) {
@ -741,14 +704,6 @@ export default function EmployeeAttendanceCard({
}
}
function toggleScheduleWorkday(day: string) {
setScheduleWorkdays((current) =>
current.includes(day)
? current.filter((item) => item !== day)
: [...current, day],
);
}
return (
<>
<section className="app-surface rounded-2xl p-4 sm:p-5">
@ -773,43 +728,15 @@ export default function EmployeeAttendanceCard({
</button>
{canManageAttendance ? (
<div className="relative">
<button
type="button"
onClick={() => setActionsOpen((current) => !current)}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
aria-expanded={actionsOpen}
aria-haspopup="menu"
aria-label="Действия посещаемости"
title="Действия посещаемости"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${actionsOpen ? "rotate-90" : ""}`}
/>
</button>
{actionsOpen ? (
<div className="app-menu absolute right-0 top-full z-30 mt-2 w-64 rounded-xl py-1.5">
<button
type="button"
onClick={openEditModal}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Edit3 size={14} className="app-text-muted" />
Редактировать записи
</button>
<button
type="button"
onClick={openScheduleModal}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<CalendarClock size={14} className="app-text-muted" />
Изменить график анализа
</button>
</div>
) : null}
</div>
<button
type="button"
onClick={openEditModal}
className="app-action-ghost flex h-9 w-9 items-center justify-center rounded-md"
aria-label="Редактировать записи посещаемости"
title="Редактировать записи"
>
<Edit3 size={15} />
</button>
) : null}
</div>
</div>
@ -1276,92 +1203,6 @@ export default function EmployeeAttendanceCard({
)}
</Modal>
<Modal
isOpen={scheduleModalOpen}
onClose={() => setScheduleModalOpen(false)}
title="График анализа"
size="md"
footer={(
<div className="flex flex-wrap justify-end gap-2">
<button
type="button"
onClick={() => setScheduleModalOpen(false)}
className="app-action-primary rounded-lg px-4 py-2 text-sm font-semibold"
>
Готово
</button>
</div>
)}
>
<div className="space-y-4">
<label className="flex items-center gap-3 text-sm font-medium text-[var(--foreground)]">
<input
type="checkbox"
checked={useCustomSchedule}
onChange={(event) => setUseCustomSchedule(event.target.checked)}
className="h-4 w-4 rounded border-[var(--border-subtle)]"
/>
Использовать ручной график при обновлении
</label>
<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-xl px-3 py-2.5 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-xl px-3 py-2.5 text-sm"
/>
</label>
<label className="block">
<span className="app-card-caption mb-2 block">Норма</span>
<input
type="number"
step="0.01"
value={scheduleExpectedHours}
onChange={(event) => setScheduleExpectedHours(event.target.value)}
className="app-input w-full rounded-xl px-3 py-2.5 text-sm"
/>
</label>
</div>
<div>
<p className="app-card-caption mb-2">Рабочие дни</p>
<div className="flex flex-wrap gap-2">
{scheduleWeekdays.map((day) => {
const active = scheduleWorkdays.includes(day.value);
return (
<button
key={day.value}
type="button"
onClick={() => toggleScheduleWorkday(day.value)}
className={active
? "app-action-primary rounded-lg px-3 py-2 text-xs font-semibold"
: "app-action-secondary rounded-lg px-3 py-2 text-xs font-semibold"
}
>
{day.label}
</button>
);
})}
</div>
</div>
<p className="app-text-muted text-xs">
Этот график применяется к следующему анализу LogStorm и не заменяет производственный календарь EUSRR.
</p>
</div>
</Modal>
</>
);
}

View File

@ -1,9 +1,23 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import { Crown, Link2, Plus, X } from "lucide-react";
import type { EmployeeDepartment, Skill } from "@/types/api";
import { useEffect, useState, type ReactNode } from "react";
import {
CalendarClock,
Check,
Clock3,
Crown,
Link2,
Loader2,
Pencil,
Plus,
Timer,
X,
} from "lucide-react";
import { apiClient } from "@/lib/api";
import type { EmployeeWorkSchedule } from "@/lib/api/attendance";
import type { EmployeeAction, EmployeeDepartment, Skill } from "@/types/api";
import { getEmployeeActionTone } from "@/lib/users/userDetailUtils";
export type ProfileContactRow = {
key: string;
@ -20,6 +34,47 @@ export type ProfileInfoItem = {
value: string;
};
const workScheduleDays = [
{ 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 compactDays(days: string[]) {
const labels = workScheduleDays
.filter((day) => days.includes(day.value))
.map((day) => day.label);
return labels.length ? labels.join(", ") : "Не указаны";
}
function normalizeScheduleTime(value?: string) {
if (!value) return "";
return value.slice(0, 5);
}
function defaultSchedule(employeeId: number): EmployeeWorkSchedule {
return {
id: null,
employee_id: employeeId,
start_time: "08:00",
end_time: "17:00",
expected_hours: 9,
workdays: [...defaultWorkdays],
date_overrides: [],
is_active: false,
is_default: true,
updated_by: null,
created_at: null,
updated_at: null,
};
}
function SectionTitle({
title,
action,
@ -192,6 +247,307 @@ export function ProfileInfoCard({
);
}
export function ProfileWorkScheduleCard({
title = "График работы",
employeeId,
canEdit = false,
currentAction,
}: {
title?: string;
employeeId: number;
canEdit?: boolean;
currentAction?: EmployeeAction | null;
}) {
const [schedule, setSchedule] = useState<EmployeeWorkSchedule>(() =>
defaultSchedule(employeeId),
);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState({
start_time: "08:00",
end_time: "17:00",
expected_hours: "9",
workdays: [...defaultWorkdays],
});
useEffect(() => {
let mounted = true;
async function loadSchedule() {
try {
setLoading(true);
setError(null);
const data = await apiClient.getEmployeeWorkSchedule(employeeId);
if (!mounted) return;
setSchedule(data);
setForm({
start_time: normalizeScheduleTime(data.start_time) || "08:00",
end_time: normalizeScheduleTime(data.end_time) || "17:00",
expected_hours: String(data.expected_hours ?? 9),
workdays: data.workdays?.length ? [...data.workdays] : [...defaultWorkdays],
});
} catch (loadError) {
if (!mounted) return;
setError(String((loadError as Error)?.message || "Не удалось загрузить график"));
} finally {
if (mounted) {
setLoading(false);
}
}
}
void loadSchedule();
return () => {
mounted = false;
};
}, [employeeId]);
const startTime = normalizeScheduleTime(schedule.start_time) || "08:00";
const endTime = normalizeScheduleTime(schedule.end_time) || "17:00";
const expectedHours = Number(schedule.expected_hours || 0) || 9;
const workdays = schedule.workdays?.length ? schedule.workdays : defaultWorkdays;
const weekendDays = workScheduleDays
.map((day) => day.value)
.filter((day) => !workdays.includes(day));
const actionTone = currentAction
? getEmployeeActionTone(currentAction.action)
: null;
const toggleFormWorkday = (day: string) => {
setForm((current) => ({
...current,
workdays: current.workdays.includes(day)
? current.workdays.filter((item) => item !== day)
: [...current.workdays, day],
}));
};
const saveSchedule = async () => {
if (saving) return;
try {
setSaving(true);
setError(null);
const updated = await apiClient.updateEmployeeWorkSchedule(employeeId, {
start_time: form.start_time,
end_time: form.end_time,
expected_hours: Number(form.expected_hours) || 0,
workdays: form.workdays,
date_overrides: schedule.date_overrides || [],
is_active: true,
});
setSchedule(updated);
setEditing(false);
} catch (saveError) {
setError(String((saveError as Error)?.message || "Не удалось сохранить график"));
} finally {
setSaving(false);
}
};
return (
<section className="app-surface rounded-2xl p-5">
<SectionTitle
title={title}
action={(
<div className="flex flex-wrap items-center justify-end gap-2">
{schedule.is_default ? (
<span className="app-status-pill app-feedback-warning">
По умолчанию
</span>
) : currentAction ? (
<span className={`app-status-pill ${actionTone?.badgeClass || ""}`}>
{currentAction.action_display || currentAction.action}
</span>
) : (
<span className="app-status-pill app-feedback-success">Активен</span>
)}
{canEdit ? (
<button
type="button"
onClick={() => setEditing((current) => !current)}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
title="Редактировать график"
aria-label="Редактировать график"
>
{editing ? <X size={15} /> : <Pencil size={15} />}
</button>
) : null}
</div>
)}
/>
<div className="app-surface-muted rounded-2xl p-4">
{loading ? (
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
<Loader2 size={16} className="animate-spin" />
Загружаем график...
</div>
) : null}
{!loading && editing ? (
<div className="mb-4 space-y-3 rounded-xl border border-[var(--border-subtle)] p-3">
<div className="grid gap-2 sm:grid-cols-3">
<label className="block">
<span className="app-card-caption mb-1 block">Начало</span>
<input
type="time"
value={form.start_time}
onChange={(event) =>
setForm((current) => ({ ...current, start_time: 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-1 block">Конец</span>
<input
type="time"
value={form.end_time}
onChange={(event) =>
setForm((current) => ({ ...current, end_time: 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-1 block">Часов</span>
<input
type="number"
min="1"
max="24"
step="0.25"
value={form.expected_hours}
onChange={(event) =>
setForm((current) => ({
...current,
expected_hours: 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">
{workScheduleDays.map((day) => {
const active = form.workdays.includes(day.value);
return (
<button
key={day.value}
type="button"
onClick={() => toggleFormWorkday(day.value)}
className={active
? "app-action-primary rounded-lg px-3 py-2 text-xs font-medium"
: "app-action-secondary rounded-lg px-3 py-2 text-xs font-medium"
}
>
{day.label}
</button>
);
})}
</div>
<div className="flex flex-wrap justify-end gap-2">
<button
type="button"
onClick={() => setEditing(false)}
className="app-action-secondary rounded-xl px-4 py-2 text-sm font-medium"
>
Отмена
</button>
<button
type="button"
onClick={() => void saveSchedule()}
disabled={saving}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{saving ? <Loader2 size={15} className="animate-spin" /> : <Check size={15} />}
Сохранить
</button>
</div>
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex items-start gap-3">
<span className="app-badge flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
<Clock3 size={18} />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">
{startTime} — {endTime}
</p>
<p className="app-text-muted mt-1 text-sm">Рабочее время</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="app-badge flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
<Timer size={18} />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">
{expectedHours.toLocaleString("ru-RU")} ч.
</p>
<p className="app-text-muted mt-1 text-sm">Норма в день</p>
</div>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{workScheduleDays.map((day) => {
const active = workdays.includes(day.value);
return (
<span
key={day.value}
className={[
"inline-flex h-9 w-9 items-center justify-center rounded-xl border text-xs font-semibold",
active
? "border-[color:color-mix(in_srgb,var(--accent-primary)_45%,var(--border-subtle))] bg-[color:color-mix(in_srgb,var(--accent-primary)_16%,transparent)] text-[var(--accent-primary)]"
: "border-[var(--border-subtle)] bg-[var(--surface-secondary)] text-[var(--muted-foreground)]",
].join(" ")}
>
{day.label}
</span>
);
})}
</div>
<div className="mt-4 grid gap-2 text-sm sm:grid-cols-2">
<div className="rounded-xl border border-[var(--border-subtle)] px-3 py-2">
<p className="app-card-caption mb-1">Рабочие дни</p>
<p className="app-text-wrap text-[var(--foreground)]">
{compactDays(workdays)}
</p>
</div>
<div className="rounded-xl border border-[var(--border-subtle)] px-3 py-2">
<p className="app-card-caption mb-1">Выходные</p>
<p className="app-text-wrap text-[var(--foreground)]">
{compactDays(weekendDays)}
</p>
</div>
</div>
{currentAction ? (
<div className="mt-3 flex items-start gap-2 rounded-xl border border-[var(--border-subtle)] px-3 py-2 text-sm">
<CalendarClock size={16} className="mt-0.5 shrink-0 text-[var(--muted-foreground)]" />
<p className="app-text-muted app-text-wrap">
Кадровое состояние с {currentAction.date_display || currentAction.date}
</p>
</div>
) : null}
{error ? (
<p className="mt-3 text-sm text-red-400">{error}</p>
) : null}
</div>
</section>
);
}
export function ProfileSkillsCard({
title = "Навыки",
inputValue,

View File

@ -18,6 +18,16 @@ export type LogStormSchedulePayload = {
date_overrides?: LogStormDateOverridePayload[];
};
export type EmployeeWorkSchedule = LogStormSchedulePayload & {
id: number | null;
employee_id: number;
is_active: boolean;
is_default: boolean;
updated_by?: number | null;
created_at?: string | null;
updated_at?: string | null;
};
export type LogStormAttendanceAnalyzePayload = {
employee_id: number;
period_start: string;
@ -172,6 +182,16 @@ export function createAttendanceApi(request: RequestFn) {
request(`/api/v1/attendance/records/${buildQuery(params)}`) as Promise<PaginatedLogStormAttendanceRecords>,
getMonthlyAttendanceMatrix: (params: MonthlyAttendanceMatrixQuery) =>
request(`/api/v1/attendance/monthly-matrix/${buildQuery(params)}`) as Promise<MonthlyAttendanceMatrix>,
getEmployeeWorkSchedule: (employeeId: number | string) =>
request(`/api/v1/attendance/work-schedules/${employeeId}/`) as Promise<EmployeeWorkSchedule>,
updateEmployeeWorkSchedule: (
employeeId: number | string,
data: Partial<LogStormSchedulePayload & { is_active: boolean }>,
) =>
request(`/api/v1/attendance/work-schedules/${employeeId}/`, {
method: 'PATCH',
body: JSON.stringify(data),
}) as Promise<EmployeeWorkSchedule>,
updateAttendanceRecord: (recordId: number, data: LogStormAttendanceRecordUpdatePayload) =>
request(`/api/v1/attendance/records/${recordId}/`, {
method: 'PATCH',