diff --git a/backend/api/v1/attendance/serializers.py b/backend/api/v1/attendance/serializers.py index d434a950..06eb8b04 100644 --- a/backend/api/v1/attendance/serializers.py +++ b/backend/api/v1/attendance/serializers.py @@ -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}$") diff --git a/backend/api/v1/attendance/views.py b/backend/api/v1/attendance/views.py index 42a64cef..dc7efec5 100644 --- a/backend/api/v1/attendance/views.py +++ b/backend/api/v1/attendance/views.py @@ -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 diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 0905dcb5..76fd9158 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -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//", + EmployeeWorkScheduleAPIView.as_view(), + name="attendance-work-schedule", + ), path( "attendance/records//", AttendanceRecordDetailAPIView.as_view(), diff --git a/backend/attendance/admin.py b/backend/attendance/admin.py index 9cec6532..5339956c 100644 --- a/backend/attendance/admin.py +++ b/backend/attendance/admin.py @@ -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") diff --git a/backend/attendance/migrations/0004_employeeworkschedule.py b/backend/attendance/migrations/0004_employeeworkschedule.py new file mode 100644 index 00000000..5c893414 --- /dev/null +++ b/backend/attendance/migrations/0004_employeeworkschedule.py @@ -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': 'Графики работы сотрудников', + }, + ), + ] diff --git a/backend/attendance/models.py b/backend/attendance/models.py index c4e5e9dd..7df9092c 100644 --- a/backend/attendance/models.py +++ b/backend/attendance/models.py @@ -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, diff --git a/backend/attendance/services.py b/backend/attendance/services.py index 79c50f1b..be1f2af5 100644 --- a/backend/attendance/services.py +++ b/backend/attendance/services.py @@ -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, diff --git a/backend/tests/api/v1/attendance/test_attendance_storage.py b/backend/tests/api/v1/attendance/test_attendance_storage.py index 79799c34..675377c8 100644 --- a/backend/tests/api/v1/attendance/test_attendance_storage.py +++ b/backend/tests/api/v1/attendance/test_attendance_storage.py @@ -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() diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index 0b8a7949..56b8dfbe 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -13,6 +13,7 @@ import { ProfileHeroCard, ProfileInfoCard, ProfileSkillsCard, + ProfileWorkScheduleCard, type ProfileContactRow, } from "@/components/users/ProfileSections"; import { @@ -172,6 +173,12 @@ export default function ProfilePage() { + + {user.departments?.length ? ( ) : null} diff --git a/frontend/src/app/users/[id]/page.tsx b/frontend/src/app/users/[id]/page.tsx index e2216966..72f871b2 100644 --- a/frontend/src/app/users/[id]/page.tsx +++ b/frontend/src/app/users/[id]/page.tsx @@ -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() { + + {person.departments?.length ? ( ) : null} diff --git a/frontend/src/components/attendance/EmployeeAttendanceCard.tsx b/frontend/src/components/attendance/EmployeeAttendanceCard.tsx index 6a4bb6e0..cfc9e723 100644 --- a/frontend/src/components/attendance/EmployeeAttendanceCard.tsx +++ b/frontend/src/components/attendance/EmployeeAttendanceCard.tsx @@ -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>({}); const [commentsLoadingMap, setCommentsLoadingMap] = useState>({}); const [commentDrafts, setCommentDrafts] = useState>({}); - const [actionsOpen, setActionsOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false); - const [scheduleModalOpen, setScheduleModalOpen] = useState(false); const [editForm, setEditForm] = useState(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(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 ( <>
@@ -773,43 +728,15 @@ export default function EmployeeAttendanceCard({ {canManageAttendance ? ( -
- - - {actionsOpen ? ( -
- - -
- ) : null} -
+ ) : null} @@ -1276,92 +1203,6 @@ export default function EmployeeAttendanceCard({ )} - setScheduleModalOpen(false)} - title="График анализа" - size="md" - footer={( -
- -
- )} - > -
- - -
- - - -
- -
-

Рабочие дни

-
- {scheduleWeekdays.map((day) => { - const active = scheduleWorkdays.includes(day.value); - return ( - - ); - })} -
-
- -

- Этот график применяется к следующему анализу LogStorm и не заменяет производственный календарь EUSRR. -

-
-
); } diff --git a/frontend/src/components/users/ProfileSections.tsx b/frontend/src/components/users/ProfileSections.tsx index f6f2cacd..4539f150 100644 --- a/frontend/src/components/users/ProfileSections.tsx +++ b/frontend/src/components/users/ProfileSections.tsx @@ -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(() => + defaultSchedule(employeeId), + ); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [editing, setEditing] = useState(false); + const [error, setError] = useState(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 ( +
+ + {schedule.is_default ? ( + + По умолчанию + + ) : currentAction ? ( + + {currentAction.action_display || currentAction.action} + + ) : ( + Активен + )} + {canEdit ? ( + + ) : null} + + )} + /> + +
+ {loading ? ( +
+ + Загружаем график... +
+ ) : null} + + {!loading && editing ? ( +
+
+ + + +
+ +
+ {workScheduleDays.map((day) => { + const active = form.workdays.includes(day.value); + return ( + + ); + })} +
+ +
+ + +
+
+ ) : null} + +
+
+ + + +
+

+ {startTime} — {endTime} +

+

Рабочее время

+
+
+ +
+ + + +
+

+ {expectedHours.toLocaleString("ru-RU")} ч. +

+

Норма в день

+
+
+
+ +
+ {workScheduleDays.map((day) => { + const active = workdays.includes(day.value); + return ( + + {day.label} + + ); + })} +
+ +
+
+

Рабочие дни

+

+ {compactDays(workdays)} +

+
+
+

Выходные

+

+ {compactDays(weekendDays)} +

+
+
+ + {currentAction ? ( +
+ +

+ Кадровое состояние с {currentAction.date_display || currentAction.date} +

+
+ ) : null} + + {error ? ( +

{error}

+ ) : null} +
+
+ ); +} + export function ProfileSkillsCard({ title = "Навыки", inputValue, diff --git a/frontend/src/lib/api/attendance.ts b/frontend/src/lib/api/attendance.ts index cd483dd7..ffd8fca3 100644 --- a/frontend/src/lib/api/attendance.ts +++ b/frontend/src/lib/api/attendance.ts @@ -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, getMonthlyAttendanceMatrix: (params: MonthlyAttendanceMatrixQuery) => request(`/api/v1/attendance/monthly-matrix/${buildQuery(params)}`) as Promise, + getEmployeeWorkSchedule: (employeeId: number | string) => + request(`/api/v1/attendance/work-schedules/${employeeId}/`) as Promise, + updateEmployeeWorkSchedule: ( + employeeId: number | string, + data: Partial, + ) => + request(`/api/v1/attendance/work-schedules/${employeeId}/`, { + method: 'PATCH', + body: JSON.stringify(data), + }) as Promise, updateAttendanceRecord: (recordId: number, data: LogStormAttendanceRecordUpdatePayload) => request(`/api/v1/attendance/records/${recordId}/`, { method: 'PATCH',