Merge branch 'develop'
This commit is contained in:
commit
77cb8ef0cb
@ -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}$")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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")
|
||||
|
||||
37
backend/attendance/migrations/0004_employeeworkschedule.py
Normal file
37
backend/attendance/migrations/0004_employeeworkschedule.py
Normal 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': 'Графики работы сотрудников',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user