Add attendance aliases and weekly summary
This commit is contained in:
parent
bd5c7b79b0
commit
f0024f7a09
@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from calendar import monthrange
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
from urllib.parse import quote
|
||||
|
||||
from attendance.models import (
|
||||
@ -92,6 +92,31 @@ def _check_staff_access(user) -> None:
|
||||
raise PermissionDenied("You don't have permission to manage attendance")
|
||||
|
||||
|
||||
def _has_attendance_pass(record: AttendanceRecord) -> bool:
|
||||
return bool(
|
||||
record.arrival_time
|
||||
or record.departure_time
|
||||
or (record.work_hours is not None and record.work_hours > 0)
|
||||
)
|
||||
|
||||
|
||||
def _has_absence_marker(record: AttendanceRecord) -> bool:
|
||||
values = [
|
||||
record.statuses,
|
||||
record.employee_issues,
|
||||
]
|
||||
return bool(
|
||||
record.is_absent
|
||||
or any(
|
||||
"absent" in normalized
|
||||
or "absence" in normalized
|
||||
or "отсутств" in normalized
|
||||
for items in values
|
||||
for normalized in [str(item).strip().lower() for item in (items or [])]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _comments_count_annotation():
|
||||
record_ct = ContentType.objects.get_for_model(AttendanceRecord)
|
||||
comments_subquery = (
|
||||
@ -174,7 +199,11 @@ class LogStormAttendanceAnalyzeAPIView(APIView):
|
||||
get_employee_work_schedule_payload(employee)
|
||||
or get_standard_work_schedule_payload()
|
||||
)
|
||||
aliases = data.get("aliases")
|
||||
aliases = (
|
||||
data["aliases"]
|
||||
if "aliases" in data
|
||||
else list(getattr(employee, "attendance_aliases", None) or [])
|
||||
)
|
||||
|
||||
try:
|
||||
result = analyze_employee_attendance(
|
||||
@ -345,6 +374,78 @@ class AttendanceRecordListAPIView(ListAPIView):
|
||||
return queryset
|
||||
|
||||
|
||||
class AttendanceWeeklySummaryAPIView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
_check_staff_access(request.user)
|
||||
week_start = self._week_start(request.query_params.get("week_start"))
|
||||
week_end = week_start + timedelta(days=6)
|
||||
records = (
|
||||
_records_visible_to(request.user)
|
||||
.filter(
|
||||
employee__is_active=True,
|
||||
date__gte=week_start,
|
||||
date__lte=week_end,
|
||||
)
|
||||
.only(
|
||||
"employee_id",
|
||||
"date",
|
||||
"arrival_time",
|
||||
"departure_time",
|
||||
"work_hours",
|
||||
"is_absent",
|
||||
"statuses",
|
||||
"employee_issues",
|
||||
)
|
||||
.order_by("date", "employee_id")
|
||||
)
|
||||
|
||||
by_date = {
|
||||
week_start + timedelta(days=day_offset): {
|
||||
"present": set(),
|
||||
"absent": set(),
|
||||
}
|
||||
for day_offset in range(7)
|
||||
}
|
||||
|
||||
for record in records:
|
||||
bucket = by_date.get(record.date)
|
||||
if bucket is None:
|
||||
continue
|
||||
if _has_attendance_pass(record):
|
||||
bucket["present"].add(record.employee_id)
|
||||
bucket["absent"].discard(record.employee_id)
|
||||
elif _has_absence_marker(record):
|
||||
bucket["absent"].add(record.employee_id)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"week_start": week_start,
|
||||
"week_end": week_end,
|
||||
"days": [
|
||||
{
|
||||
"date": day.isoformat(),
|
||||
"weekday": day.strftime("%A"),
|
||||
"present": len(counts["present"]),
|
||||
"absent": len(counts["absent"]),
|
||||
}
|
||||
for day, counts in by_date.items()
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
def _week_start(self, value: str | None) -> date:
|
||||
if value:
|
||||
try:
|
||||
parsed = date.fromisoformat(value)
|
||||
except ValueError:
|
||||
parsed = timezone.localdate()
|
||||
else:
|
||||
parsed = timezone.localdate()
|
||||
return parsed - timedelta(days=parsed.weekday())
|
||||
|
||||
|
||||
class AttendanceMonthlyMatrixAPIView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
|
||||
@ -52,6 +52,11 @@ class EmployeeSerializer(serializers.ModelSerializer):
|
||||
"""Полная версия сотрудника для /employees/."""
|
||||
|
||||
avatar = Base64ImageField(required=False, allow_null=True)
|
||||
attendance_aliases = serializers.ListField(
|
||||
child=serializers.CharField(allow_blank=True),
|
||||
required=False,
|
||||
allow_empty=True,
|
||||
)
|
||||
actions = EmployeeActionSerializer(many=True, read_only=True)
|
||||
username = serializers.CharField(read_only=True, allow_blank=True)
|
||||
last_activity_at = serializers.SerializerMethodField()
|
||||
@ -95,6 +100,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
|
||||
"telegram",
|
||||
"whatsapp",
|
||||
"wechat",
|
||||
"attendance_aliases",
|
||||
"skills",
|
||||
"skills_ids",
|
||||
"is_active",
|
||||
@ -132,6 +138,14 @@ class EmployeeSerializer(serializers.ModelSerializer):
|
||||
return None
|
||||
return value
|
||||
|
||||
def validate_attendance_aliases(self, value):
|
||||
result = []
|
||||
for alias in value or []:
|
||||
alias_value = str(alias).strip()
|
||||
if alias_value and alias_value not in result:
|
||||
result.append(alias_value)
|
||||
return result
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
skills = validated_data.pop("skills_ids", None)
|
||||
|
||||
|
||||
@ -40,6 +40,7 @@ from .attendance.views import (
|
||||
AttendanceRecordCommentsAPIView,
|
||||
AttendanceRecordDetailAPIView,
|
||||
AttendanceRecordListAPIView,
|
||||
AttendanceWeeklySummaryAPIView,
|
||||
EmployeeWorkScheduleAPIView,
|
||||
LogStormAttendanceAnalyzeAPIView,
|
||||
StandardWorkScheduleAPIView,
|
||||
@ -113,6 +114,11 @@ urlpatterns = [
|
||||
AttendanceRecordListAPIView.as_view(),
|
||||
name="attendance-records",
|
||||
),
|
||||
path(
|
||||
"attendance/weekly-summary/",
|
||||
AttendanceWeeklySummaryAPIView.as_view(),
|
||||
name="attendance-weekly-summary",
|
||||
),
|
||||
path(
|
||||
"attendance/monthly-matrix/",
|
||||
AttendanceMonthlyMatrixAPIView.as_view(),
|
||||
|
||||
@ -318,11 +318,13 @@ def run_attendance_auto_sync(*, force: bool = False) -> AttendanceAutoSyncSettin
|
||||
or get_standard_work_schedule_payload()
|
||||
)
|
||||
try:
|
||||
aliases = list(getattr(employee, "attendance_aliases", None) or [])
|
||||
result = analyze_employee_attendance(
|
||||
employee=employee,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
schedule=schedule_payload,
|
||||
aliases=aliases,
|
||||
client=None,
|
||||
)
|
||||
request_payload = build_logstorm_attendance_payload(
|
||||
@ -330,6 +332,7 @@ def run_attendance_auto_sync(*, force: bool = False) -> AttendanceAutoSyncSettin
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
schedule=schedule_payload,
|
||||
aliases=aliases,
|
||||
)
|
||||
save_logstorm_attendance_result(
|
||||
employee=employee,
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("employees", "0051_drop_legacy_state_tables"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="employee",
|
||||
name="attendance_aliases",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text="Дополнительные идентификаторы сотрудника в LogStorm",
|
||||
verbose_name="Алиасы посещаемости",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -231,6 +231,12 @@ class Employee(AbstractUser):
|
||||
)
|
||||
whatsapp = PhoneNumberField("WhatsApp", blank=True)
|
||||
wechat = models.CharField("WeChat", max_length=100, blank=True)
|
||||
attendance_aliases = models.JSONField(
|
||||
"Алиасы посещаемости",
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text="Дополнительные идентификаторы сотрудника в LogStorm",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
skills = models.ManyToManyField(
|
||||
|
||||
@ -38,6 +38,10 @@ def _monthly_matrix_url():
|
||||
return reverse("api:v1:attendance-monthly-matrix")
|
||||
|
||||
|
||||
def _weekly_summary_url():
|
||||
return reverse("api:v1:attendance-weekly-summary")
|
||||
|
||||
|
||||
def _monthly_matrix_export_url():
|
||||
return reverse("api:v1:attendance-monthly-matrix-export")
|
||||
|
||||
@ -181,6 +185,57 @@ def test_analyze_saves_run_and_records(auth_client_factory, user_factory):
|
||||
assert record.raw_data["employee_id"] == "42"
|
||||
|
||||
|
||||
def test_weekly_summary_counts_present_and_absent_people(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
):
|
||||
staff = user_factory(staff=True)
|
||||
present_employee = user_factory()
|
||||
absent_employee = user_factory()
|
||||
inactive_employee = user_factory(active=False)
|
||||
client = auth_client_factory(staff)
|
||||
run = AttendanceAnalysisRun.objects.create(
|
||||
employee=present_employee,
|
||||
period_start="2026-04-20",
|
||||
period_end="2026-04-26",
|
||||
triggered_by=staff,
|
||||
)
|
||||
|
||||
AttendanceRecord.objects.create(
|
||||
analysis_run=run,
|
||||
employee=present_employee,
|
||||
date="2026-04-20",
|
||||
arrival_time="09:00",
|
||||
departure_time="18:00",
|
||||
work_hours=9,
|
||||
is_absent=False,
|
||||
)
|
||||
AttendanceRecord.objects.create(
|
||||
analysis_run=run,
|
||||
employee=absent_employee,
|
||||
date="2026-04-20",
|
||||
work_hours=0,
|
||||
is_absent=True,
|
||||
statuses=["absence"],
|
||||
)
|
||||
AttendanceRecord.objects.create(
|
||||
analysis_run=run,
|
||||
employee=inactive_employee,
|
||||
date="2026-04-20",
|
||||
arrival_time="09:00",
|
||||
work_hours=9,
|
||||
)
|
||||
|
||||
response = client.get(_weekly_summary_url(), {"week_start": "2026-04-20"})
|
||||
|
||||
assert response.status_code == 200
|
||||
monday = response.data["days"][0]
|
||||
assert monday["date"] == "2026-04-20"
|
||||
assert monday["present"] == 1
|
||||
assert monday["absent"] == 1
|
||||
assert response.data["days"][6]["date"] == "2026-04-26"
|
||||
|
||||
|
||||
def test_work_schedule_endpoint_returns_default_for_self(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
|
||||
@ -102,6 +102,24 @@ def test_retrieve_ok(api_client: APIClient):
|
||||
assert data["id"] == e.id
|
||||
assert data["email"] == e.email
|
||||
|
||||
|
||||
def test_update_attendance_aliases_normalizes_values(api_client: APIClient):
|
||||
staff = make_user("staff@example.com", staff=True)
|
||||
employee = make_user("alias@example.com")
|
||||
api_client.force_authenticate(user=staff)
|
||||
|
||||
url = reverse("api:v1:employees-detail", args=[employee.pk])
|
||||
resp = api_client.patch(
|
||||
url,
|
||||
{"attendance_aliases": [" 200 ", "200", "", "300"]},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.json()["attendance_aliases"] == ["200", "300"]
|
||||
employee.refresh_from_db()
|
||||
assert employee.attendance_aliases == ["200", "300"]
|
||||
|
||||
# ---------- search & ordering ----------
|
||||
|
||||
def test_search_by_email_and_last_name(api_client: APIClient):
|
||||
|
||||
@ -134,6 +134,24 @@ def test_logstorm_attendance_api_passes_aliases(
|
||||
assert analyze.call_args.kwargs["aliases"] == ["200", "300"]
|
||||
|
||||
|
||||
def test_logstorm_attendance_api_uses_employee_aliases_when_omitted(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
):
|
||||
staff = user_factory(staff=True)
|
||||
employee = user_factory(attendance_aliases=["200", "300"])
|
||||
client = auth_client_factory(staff)
|
||||
|
||||
with patch(
|
||||
"api.v1.attendance.views.analyze_employee_attendance",
|
||||
return_value={"records": []},
|
||||
) as analyze:
|
||||
response = client.post(_url(), _payload(employee.id), format="json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert analyze.call_args.kwargs["aliases"] == ["200", "300"]
|
||||
|
||||
|
||||
def test_logstorm_attendance_api_rejects_invalid_period(
|
||||
auth_client_factory,
|
||||
user_factory,
|
||||
|
||||
@ -41,6 +41,7 @@ import type {
|
||||
AttendanceAutoSyncSettings,
|
||||
AttendanceMatrixExportFile,
|
||||
AttendanceSchedulePayload,
|
||||
AttendanceWeeklySummary,
|
||||
StandardWorkSchedule,
|
||||
} from "@/lib/api/attendance";
|
||||
import type { User } from "@/types/api";
|
||||
@ -87,6 +88,7 @@ type AttendanceAnalyzeRequest = {
|
||||
aliases?: string[];
|
||||
};
|
||||
type MonthlyMatrixRequest = { employee_ids: string; month: string };
|
||||
type AttendanceWeeklySummaryRequest = { week_start?: string };
|
||||
|
||||
const periodPresets: Array<{ value: AttendancePeriodPreset; label: string }> = [
|
||||
{ value: "month", label: "За месяц" },
|
||||
@ -375,6 +377,22 @@ function getAttendanceRecord(recordId: number) {
|
||||
return apiClient.request<AttendanceRecord>(`/api/v1/attendance/records/${recordId}/`);
|
||||
}
|
||||
|
||||
function getAttendanceWeeklySummary(params?: AttendanceWeeklySummaryRequest) {
|
||||
const client = apiClient as typeof apiClient & {
|
||||
getAttendanceWeeklySummary?: (
|
||||
params?: AttendanceWeeklySummaryRequest,
|
||||
) => Promise<AttendanceWeeklySummary>;
|
||||
};
|
||||
|
||||
if (typeof client.getAttendanceWeeklySummary === "function") {
|
||||
return client.getAttendanceWeeklySummary(params);
|
||||
}
|
||||
|
||||
return apiClient.request<AttendanceWeeklySummary>(
|
||||
`/api/v1/attendance/weekly-summary/${buildQuery(params)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function formatOptionalDateTime(value?: string | null) {
|
||||
return value ? formatDateTime(value) : "-";
|
||||
}
|
||||
@ -566,6 +584,15 @@ function weekdayLabelFromDate(value?: string) {
|
||||
return DAYS_RU_FROM_INDEX[date.getDay()] || "";
|
||||
}
|
||||
|
||||
function formatWeeklyDateLabel(value: string) {
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
});
|
||||
}
|
||||
|
||||
const DAYS_RU_FROM_INDEX: Record<number, string> = {
|
||||
0: "Вс",
|
||||
1: "Пн",
|
||||
@ -608,6 +635,10 @@ export default function AttendancePage() {
|
||||
const [loadingEmployees, setLoadingEmployees] = useState(true);
|
||||
const [loadingSchedule, setLoadingSchedule] = useState(true);
|
||||
const [savingSchedule, setSavingSchedule] = useState(false);
|
||||
const [loadingWeeklySummary, setLoadingWeeklySummary] = useState(true);
|
||||
const [weeklySummary, setWeeklySummary] =
|
||||
useState<AttendanceWeeklySummary | null>(null);
|
||||
const [weeklySummaryOpen, setWeeklySummaryOpen] = useState(false);
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
const [downloadingReport, setDownloadingReport] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -681,6 +712,35 @@ export default function AttendancePage() {
|
||||
);
|
||||
}, [employees]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadWeeklySummary() {
|
||||
if (!canManageAttendance) {
|
||||
setLoadingWeeklySummary(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingWeeklySummary(true);
|
||||
const summary = await getAttendanceWeeklySummary();
|
||||
if (!cancelled) setWeeklySummary(summary);
|
||||
} catch (summaryError) {
|
||||
if (!cancelled) {
|
||||
setError(getErrorMessage(summaryError, "Не удалось загрузить недельную статистику"));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoadingWeeklySummary(false);
|
||||
}
|
||||
}
|
||||
|
||||
void loadWeeklySummary();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canManageAttendance]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionsMenuOpen) {
|
||||
return;
|
||||
@ -1343,6 +1403,77 @@ export default function AttendancePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 app-surface-muted rounded-2xl p-3 sm:p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWeeklySummaryOpen((current) => !current)}
|
||||
className="flex w-full items-start gap-3 text-left"
|
||||
aria-expanded={weeklySummaryOpen}
|
||||
>
|
||||
<span className="app-action-secondary mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className={`transition ${weeklySummaryOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-semibold text-[var(--foreground)]">
|
||||
Неделя по посещаемости
|
||||
</span>
|
||||
<span className="app-text-muted mt-1 block text-xs">
|
||||
{weeklySummary
|
||||
? `${weeklySummary.week_start} — ${weeklySummary.week_end}`
|
||||
: "Текущая неделя"}
|
||||
</span>
|
||||
</span>
|
||||
{loadingWeeklySummary ? (
|
||||
<span className="app-text-muted inline-flex items-center gap-2 text-xs">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Загрузка
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{weeklySummaryOpen ? (
|
||||
<div className="mt-3 divide-y divide-[var(--border-subtle)]">
|
||||
{(weeklySummary?.days || []).map((day) => {
|
||||
const total = day.present + day.absent;
|
||||
return (
|
||||
<div
|
||||
key={day.date}
|
||||
className="grid grid-cols-[3.25rem_2.25rem_minmax(0,1fr)_2rem_minmax(0,1fr)_2rem] items-center gap-2 py-1.5 text-xs"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="font-semibold text-[var(--foreground)]">
|
||||
{weekdayLabelFromDate(day.date)}
|
||||
</span>
|
||||
<span className="app-text-muted ml-1">
|
||||
{formatWeeklyDateLabel(day.date).replace(".", "")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="app-status-pill app-badge justify-center">
|
||||
{total}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-emerald-300">Пришли</span>
|
||||
<span className="text-right font-semibold text-[var(--foreground)]">
|
||||
{day.present}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-amber-300">Отсутствовали</span>
|
||||
<span className="text-right font-semibold text-[var(--foreground)]">
|
||||
{day.absent}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!loadingWeeklySummary && !weeklySummary?.days.length ? (
|
||||
<div className="rounded-xl p-2 text-sm text-[var(--muted-foreground)]">
|
||||
За текущую неделю сохранённых записей пока нет.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="max-w-[34rem]">
|
||||
<div className="app-surface-muted rounded-2xl p-3 sm:p-4">
|
||||
<div className="relative">
|
||||
|
||||
@ -196,7 +196,11 @@ export default function ProfilePage() {
|
||||
personnelState={user.personnel_state}
|
||||
/>
|
||||
|
||||
<EmployeeAttendanceCard employeeActions={user.actions} employeeId={user.id} />
|
||||
<EmployeeAttendanceCard
|
||||
attendanceAliases={user.attendance_aliases}
|
||||
employeeActions={user.actions}
|
||||
employeeId={user.id}
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@ -533,7 +533,11 @@ export default function UserDetailPage() {
|
||||
personnelState={person.personnel_state}
|
||||
/>
|
||||
|
||||
<EmployeeAttendanceCard employeeActions={person.actions} employeeId={person.id} />
|
||||
<EmployeeAttendanceCard
|
||||
attendanceAliases={person.attendance_aliases}
|
||||
employeeActions={person.actions}
|
||||
employeeId={person.id}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Camera,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Edit3,
|
||||
Loader2,
|
||||
@ -12,6 +13,7 @@ import {
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
@ -33,6 +35,7 @@ import type {
|
||||
import type { EmployeeAction } from "@/types/api";
|
||||
|
||||
type EmployeeAttendanceCardProps = {
|
||||
attendanceAliases?: string[] | null;
|
||||
employeeId: number;
|
||||
employeeActions?: EmployeeAction[] | null;
|
||||
};
|
||||
@ -463,6 +466,17 @@ function nullableTime(value: string) {
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function normalizeAttendanceAliases(aliases: Array<string | null | undefined>) {
|
||||
const result: string[] = [];
|
||||
aliases.forEach((alias) => {
|
||||
const value = String(alias || "").trim();
|
||||
if (value && !result.includes(value)) {
|
||||
result.push(value);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
type AttendanceCardApiClient = typeof apiClient & {
|
||||
request: <T>(endpoint: string, options?: RequestInit) => Promise<T>;
|
||||
addAttendanceRecordComment?: unknown;
|
||||
@ -491,6 +505,7 @@ function analyzeEmployeeAttendance(data: {
|
||||
employee_id: number;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
aliases?: string[];
|
||||
}) {
|
||||
const client = attendanceApiClient();
|
||||
if (typeof client.analyzeAttendance === "function") {
|
||||
@ -569,10 +584,12 @@ function updateAttendanceRecord(recordId: number, data: AttendanceRecordUpdatePa
|
||||
}
|
||||
|
||||
export default function EmployeeAttendanceCard({
|
||||
attendanceAliases,
|
||||
employeeActions,
|
||||
employeeId,
|
||||
}: EmployeeAttendanceCardProps) {
|
||||
const { user: currentUser } = useUser();
|
||||
const actionsMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const defaultRange = useMemo(() => getPeriodRange("week"), []);
|
||||
const [period, setPeriod] = useState<AttendancePeriod>("week");
|
||||
const [periodStart, setPeriodStart] = useState(defaultRange.start);
|
||||
@ -589,6 +606,13 @@ export default function EmployeeAttendanceCard({
|
||||
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
|
||||
const [dayEventsRecordPreview, setDayEventsRecordPreview] =
|
||||
useState<AttendanceDayEventsPreview | null>(null);
|
||||
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
|
||||
const [aliasModalOpen, setAliasModalOpen] = useState(false);
|
||||
const [aliases, setAliases] = useState<string[]>(() =>
|
||||
normalizeAttendanceAliases(attendanceAliases || []),
|
||||
);
|
||||
const [aliasDraft, setAliasDraft] = useState("");
|
||||
const [aliasError, setAliasError] = useState<string | null>(null);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editForm, setEditForm] = useState<AttendanceRecordEditForm | null>(null);
|
||||
|
||||
@ -596,6 +620,33 @@ export default function EmployeeAttendanceCard({
|
||||
currentUser?.auth?.is_staff || currentUser?.auth?.is_superuser,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setAliases(normalizeAttendanceAliases(attendanceAliases || []));
|
||||
}, [attendanceAliases]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionsMenuOpen) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (actionsMenuRef.current && !actionsMenuRef.current.contains(event.target as Node)) {
|
||||
setActionsMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setActionsMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [actionsMenuOpen]);
|
||||
|
||||
const records = useMemo(() => {
|
||||
if (!result?.records || !Array.isArray(result.records)) return [];
|
||||
return result.records;
|
||||
@ -637,6 +688,7 @@ export default function EmployeeAttendanceCard({
|
||||
employee_id: employeeId,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
aliases: aliases.length ? aliases : undefined,
|
||||
});
|
||||
const savedRecords = await getAttendanceRecords({
|
||||
employee_id: employeeId,
|
||||
@ -835,6 +887,43 @@ export default function EmployeeAttendanceCard({
|
||||
setEditModalOpen(true);
|
||||
}
|
||||
|
||||
function openAliasModal() {
|
||||
setAliasDraft("");
|
||||
setAliasError(null);
|
||||
setAliasModalOpen(true);
|
||||
}
|
||||
|
||||
async function saveAliases(nextAliases: string[]) {
|
||||
const normalizedAliases = normalizeAttendanceAliases(nextAliases);
|
||||
try {
|
||||
setBusyKey("attendance-aliases");
|
||||
setAliasError(null);
|
||||
await apiClient.updateEmployee(employeeId, {
|
||||
attendance_aliases: normalizedAliases,
|
||||
});
|
||||
setAliases(normalizedAliases);
|
||||
setAliasDraft("");
|
||||
} catch (saveError) {
|
||||
setAliasError(getErrorMessage(saveError, "Не удалось сохранить алиасы"));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function addAlias() {
|
||||
const alias = aliasDraft.trim();
|
||||
if (!alias) return;
|
||||
if (aliases.includes(alias)) {
|
||||
setAliasError("Такой алиас уже добавлен");
|
||||
return;
|
||||
}
|
||||
await saveAliases([...aliases, alias]);
|
||||
}
|
||||
|
||||
async function removeAlias(alias: string) {
|
||||
await saveAliases(aliases.filter((item) => item !== alias));
|
||||
}
|
||||
|
||||
function handleEditRecordSelect(recordId: string) {
|
||||
const selectedRecord = records.find((record) => getRecordId(record) === Number(recordId));
|
||||
const form = selectedRecord ? recordToEditForm(selectedRecord) : null;
|
||||
@ -912,15 +1001,48 @@ export default function EmployeeAttendanceCard({
|
||||
</button>
|
||||
|
||||
{canManageAttendance ? (
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
@ -1250,6 +1372,94 @@ export default function EmployeeAttendanceCard({
|
||||
record={dayEventsRecordPreview}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={aliasModalOpen}
|
||||
onClose={() => setAliasModalOpen(false)}
|
||||
title="Алиасы посещаемости"
|
||||
size="sm"
|
||||
footer={(
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAliasModalOpen(false)}
|
||||
className="app-action-secondary rounded-lg px-4 py-2 text-sm font-semibold"
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void addAlias();
|
||||
}}
|
||||
className="flex flex-col gap-2 sm:flex-row"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={aliasDraft}
|
||||
onChange={(event) => {
|
||||
setAliasDraft(event.target.value);
|
||||
setAliasError(null);
|
||||
}}
|
||||
className="app-input min-w-0 flex-1 rounded-xl px-3 py-2.5 text-sm"
|
||||
placeholder="Например, 200"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!aliasDraft.trim() || busyKey === "attendance-aliases"}
|
||||
className="app-action-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busyKey === "attendance-aliases" ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Tag size={16} />
|
||||
)}
|
||||
Добавить
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{aliasError ? (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
|
||||
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
|
||||
<span>{aliasError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="app-card-caption">Текущие алиасы</p>
|
||||
{aliases.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{aliases.map((alias) => (
|
||||
<span
|
||||
key={alias}
|
||||
className="app-status-pill app-badge inline-flex items-center gap-2"
|
||||
>
|
||||
{alias}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void removeAlias(alias)}
|
||||
disabled={busyKey === "attendance-aliases"}
|
||||
className="text-[var(--muted-foreground)] transition hover:text-[var(--foreground)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
aria-label={`Удалить алиас ${alias}`}
|
||||
title="Удалить алиас"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl p-4 text-sm text-[var(--muted-foreground)]">
|
||||
Алиасы не добавлены.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={editModalOpen}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
|
||||
@ -58,6 +58,7 @@ export type AttendanceAnalyzePayload = {
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
schedule?: AttendanceSchedulePayload;
|
||||
aliases?: string[];
|
||||
};
|
||||
|
||||
export type AttendanceRecord = {
|
||||
@ -220,6 +221,23 @@ export type MonthlyAttendanceMatrixExportQuery = {
|
||||
period_end: string;
|
||||
};
|
||||
|
||||
export type AttendanceWeeklySummaryDay = {
|
||||
date: string;
|
||||
weekday: string;
|
||||
present: number;
|
||||
absent: number;
|
||||
};
|
||||
|
||||
export type AttendanceWeeklySummary = {
|
||||
week_start: string;
|
||||
week_end: string;
|
||||
days: AttendanceWeeklySummaryDay[];
|
||||
};
|
||||
|
||||
export type AttendanceWeeklySummaryQuery = {
|
||||
week_start?: string;
|
||||
};
|
||||
|
||||
export type AttendanceMatrixExportFile = {
|
||||
blob: Blob;
|
||||
filename: string;
|
||||
@ -242,6 +260,8 @@ export function createAttendanceApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
}) as Promise<AttendanceAnalysisResponse>,
|
||||
getAttendanceRecords: (params?: AttendanceRecordsQuery) =>
|
||||
request(`/api/v1/attendance/records/${buildQuery(params)}`) as Promise<PaginatedAttendanceRecords>,
|
||||
getAttendanceWeeklySummary: (params?: AttendanceWeeklySummaryQuery) =>
|
||||
request(`/api/v1/attendance/weekly-summary/${buildQuery(params)}`) as Promise<AttendanceWeeklySummary>,
|
||||
getAttendanceRecord: (recordId: number) =>
|
||||
request(`/api/v1/attendance/records/${recordId}/`) as Promise<AttendanceRecord>,
|
||||
getMonthlyAttendanceMatrix: (params: MonthlyAttendanceMatrixQuery) =>
|
||||
|
||||
@ -52,6 +52,7 @@ export interface User {
|
||||
telegram?: string;
|
||||
whatsapp?: string;
|
||||
wechat?: string;
|
||||
attendance_aliases?: string[];
|
||||
gender?: string | number; // 1 - мужской, 2 - женский, 0 - не указан
|
||||
birth_date?: string;
|
||||
skills?: Skill[];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user