fix(profiles): align personnel and attendance controls

This commit is contained in:
lizerk 2026-08-10 13:04:03 +03:00
parent f5da80ce3f
commit 05d823007d
6 changed files with 113 additions and 67 deletions

View File

@ -7,6 +7,7 @@ from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils import timezone
from employees.models import EmployeeDepartment, Position, Skill
from employees.services.personnel_state import resolve_employee_personnel_state
@ -114,7 +115,14 @@ def _employee_attendance_status_payload(obj):
and not has_attendance_annotation
and hasattr(obj, "attendance_records")
):
latest_record = obj.attendance_records.order_by("-date", "-id").first()
latest_record = (
obj.attendance_records.filter(
(Q(arrival_time__isnull=False) & ~Q(arrival_time=""))
| (Q(departure_time__isnull=False) & ~Q(departure_time=""))
)
.order_by("-date", "-id")
.first()
)
if latest_record is not None:
record_id = latest_record.id
record_date = latest_record.date
@ -144,9 +152,7 @@ def _employee_attendance_status_payload(obj):
else:
display_date = str(record_date)
display = f"{label} · {display_date}"
if time_value:
display = f"{display}, {time_value}"
display = f"{display_date}, {time_value}" if time_value else display_date
return {
"record_id": record_id,

View File

@ -83,6 +83,9 @@ class EmployeeViewSet(viewsets.ModelViewSet):
def _with_last_attendance_status(queryset):
latest_attendance = AttendanceRecord.objects.filter(
employee_id=OuterRef("pk")
).filter(
(Q(arrival_time__isnull=False) & ~Q(arrival_time=""))
| (Q(departure_time__isnull=False) & ~Q(departure_time=""))
).order_by("-date", "-id")
return queryset.annotate(
latest_attendance_record_id=Subquery(

View File

@ -355,7 +355,7 @@ def test_retrieve_exposes_last_attendance_status_to_authenticated_users(
"time": "17:45",
"event": "departure",
"label": "Уход",
"display": "Уход · 11.04.2026, 17:45",
"display": "11.04.2026, 17:45",
}
@ -387,6 +387,40 @@ def test_retrieve_last_attendance_status_uses_arrival_when_no_departure(
assert payload["time"] == "09:05"
assert payload["event"] == "arrival"
assert payload["label"] == "Приход"
assert payload["display"] == "12.04.2026, 09:05"
def test_retrieve_last_attendance_status_skips_records_without_events(
api_client: APIClient,
):
viewer = make_user("attendance-event-viewer@example.com")
target = make_user("attendance-event-target@example.com")
run = AttendanceAnalysisRun.objects.create(
employee=target,
period_start=dt.date(2026, 4, 1),
period_end=dt.date(2026, 4, 30),
)
event_record = AttendanceRecord.objects.create(
analysis_run=run,
employee=target,
date=dt.date(2026, 4, 29),
arrival_time="08:47",
)
AttendanceRecord.objects.create(
analysis_run=run,
employee=target,
date=dt.date(2026, 4, 30),
arrival_time=None,
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"] == event_record.id
assert payload["display"] == "29.04.2026, 08:47"
def test_update_attendance_aliases_normalizes_values(api_client: APIClient):

View File

@ -580,6 +580,8 @@ export default function UserDetailPage() {
onEditAction={handleEditAction}
personnelState={person.personnel_state}
sortedActions={sortedActions}
initialVisibleCount={3}
showCountLabel={false}
truncateCommentLength={120}
expandedCommentLength={240}
/>

View File

@ -1001,59 +1001,66 @@ export default function EmployeeAttendanceCard({
</div>
<div className="flex items-center gap-2 sm:shrink-0">
<button
type="submit"
disabled={loading}
className="app-action-primary inline-flex h-10 items-center justify-center gap-2 rounded-lg px-4 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? <Loader2 size={16} className="animate-spin" /> : <RefreshCw size={16} />}
Обновить
</button>
{canManageAttendance ? (
<div ref={actionsMenuRef} className="relative">
<button
type="button"
onClick={() => setActionsMenuOpen((current) => !current)}
className="app-action-ghost flex h-9 w-9 items-center justify-center rounded-md"
aria-label="Действия посещаемости"
aria-expanded={actionsMenuOpen}
title="Действия посещаемости"
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${actionsMenuOpen ? "rotate-90" : ""}`}
/>
</button>
{actionsMenuOpen ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-64 rounded-xl py-1.5">
<button
type="button"
onClick={() => {
setActionsMenuOpen(false);
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={() => {
setActionsMenuOpen(false);
openAliasModal();
}}
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)]"
>
<Tag size={14} className="app-text-muted" />
Добавить алиас
</button>
</div>
) : null}
</div>
) : null}
<div ref={actionsMenuRef} className="relative">
<button
type="button"
onClick={() => setActionsMenuOpen((current) => !current)}
className="app-action-ghost flex h-9 w-9 items-center justify-center rounded-md"
aria-label="Действия посещаемости"
aria-expanded={actionsMenuOpen}
title="Действия посещаемости"
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${actionsMenuOpen ? "rotate-90" : ""}`}
/>
</button>
{actionsMenuOpen ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-64 rounded-xl py-1.5">
<button
type="submit"
disabled={loading}
onClick={() => setActionsMenuOpen(false)}
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)] disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? (
<Loader2 size={14} className="app-text-muted animate-spin" />
) : (
<RefreshCw size={14} className="app-text-muted" />
)}
Обновить
</button>
{canManageAttendance ? (
<>
<div className="my-1 border-t border-[var(--border-subtle)]" />
<button
type="button"
onClick={() => {
setActionsMenuOpen(false);
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={() => {
setActionsMenuOpen(false);
openAliasModal();
}}
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)]"
>
<Tag size={14} className="app-text-muted" />
Добавить алиас
</button>
</>
) : null}
</div>
) : null}
</div>
</div>
</div>

View File

@ -189,12 +189,6 @@ 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,
}: {
@ -204,12 +198,12 @@ export function ProfileAttendanceStatusPill({
return (
<span
className={`app-status-pill inline-flex max-w-full items-center gap-1.5 ${getAttendanceStatusBadgeClass(status)}`}
title="Последняя посещаемость"
className="app-status-pill app-badge inline-flex max-w-full items-center gap-1.5"
title="Последняя запись визита"
>
<Clock3 size={13} className="shrink-0" />
<span className="truncate">
{status.display || status.label || "Последняя посещаемость"}
{status.display}
</span>
</span>
);