fix(profiles): align personnel and attendance controls
This commit is contained in:
parent
f5da80ce3f
commit
05d823007d
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -580,6 +580,8 @@ export default function UserDetailPage() {
|
||||
onEditAction={handleEditAction}
|
||||
personnelState={person.personnel_state}
|
||||
sortedActions={sortedActions}
|
||||
initialVisibleCount={3}
|
||||
showCountLabel={false}
|
||||
truncateCommentLength={120}
|
||||
expandedCommentLength={240}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user