Fix request date filters and employee attendance status
This commit is contained in:
parent
c0cff00e82
commit
98096a7af4
@ -89,6 +89,75 @@ def _employee_personnel_state_payload(obj):
|
||||
}
|
||||
|
||||
|
||||
def _normalize_attendance_time(value) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
if "T" in text:
|
||||
text = text.split("T", 1)[1]
|
||||
if len(text) >= 5 and text[2] == ":":
|
||||
return text[:5]
|
||||
return text
|
||||
|
||||
|
||||
def _employee_attendance_status_payload(obj):
|
||||
has_attendance_annotation = hasattr(obj, "latest_attendance_record_id")
|
||||
record_id = getattr(obj, "latest_attendance_record_id", None)
|
||||
record_date = getattr(obj, "latest_attendance_date", None)
|
||||
arrival_time = getattr(obj, "latest_attendance_arrival_time", None)
|
||||
departure_time = getattr(obj, "latest_attendance_departure_time", None)
|
||||
|
||||
if (
|
||||
record_id is None
|
||||
and not has_attendance_annotation
|
||||
and hasattr(obj, "attendance_records")
|
||||
):
|
||||
latest_record = obj.attendance_records.order_by("-date", "-id").first()
|
||||
if latest_record is not None:
|
||||
record_id = latest_record.id
|
||||
record_date = latest_record.date
|
||||
arrival_time = latest_record.arrival_time
|
||||
departure_time = latest_record.departure_time
|
||||
|
||||
if record_id is None or record_date is None:
|
||||
return None
|
||||
|
||||
event = "none"
|
||||
label = "Без отметок"
|
||||
time_value = None
|
||||
|
||||
departure = _normalize_attendance_time(departure_time)
|
||||
arrival = _normalize_attendance_time(arrival_time)
|
||||
if departure:
|
||||
event = "departure"
|
||||
label = "Уход"
|
||||
time_value = departure
|
||||
elif arrival:
|
||||
event = "arrival"
|
||||
label = "Приход"
|
||||
time_value = arrival
|
||||
|
||||
if hasattr(record_date, "strftime"):
|
||||
display_date = record_date.strftime("%d.%m.%Y")
|
||||
else:
|
||||
display_date = str(record_date)
|
||||
|
||||
display = f"{label} · {display_date}"
|
||||
if time_value:
|
||||
display = f"{display}, {time_value}"
|
||||
|
||||
return {
|
||||
"record_id": record_id,
|
||||
"date": record_date,
|
||||
"time": time_value,
|
||||
"event": event,
|
||||
"label": label,
|
||||
"display": display,
|
||||
}
|
||||
|
||||
|
||||
class EmployeeDepartmentSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField(allow_null=True)
|
||||
@ -114,6 +183,15 @@ class EmployeePersonnelStateSerializer(serializers.Serializer):
|
||||
expects_attendance = serializers.BooleanField()
|
||||
|
||||
|
||||
class EmployeeAttendanceStatusSerializer(serializers.Serializer):
|
||||
record_id = serializers.IntegerField()
|
||||
date = serializers.DateField()
|
||||
time = serializers.CharField(allow_null=True)
|
||||
event = serializers.CharField()
|
||||
label = serializers.CharField()
|
||||
display = serializers.CharField()
|
||||
|
||||
|
||||
class EmployeeSerializer(serializers.ModelSerializer):
|
||||
"""Полная версия сотрудника для /employees/."""
|
||||
|
||||
@ -144,6 +222,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
|
||||
departments = serializers.SerializerMethodField()
|
||||
auth = serializers.SerializerMethodField(read_only=True)
|
||||
personnel_state = serializers.SerializerMethodField(read_only=True)
|
||||
attendance_status = serializers.SerializerMethodField(read_only=True)
|
||||
linked_tasks = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
@ -179,6 +258,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
|
||||
"date_joined",
|
||||
"auth",
|
||||
"personnel_state",
|
||||
"attendance_status",
|
||||
"linked_tasks",
|
||||
)
|
||||
read_only_fields = (
|
||||
@ -192,6 +272,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
|
||||
"date_joined",
|
||||
"auth",
|
||||
"personnel_state",
|
||||
"attendance_status",
|
||||
"linked_tasks",
|
||||
)
|
||||
extra_kwargs = {
|
||||
@ -346,6 +427,10 @@ class EmployeeSerializer(serializers.ModelSerializer):
|
||||
def get_personnel_state(self, obj):
|
||||
return _employee_personnel_state_payload(obj)
|
||||
|
||||
@extend_schema_field(EmployeeAttendanceStatusSerializer(allow_null=True))
|
||||
def get_attendance_status(self, obj):
|
||||
return _employee_attendance_status_payload(obj)
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||
def get_linked_tasks(self, obj):
|
||||
prefetched = getattr(obj, "_linked_task_payloads", None)
|
||||
@ -415,6 +500,7 @@ class EmployeeListSerializer(serializers.ModelSerializer):
|
||||
skills = SkillSerializer(many=True, read_only=True)
|
||||
department_relation = serializers.SerializerMethodField()
|
||||
personnel_state = serializers.SerializerMethodField(read_only=True)
|
||||
attendance_status = serializers.SerializerMethodField(read_only=True)
|
||||
linked_tasks = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
@ -435,6 +521,7 @@ class EmployeeListSerializer(serializers.ModelSerializer):
|
||||
"display_name",
|
||||
"department_relation",
|
||||
"personnel_state",
|
||||
"attendance_status",
|
||||
"linked_tasks",
|
||||
)
|
||||
|
||||
@ -442,6 +529,10 @@ class EmployeeListSerializer(serializers.ModelSerializer):
|
||||
def get_personnel_state(self, obj):
|
||||
return _employee_personnel_state_payload(obj)
|
||||
|
||||
@extend_schema_field(EmployeeAttendanceStatusSerializer(allow_null=True))
|
||||
def get_attendance_status(self, obj):
|
||||
return _employee_attendance_status_payload(obj)
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
|
||||
def get_linked_tasks(self, obj):
|
||||
prefetched = getattr(obj, "_linked_task_payloads", None)
|
||||
|
||||
@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from common.emails import send_templated_mail
|
||||
from attendance.models import AttendanceRecord
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Exists, Max, OuterRef, Prefetch, Q, Subquery
|
||||
from django.utils.crypto import get_random_string
|
||||
@ -76,6 +77,26 @@ class EmployeeViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _with_last_attendance_status(queryset):
|
||||
latest_attendance = AttendanceRecord.objects.filter(
|
||||
employee_id=OuterRef("pk")
|
||||
).order_by("-date", "-id")
|
||||
return queryset.annotate(
|
||||
latest_attendance_record_id=Subquery(
|
||||
latest_attendance.values("id")[:1]
|
||||
),
|
||||
latest_attendance_date=Subquery(
|
||||
latest_attendance.values("date")[:1]
|
||||
),
|
||||
latest_attendance_arrival_time=Subquery(
|
||||
latest_attendance.values("arrival_time")[:1]
|
||||
),
|
||||
latest_attendance_departure_time=Subquery(
|
||||
latest_attendance.values("departure_time")[:1]
|
||||
),
|
||||
)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == "create":
|
||||
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
|
||||
@ -112,11 +133,13 @@ class EmployeeViewSet(viewsets.ModelViewSet):
|
||||
),
|
||||
]
|
||||
|
||||
qs = self._with_last_activity(
|
||||
Employee.objects.select_related("position")
|
||||
.prefetch_related(*prefetches)
|
||||
.annotate(last_action_code=last_action_code_sq)
|
||||
.order_by(*self.ordering)
|
||||
qs = self._with_last_attendance_status(
|
||||
self._with_last_activity(
|
||||
Employee.objects.select_related("position")
|
||||
.prefetch_related(*prefetches)
|
||||
.annotate(last_action_code=last_action_code_sq)
|
||||
.order_by(*self.ordering)
|
||||
)
|
||||
)
|
||||
|
||||
qp = self.request.query_params
|
||||
|
||||
@ -341,15 +341,20 @@ class RequestViewSet(viewsets.ModelViewSet):
|
||||
if created_to:
|
||||
qs = qs.filter(created_at__date__lte=created_to)
|
||||
|
||||
# Фильтры по периоду заявления (date_from/date_to в самой заявке)
|
||||
# Фильтры по периоду заявления (date_from/date_to в самой заявке).
|
||||
# Пустой date_to означает однодневное заявление на date_from.
|
||||
# Заявления без date_from не имеют периода и не участвуют в period-фильтре.
|
||||
if date_from or date_to:
|
||||
qs = qs.filter(date_from__isnull=False)
|
||||
if date_from:
|
||||
# Заявки, у которых период не закончился до указанной даты
|
||||
qs = qs.filter(Q(date_to__isnull=True) | Q(date_to__gte=date_from))
|
||||
if date_to:
|
||||
# Заявки, у которых период не начался после указанной даты
|
||||
# effective_date_to >= date_from, где date_to=NULL считается date_from.
|
||||
qs = qs.filter(
|
||||
Q(date_from__isnull=True) | Q(date_from__lte=date_to)
|
||||
Q(date_to__gte=date_from)
|
||||
| Q(date_to__isnull=True, date_from__gte=date_from)
|
||||
)
|
||||
if date_to:
|
||||
# effective_date_from <= date_to.
|
||||
qs = qs.filter(date_from__lte=date_to)
|
||||
|
||||
# Аннотируем счётчик комментариев через communications.Chat
|
||||
from communications.models import Message
|
||||
|
||||
@ -3,6 +3,7 @@ import itertools
|
||||
import datetime as dt
|
||||
|
||||
import pytest
|
||||
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
@ -189,6 +190,75 @@ def test_retrieve_ok(api_client: APIClient):
|
||||
assert data["email"] == e.email
|
||||
|
||||
|
||||
def test_retrieve_exposes_last_attendance_status_to_authenticated_users(
|
||||
api_client: APIClient,
|
||||
):
|
||||
viewer = make_user("attendance-viewer@example.com")
|
||||
target = make_user("attendance-target@example.com")
|
||||
run = AttendanceAnalysisRun.objects.create(
|
||||
employee=target,
|
||||
period_start=dt.date(2026, 4, 1),
|
||||
period_end=dt.date(2026, 4, 30),
|
||||
)
|
||||
AttendanceRecord.objects.create(
|
||||
analysis_run=run,
|
||||
employee=target,
|
||||
date=dt.date(2026, 4, 10),
|
||||
arrival_time="08:35",
|
||||
)
|
||||
latest = AttendanceRecord.objects.create(
|
||||
analysis_run=run,
|
||||
employee=target,
|
||||
date=dt.date(2026, 4, 11),
|
||||
arrival_time="08:20",
|
||||
departure_time="17:45",
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=viewer)
|
||||
response = api_client.get(reverse("api:v1:employees-detail", args=[target.pk]))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
payload = response.json()["attendance_status"]
|
||||
assert payload == {
|
||||
"record_id": latest.id,
|
||||
"date": "2026-04-11",
|
||||
"time": "17:45",
|
||||
"event": "departure",
|
||||
"label": "Уход",
|
||||
"display": "Уход · 11.04.2026, 17:45",
|
||||
}
|
||||
|
||||
|
||||
def test_retrieve_last_attendance_status_uses_arrival_when_no_departure(
|
||||
api_client: APIClient,
|
||||
):
|
||||
viewer = make_user("attendance-arrival-viewer@example.com")
|
||||
target = make_user("attendance-arrival-target@example.com")
|
||||
run = AttendanceAnalysisRun.objects.create(
|
||||
employee=target,
|
||||
period_start=dt.date(2026, 4, 1),
|
||||
period_end=dt.date(2026, 4, 30),
|
||||
)
|
||||
record = AttendanceRecord.objects.create(
|
||||
analysis_run=run,
|
||||
employee=target,
|
||||
date=dt.date(2026, 4, 12),
|
||||
arrival_time="09:05",
|
||||
departure_time=None,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=viewer)
|
||||
response = api_client.get(reverse("api:v1:employees-detail", args=[target.pk]))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
payload = response.json()["attendance_status"]
|
||||
assert payload["record_id"] == record.id
|
||||
assert payload["date"] == "2026-04-12"
|
||||
assert payload["time"] == "09:05"
|
||||
assert payload["event"] == "arrival"
|
||||
assert payload["label"] == "Приход"
|
||||
|
||||
|
||||
def test_update_attendance_aliases_normalizes_values(api_client: APIClient):
|
||||
staff = make_user("staff@example.com", staff=True)
|
||||
employee = make_user("alias@example.com")
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@ -36,6 +36,10 @@ def _results(payload: Any) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
def _result_ids(payload: Any) -> set[int]:
|
||||
return {item["id"] for item in _results(payload)}
|
||||
|
||||
|
||||
def test_list_unauth_401(api_client: APIClient) -> None:
|
||||
"""Неаутентифицированный доступ к списку → 401."""
|
||||
resp = api_client.get(API_BASE)
|
||||
@ -222,6 +226,117 @@ def test_list_addressed_to_me_shows_only_recipient_and_cc_requests(
|
||||
assert cc_request.id in ids
|
||||
|
||||
|
||||
def test_list_period_filter_matches_overlap_and_excludes_missing_period(
|
||||
auth_client, regular_user: models.Model, make_request
|
||||
) -> None:
|
||||
"""Фильтр периода ищет пересечение интервалов и не подтягивает заявки без дат."""
|
||||
no_period = make_request(employee=regular_user, type_=RequestType.OTHER)
|
||||
|
||||
single_day = make_request(employee=regular_user, type_=RequestType.OTHER)
|
||||
single_day.date_from = date(2026, 4, 10)
|
||||
single_day.date_to = None
|
||||
single_day.save(update_fields=["date_from", "date_to"])
|
||||
|
||||
spanning = make_request(employee=regular_user, type_=RequestType.OTHER)
|
||||
spanning.date_from = date(2026, 4, 5)
|
||||
spanning.date_to = date(2026, 4, 12)
|
||||
spanning.save(update_fields=["date_from", "date_to"])
|
||||
|
||||
before = make_request(employee=regular_user, type_=RequestType.OTHER)
|
||||
before.date_from = date(2026, 4, 1)
|
||||
before.date_to = date(2026, 4, 3)
|
||||
before.save(update_fields=["date_from", "date_to"])
|
||||
|
||||
after = make_request(employee=regular_user, type_=RequestType.OTHER)
|
||||
after.date_from = date(2026, 4, 20)
|
||||
after.date_to = None
|
||||
after.save(update_fields=["date_from", "date_to"])
|
||||
|
||||
resp = auth_client(regular_user).get(
|
||||
f"{API_BASE}?date_from=2026-04-10&date_to=2026-04-10"
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
ids = _result_ids(resp.json())
|
||||
assert single_day.id in ids
|
||||
assert spanning.id in ids
|
||||
assert no_period.id not in ids
|
||||
assert before.id not in ids
|
||||
assert after.id not in ids
|
||||
|
||||
|
||||
def test_list_period_filter_with_single_boundary_handles_single_day_requests(
|
||||
auth_client, regular_user: models.Model, make_request
|
||||
) -> None:
|
||||
"""Одна граница периода корректно работает для date_from без date_to."""
|
||||
no_period = make_request(employee=regular_user, type_=RequestType.OTHER)
|
||||
|
||||
old_single_day = make_request(employee=regular_user, type_=RequestType.OTHER)
|
||||
old_single_day.date_from = date(2026, 4, 1)
|
||||
old_single_day.date_to = None
|
||||
old_single_day.save(update_fields=["date_from", "date_to"])
|
||||
|
||||
target_single_day = make_request(employee=regular_user, type_=RequestType.OTHER)
|
||||
target_single_day.date_from = date(2026, 4, 10)
|
||||
target_single_day.date_to = None
|
||||
target_single_day.save(update_fields=["date_from", "date_to"])
|
||||
|
||||
future_single_day = make_request(employee=regular_user, type_=RequestType.OTHER)
|
||||
future_single_day.date_from = date(2026, 4, 20)
|
||||
future_single_day.date_to = None
|
||||
future_single_day.save(update_fields=["date_from", "date_to"])
|
||||
|
||||
from_resp = auth_client(regular_user).get(f"{API_BASE}?date_from=2026-04-10")
|
||||
to_resp = auth_client(regular_user).get(f"{API_BASE}?date_to=2026-04-10")
|
||||
|
||||
assert from_resp.status_code == 200
|
||||
from_ids = _result_ids(from_resp.json())
|
||||
assert target_single_day.id in from_ids
|
||||
assert future_single_day.id in from_ids
|
||||
assert old_single_day.id not in from_ids
|
||||
assert no_period.id not in from_ids
|
||||
|
||||
assert to_resp.status_code == 200
|
||||
to_ids = _result_ids(to_resp.json())
|
||||
assert old_single_day.id in to_ids
|
||||
assert target_single_day.id in to_ids
|
||||
assert future_single_day.id not in to_ids
|
||||
assert no_period.id not in to_ids
|
||||
|
||||
|
||||
def test_list_created_date_filter_includes_same_day_boundaries(
|
||||
auth_client, regular_user: models.Model, make_request
|
||||
) -> None:
|
||||
"""Одинаковые created_from/created_to выбирают весь календарный день."""
|
||||
tz = timezone.get_current_timezone()
|
||||
previous = make_request(employee=regular_user, type_=RequestType.OTHER)
|
||||
previous.created_at = datetime(2026, 4, 9, 23, 59, tzinfo=tz)
|
||||
previous.save(update_fields=["created_at"])
|
||||
|
||||
morning = make_request(employee=regular_user, type_=RequestType.OTHER)
|
||||
morning.created_at = datetime(2026, 4, 10, 0, 1, tzinfo=tz)
|
||||
morning.save(update_fields=["created_at"])
|
||||
|
||||
evening = make_request(employee=regular_user, type_=RequestType.OTHER)
|
||||
evening.created_at = datetime(2026, 4, 10, 23, 59, tzinfo=tz)
|
||||
evening.save(update_fields=["created_at"])
|
||||
|
||||
next_day = make_request(employee=regular_user, type_=RequestType.OTHER)
|
||||
next_day.created_at = datetime(2026, 4, 11, 0, 1, tzinfo=tz)
|
||||
next_day.save(update_fields=["created_at"])
|
||||
|
||||
resp = auth_client(regular_user).get(
|
||||
f"{API_BASE}?created_from=2026-04-10&created_to=2026-04-10"
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
ids = _result_ids(resp.json())
|
||||
assert morning.id in ids
|
||||
assert evening.id in ids
|
||||
assert previous.id not in ids
|
||||
assert next_day.id not in ids
|
||||
|
||||
|
||||
def test_list_includes_accessible_linked_tasks(
|
||||
auth_client, regular_user: models.Model, make_user, make_request
|
||||
) -> None:
|
||||
|
||||
@ -8,6 +8,7 @@ import EmployeeAttendanceCard from "@/components/attendance/EmployeeAttendanceCa
|
||||
import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline";
|
||||
import {
|
||||
ProfileContactsPanel,
|
||||
ProfileAttendanceStatusPill,
|
||||
ProfileDepartmentBadge,
|
||||
ProfileDepartmentsCard,
|
||||
ProfileHeroCard,
|
||||
@ -116,12 +117,17 @@ export default function ProfilePage() {
|
||||
<ProfileHeroCard
|
||||
caption="Мой профиль"
|
||||
statusBadge={
|
||||
user.personnel_state ? (
|
||||
<span
|
||||
className={`app-status-pill ${getEmployeeActionTone(user.personnel_state.status).badgeClass}`}
|
||||
>
|
||||
{user.personnel_state.label || user.personnel_state.status}
|
||||
</span>
|
||||
user.personnel_state || user.attendance_status ? (
|
||||
<div className="flex max-w-full flex-wrap items-center justify-end gap-2">
|
||||
{user.personnel_state ? (
|
||||
<span
|
||||
className={`app-status-pill ${getEmployeeActionTone(user.personnel_state.status).badgeClass}`}
|
||||
>
|
||||
{user.personnel_state.label || user.personnel_state.status}
|
||||
</span>
|
||||
) : null}
|
||||
<ProfileAttendanceStatusPill status={user.attendance_status} />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
avatar={
|
||||
|
||||
@ -25,6 +25,7 @@ import EmployeeActionModal from "@/components/users/EmployeeActionModal";
|
||||
import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline";
|
||||
import {
|
||||
ProfileContactsPanel,
|
||||
ProfileAttendanceStatusPill,
|
||||
ProfileDepartmentBadge,
|
||||
ProfileDepartmentsCard,
|
||||
ProfileHeroCard,
|
||||
@ -367,10 +368,10 @@ export default function UserDetailPage() {
|
||||
<ProfileHeroCard
|
||||
caption="Профиль сотрудника"
|
||||
statusBadge={
|
||||
(person.personnel_state || latestAction || currentUser) ? (
|
||||
(person.personnel_state || person.attendance_status || latestAction || currentUser) ? (
|
||||
<div
|
||||
ref={profileMenuOpen ? profileMenuRef : null}
|
||||
className="relative flex items-center gap-2"
|
||||
className="relative flex max-w-full flex-wrap items-center justify-end gap-2"
|
||||
>
|
||||
{person.personnel_state ? (
|
||||
<span
|
||||
@ -379,6 +380,7 @@ export default function UserDetailPage() {
|
||||
{person.personnel_state.label || person.personnel_state.status}
|
||||
</span>
|
||||
) : null}
|
||||
<ProfileAttendanceStatusPill status={person.attendance_status} />
|
||||
{currentUser ? (
|
||||
<>
|
||||
<button
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { EmployeeWorkSchedule } from "@/lib/api/attendance";
|
||||
import type {
|
||||
EmployeeAttendanceStatus,
|
||||
EmployeeDepartment,
|
||||
EmployeePersonnelState,
|
||||
Skill,
|
||||
@ -188,6 +189,32 @@ export function ProfileHeroCard({
|
||||
);
|
||||
}
|
||||
|
||||
function getAttendanceStatusBadgeClass(status?: EmployeeAttendanceStatus | null) {
|
||||
if (status?.event === "arrival") return "app-feedback-success";
|
||||
if (status?.event === "departure") return "app-selected";
|
||||
return "app-badge";
|
||||
}
|
||||
|
||||
export function ProfileAttendanceStatusPill({
|
||||
status,
|
||||
}: {
|
||||
status?: EmployeeAttendanceStatus | null;
|
||||
}) {
|
||||
if (!status) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`app-status-pill inline-flex max-w-full items-center gap-1.5 ${getAttendanceStatusBadgeClass(status)}`}
|
||||
title="Последняя посещаемость"
|
||||
>
|
||||
<Clock3 size={13} className="shrink-0" />
|
||||
<span className="truncate">
|
||||
{status.display || status.label || "Последняя посещаемость"}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileContactsPanel({
|
||||
rows,
|
||||
emptyText = "Контакты не указаны",
|
||||
|
||||
@ -28,6 +28,15 @@ export interface EmployeePersonnelState {
|
||||
expects_attendance: boolean;
|
||||
}
|
||||
|
||||
export interface EmployeeAttendanceStatus {
|
||||
record_id: number;
|
||||
date: string;
|
||||
time: string | null;
|
||||
event: "arrival" | "departure" | "none" | string;
|
||||
label: string;
|
||||
display: string;
|
||||
}
|
||||
|
||||
export interface EmployeeDepartment {
|
||||
id: number;
|
||||
name: string;
|
||||
@ -58,6 +67,7 @@ export interface User {
|
||||
skills?: Skill[];
|
||||
actions?: EmployeeAction[];
|
||||
personnel_state?: EmployeePersonnelState;
|
||||
attendance_status?: EmployeeAttendanceStatus | null;
|
||||
email_verified?: boolean;
|
||||
is_ldap_managed?: boolean;
|
||||
created_at?: string;
|
||||
@ -333,6 +343,7 @@ export interface TaskLinkedEmployee {
|
||||
departments?: EmployeeDepartment[];
|
||||
is_active?: boolean;
|
||||
personnel_state?: EmployeePersonnelState | null;
|
||||
attendance_status?: EmployeeAttendanceStatus | null;
|
||||
} | null;
|
||||
can_open?: boolean;
|
||||
object_url?: string | null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user