Add attendance aliases and weekly summary

This commit is contained in:
lizerk 2026-05-03 23:02:33 +03:00
parent bd5c7b79b0
commit f0024f7a09
15 changed files with 625 additions and 14 deletions

View File

@ -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]

View File

@ -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)

View File

@ -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(),

View File

@ -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,

View File

@ -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="Алиасы посещаемости",
),
),
]

View File

@ -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(

View File

@ -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,

View File

@ -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):

View File

@ -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,

View File

@ -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">

View File

@ -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>
);

View File

@ -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>

View File

@ -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)}

View File

@ -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) =>

View File

@ -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[];