diff --git a/backend/attendance/services.py b/backend/attendance/services.py index 472e6f2f..d0ce38f9 100644 --- a/backend/attendance/services.py +++ b/backend/attendance/services.py @@ -1046,10 +1046,12 @@ def _matrix_display_text( return f"{short_label} + {_format_export_number(record.work_hours)}ч" return short_label + time_part = ( + f"{_format_matrix_time(record.arrival_time)}/" + f"{_format_matrix_time(record.departure_time)}" + ) if status == "normal": - time_part = f"{record.arrival_time or '-'}/{record.departure_time or '-'}" return f"{time_part} · {_format_export_number(record.work_hours)}ч" - time_part = f"{record.arrival_time or '-'}/{record.departure_time or '-'}" if status == "late": suffix = f" {record.late_minutes}м" if record.late_minutes else "" return f"{short_label}{suffix} · {time_part}" @@ -1074,6 +1076,17 @@ def _matrix_display_text( return short_label +def _format_matrix_time(value: Any) -> str: + if not value: + return "-" + text = str(value) + if "T" in text: + text = text.split("T", 1)[1] + if len(text) >= 5 and text[2] == ":": + return text[:5] + return text + + def _matrix_detail_lines( *, record: AttendanceRecord, diff --git a/frontend/src/app/attendance/page.tsx b/frontend/src/app/attendance/page.tsx index 9cda8027..9a472e62 100644 --- a/frontend/src/app/attendance/page.tsx +++ b/frontend/src/app/attendance/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, CalendarCheck, @@ -55,12 +55,10 @@ const matrixDateColumnWidth = 140; const matrixEmployeeColumnWidth = 160; const matrixModalHorizontalPadding = 96; -type AttendancePeriodPreset = "week" | "month" | "year" | "custom"; +type AttendancePeriodPreset = "month" | "custom"; const periodPresets: Array<{ value: AttendancePeriodPreset; label: string }> = [ - { value: "week", label: "За неделю" }, { value: "month", label: "За месяц" }, - { value: "year", label: "За год" }, { value: "custom", label: "Свой период" }, ]; @@ -89,12 +87,34 @@ function toTimeInputValue(value: string | undefined) { return (value || "").slice(0, 5); } -function getPeriodStart(preset: Exclude) { +function getCurrentMonthKey() { const date = new Date(); - if (preset === "week") date.setDate(date.getDate() - 6); - if (preset === "month") date.setMonth(date.getMonth() - 1); - if (preset === "year") date.setFullYear(date.getFullYear() - 1); - return toDateInputValue(date); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; +} + +function getMonthRange(monthKey: string) { + const [year, month] = monthKey.split("-").map(Number); + const start = new Date(year, month - 1, 1); + const end = new Date(year, month, 0); + return { + start: toDateInputValue(start), + end: toDateInputValue(end), + }; +} + +function shiftMonthKey(monthKey: string, delta: number) { + const [year, month] = monthKey.split("-").map(Number); + const date = new Date(year, month - 1 + delta, 1); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; +} + +function formatMonthKeyLabel(monthKey: string) { + const [year, month] = monthKey.split("-").map(Number); + const date = new Date(year, month - 1, 1); + return new Intl.DateTimeFormat("ru-RU", { + month: "long", + year: "numeric", + }).format(date); } function getInitials(employee: User) { @@ -109,6 +129,13 @@ function formatTime(value: unknown) { return String(value); } +function formatMatrixTime(value: unknown) { + const text = formatTime(value); + if (text === "-") return text; + const timeText = text.includes("T") ? text.split("T", 2)[1] : text; + return timeText.length >= 5 && timeText[2] === ":" ? timeText.slice(0, 5) : timeText; +} + function formatHours(value: unknown) { if (value === null || value === undefined || value === "") return "0"; const numeric = Number(value); @@ -231,7 +258,7 @@ function matrixCellDisplay(cell: MonthlyAttendanceMatrixCell) { if (cell.display_text) return cell.display_text; if (cell.short_label) return cell.short_label; if (cell.status === "empty") return ""; - return `${formatTime(cell.arrival_time)}/${formatTime(cell.departure_time)}`; + return `${formatMatrixTime(cell.arrival_time)}/${formatMatrixTime(cell.departure_time)}`; } function attendanceRecordStatusLabel(record: AttendanceRecord) { @@ -313,10 +340,16 @@ export default function AttendancePage() { const { user } = useUser(); const [employees, setEmployees] = useState([]); const [employeeSearch, setEmployeeSearch] = useState(""); + const [showInactiveEmployees, setShowInactiveEmployees] = useState(false); const [selectedEmployeeIds, setSelectedEmployeeIds] = useState([]); - const [periodPreset, setPeriodPreset] = useState("week"); - const [periodStart, setPeriodStart] = useState(() => getPeriodStart("week")); - const [periodEnd, setPeriodEnd] = useState(() => toDateInputValue(new Date())); + const [periodPreset, setPeriodPreset] = useState("month"); + const [selectedMonth, setSelectedMonth] = useState(() => getCurrentMonthKey()); + const [periodStart, setPeriodStart] = useState( + () => getMonthRange(getCurrentMonthKey()).start, + ); + const [periodEnd, setPeriodEnd] = useState( + () => getMonthRange(getCurrentMonthKey()).end, + ); const [scheduleStart, setScheduleStart] = useState("08:00"); const [scheduleEnd, setScheduleEnd] = useState("17:00"); const [expectedHours, setExpectedHours] = useState("9"); @@ -350,6 +383,8 @@ export default function AttendancePage() { useState(null); const [openedRecordFromUrl, setOpenedRecordFromUrl] = useState(false); const [statsModalOpen, setStatsModalOpen] = useState(false); + const [actionsMenuOpen, setActionsMenuOpen] = useState(false); + const actionsMenuRef = useRef(null); const canManageAttendance = Boolean(user?.auth?.is_staff || user?.auth?.is_superuser); useEffect(() => { @@ -367,7 +402,7 @@ export default function AttendancePage() { const nextEmployees = await loadAllPages((params) => apiClient.getEmployees({ ...params, - is_active: true, + is_active: showInactiveEmployees ? undefined : true, ordering: "last_name", }), ); @@ -386,7 +421,40 @@ export default function AttendancePage() { return () => { cancelled = true; }; - }, [canManageAttendance]); + }, [canManageAttendance, showInactiveEmployees]); + + useEffect(() => { + const availableIds = new Set(employees.map((employee) => employee.id)); + setSelectedEmployeeIds((current) => + current.filter((employeeId) => availableIds.has(employeeId)), + ); + }, [employees]); + + useEffect(() => { + if (!actionsMenuOpen) { + return; + } + + const handlePointerDown = (event: MouseEvent) => { + if (actionsMenuRef.current && !actionsMenuRef.current.contains(event.target as Node)) { + setActionsMenuOpen(false); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setActionsMenuOpen(false); + } + }; + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [actionsMenuOpen]); useEffect(() => { let cancelled = false; @@ -537,6 +605,8 @@ export default function AttendancePage() { visibleEmployeeStart + visibleEmployees.length, activeMatrix?.employees.length || 0, ); + const matrixTableWidth = + matrixDateColumnWidth + visibleEmployees.length * matrixEmployeeColumnWidth; const hasPreviousEmployeePage = visibleEmployeePage > 0; const hasNextEmployeePage = activeMatrix ? visibleEmployeeEnd < activeMatrix.employees.length @@ -590,12 +660,24 @@ export default function AttendancePage() { setPeriodPreset(nextPreset); setMatrices([]); setSelectedCell(null); - if (nextPreset !== "custom") { - setPeriodStart(getPeriodStart(nextPreset)); - setPeriodEnd(toDateInputValue(new Date())); + if (nextPreset === "month") { + const range = getMonthRange(selectedMonth); + setPeriodStart(range.start); + setPeriodEnd(range.end); } } + function shiftSelectedMonth(delta: number) { + const nextMonth = shiftMonthKey(selectedMonth, delta); + const range = getMonthRange(nextMonth); + setPeriodPreset("month"); + setSelectedMonth(nextMonth); + setPeriodStart(range.start); + setPeriodEnd(range.end); + setMatrices([]); + setSelectedCell(null); + } + async function saveStandardSchedule() { const hours = Number(expectedHours); if (!scheduleStart || !scheduleEnd || !Number.isFinite(hours) || hours <= 0 || hours > 24) { @@ -879,38 +961,71 @@ export default function AttendancePage() {

Посещаемость

-

- Выбор сотрудников и периода для статистики посещаемости -

+
- - +
+ + + {actionsMenuOpen ? ( +
+ + +
+ ) : null} +
@@ -957,6 +1072,16 @@ export default function AttendancePage() { + +
{loadingEmployees ? (
@@ -1002,8 +1127,11 @@ export default function AttendancePage() { )} - - {fullName} + + + {fullName} + + {subtitle} @@ -1040,6 +1168,30 @@ export default function AttendancePage() { })}
+ {periodPreset === "month" ? ( +
+ + + {formatMonthKeyLabel(selectedMonth)} + + +
+ ) : null} +

{periodStart} — {periodEnd}

@@ -1418,21 +1570,30 @@ export default function AttendancePage() {
-
- +
+
+ + + {visibleEmployees.map((employee) => ( + + ))} + {visibleEmployees.map((employee) => ( ))} @@ -1463,7 +1624,7 @@ export default function AttendancePage() { setSelectedCell({ row, employee, cell }); openCellDayEvents(row, employee, cell); }} - className={`h-10 w-full px-2 text-center transition ${matrixCellTone(cell.status)} ${matrixCellSourceTone(cell)} ${ + className={`h-10 w-full overflow-hidden px-2 text-center transition ${matrixCellTone(cell.status)} ${matrixCellSourceTone(cell)} ${ active ? "ring-2 ring-inset ring-[var(--accent-primary)]" : "" }`} title={cell.primary_label || matrixStatusLabel(cell.status)}
Дата - {employee.name} + {employee.name}