From 0ec0e1c75bf5a102a5a52949de5f03c4b9555cba Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 22 Apr 2026 14:43:57 +0300 Subject: [PATCH 1/3] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D0=BB=20=D1=83?= =?UTF-8?q?=D0=BF=D0=BE=D0=BC=D0=B8=D0=BD=D0=B0=D0=BD=D0=B8=D1=8F=20LogSto?= =?UTF-8?q?rm/LogShtorm=20=D0=B8=D0=B7=20frontend-=D1=81=D0=BB=D0=BE=D1=8F?= =?UTF-8?q?=20EUSRR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/attendance/page.tsx | 14 +++---- frontend/src/app/profile/page.tsx | 12 +++--- frontend/src/app/users/[id]/page.tsx | 12 +++--- .../attendance/EmployeeAttendanceCard.tsx | 38 ++++++++--------- frontend/src/lib/api/attendance.ts | 42 +++++++++---------- 5 files changed, 57 insertions(+), 61 deletions(-) diff --git a/frontend/src/app/attendance/page.tsx b/frontend/src/app/attendance/page.tsx index f59e14a5..344252ac 100644 --- a/frontend/src/app/attendance/page.tsx +++ b/frontend/src/app/attendance/page.tsx @@ -23,7 +23,7 @@ import type { MonthlyAttendanceMatrixCell, MonthlyAttendanceMatrixEmployee, MonthlyAttendanceMatrixRow, - LogStormSchedulePayload, + AttendanceSchedulePayload, } from "@/lib/api/attendance"; import type { User } from "@/types/api"; @@ -339,7 +339,7 @@ export default function AttendancePage() { } } - function getSchedulePayload(): LogStormSchedulePayload | undefined { + function getSchedulePayload(): AttendanceSchedulePayload | undefined { if (!useManualSchedule) return undefined; return { start_time: scheduleStart, @@ -365,7 +365,7 @@ export default function AttendancePage() { return true; } - async function loadStatistics(options?: { refreshFromLogStorm?: boolean }) { + async function loadStatistics(options?: { refreshFromAnalyzer?: boolean }) { setError(null); setMatrices([]); setSelectedCell(null); @@ -378,8 +378,8 @@ export default function AttendancePage() { const employeeIds = selectedEmployees.map((employee) => employee.id).join(","); for (const employee of selectedEmployees) { - if (options?.refreshFromLogStorm) { - await apiClient.analyzeLogStormAttendance({ + if (options?.refreshFromAnalyzer) { + await apiClient.analyzeAttendance({ employee_id: employee.id, period_start: periodStart, period_end: periodEnd, @@ -424,7 +424,7 @@ export default function AttendancePage() { Посещаемость

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

+
+ + + +
+ - {selectorOpen ? ( -
-
+
- - - {useManualSchedule ? ( -
-
- - - -
- -
- {weekdays.map((day) => { - const active = workdays.includes(day.value); - return ( - - ); - })} -
+
+
+

+ Стандартный график анализа +

+

+ Сохраняется на сервере и применяется, если у сотрудника нет индивидуального графика. +

- ) : null} -
+ +
-
- - + {loadingSchedule ? ( +

+ + Загрузка стандартного графика +

+ ) : null} + +
+
+ + + +
+ +
+ {weekdays.map((day) => { + const active = workdays.includes(day.value); + return ( + + ); + })} +
+ + {scheduleFeedback ? ( +

+ {scheduleFeedback} +

+ ) : null} +
-
{error ? ( -
+
{error}
) : null} -
- ) : null} +
@@ -791,7 +1195,6 @@ export default function AttendancePage() { Дата @@ -799,29 +1202,12 @@ export default function AttendancePage() { {visibleEmployees.map((employee) => ( {employee.name} ))} - - {visibleEmployees.map((employee) => ( - - - Приход - - - Уход - - - ))} - {activeMatrix.rows.map((row) => ( @@ -838,36 +1224,60 @@ export default function AttendancePage() { const active = selectedCell?.row.date === row.date && selectedCell.employee.id === employee.id; return ( - - + +
- - - - - + {cell.comments_count ? ( + + ) : cell.record_id ? ( + + ) : null} + {cell.is_manually_edited ? ( + + ) : null} +
+ ); })} @@ -880,7 +1290,6 @@ export default function AttendancePage() { {visibleEmployees.map((employee) => ( {summaryRow.values[String(employee.id)] ?? 0} @@ -904,9 +1313,21 @@ export default function AttendancePage() {

- {matrixStatusLabel(selectedCell.cell.status)} + {selectedCell.cell.primary_label || matrixStatusLabel(selectedCell.cell.status)}
+
+

+ {matrixCellDisplay(selectedCell.cell) || "Нет записи"} +

+ {selectedCell.cell.detail_lines?.length ? ( +
+ {selectedCell.cell.detail_lines.map((line) => ( +

{line}

+ ))} +
+ ) : null} +

Приход

@@ -945,6 +1366,28 @@ export default function AttendancePage() { ))}
) : null} + {selectedCell.cell.is_manually_edited ? ( +
+ Ручная корректировка EUSRR +
+ ) : null} + {selectedCell.cell.record_id ? ( +
+ +
+ ) : null}
) : (

@@ -955,6 +1398,18 @@ export default function AttendancePage() { )}

+ setCommentsRecordPreview(null)} + onCommentCountChange={updateSelectedCellCommentCount} + record={commentsRecordPreview} + /> + setDayEventsRecordPreview(null)} + record={dayEventsRecordPreview} + /> ); } diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 522473e2..fe408bb5 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -317,8 +317,14 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa function LeftNavContent({ onNavigate }: LeftNavContentProps) { const pathname = usePathname(); + const { user } = useUser(); const { notifications: notificationsData, markCategoryAsRead } = useNotifications(); const notifications = useMemo(() => Array.isArray(notificationsData) ? notificationsData : [], [notificationsData]); + const canManageAttendance = Boolean(user?.auth?.is_staff || user?.auth?.is_superuser); + const visibleNavItems = useMemo( + () => navItems.filter((item) => item.href !== "/attendance" || canManageAttendance), + [canManageAttendance], + ); // Подсчет уведомлений по категориям const categoryCounts = useMemo(() => { @@ -355,7 +361,7 @@ function LeftNavContent({ onNavigate }: LeftNavContentProps) { return (
- {navItems.map(({ href, label, icon: Icon, category }) => { + {visibleNavItems.map(({ href, label, icon: Icon, category }) => { const count = category ? categoryCounts[category] || 0 : 0; return ( diff --git a/frontend/src/components/attendance/AttendanceDayEventsModal.tsx b/frontend/src/components/attendance/AttendanceDayEventsModal.tsx new file mode 100644 index 00000000..f8bad446 --- /dev/null +++ b/frontend/src/components/attendance/AttendanceDayEventsModal.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Camera, Clock3, ImageOff, Loader2 } from "lucide-react"; + +import { Modal } from "@/components/ui/Modal"; +import { apiClient } from "@/lib/api"; +import type { AttendanceDayEvent } from "@/lib/api/attendance"; + +export type AttendanceDayEventsPreview = { + recordId: number; + employeeName: string; + date: string; + statusLabel: string; + displayText: string; + detailLines?: string[]; + issues?: string[]; + isManuallyEdited?: boolean; +}; + +type AttendanceDayEventsModalProps = { + isOpen: boolean; + onClose: () => void; + record: AttendanceDayEventsPreview | null; +}; + +function getErrorMessage(error: unknown, fallback: string) { + return String((error as Error)?.message || fallback); +} + +function revokePhotoUrls(urls: Record) { + Object.values(urls).forEach((url) => URL.revokeObjectURL(url)); +} + +export function AttendanceDayEventsModal({ + isOpen, + onClose, + record, +}: AttendanceDayEventsModalProps) { + const [events, setEvents] = useState([]); + const [photoUrls, setPhotoUrls] = useState>({}); + const [loadingEvents, setLoadingEvents] = useState(false); + const [loadingPhotos, setLoadingPhotos] = useState(false); + const [error, setError] = useState(null); + const photoUrlsRef = useRef>({}); + + useEffect(() => { + photoUrlsRef.current = photoUrls; + }, [photoUrls]); + + useEffect(() => { + if (!isOpen || !record?.recordId) return; + let cancelled = false; + const recordId = record.recordId; + + async function loadEvents() { + revokePhotoUrls(photoUrlsRef.current); + setEvents([]); + setPhotoUrls({}); + setError(null); + setLoadingEvents(true); + setLoadingPhotos(false); + + try { + const nextEvents = await apiClient.getAttendanceRecordDayEvents(recordId); + if (cancelled) return; + setEvents(nextEvents); + + const eventsWithPhotos = nextEvents.filter( + (event) => event.has_photo && event.photo_url, + ); + if (eventsWithPhotos.length === 0) return; + + setLoadingPhotos(true); + const loadedEntries = await Promise.all( + eventsWithPhotos.map(async (event) => { + const blob = await apiClient.getAttendanceDayEventPhoto(event.photo_url as string); + return [event.event_key, URL.createObjectURL(blob)] as const; + }), + ); + if (cancelled) { + loadedEntries.forEach(([, url]) => URL.revokeObjectURL(url)); + return; + } + setPhotoUrls(Object.fromEntries(loadedEntries)); + } catch (loadError) { + if (!cancelled) { + setError(getErrorMessage(loadError, "Не удалось загрузить события дня")); + } + } finally { + if (!cancelled) { + setLoadingEvents(false); + setLoadingPhotos(false); + } + } + } + + void loadEvents(); + + return () => { + cancelled = true; + }; + }, [isOpen, record?.recordId]); + + useEffect(() => ( + () => { + revokePhotoUrls(photoUrlsRef.current); + } + ), []); + + const close = () => { + revokePhotoUrls(photoUrlsRef.current); + setPhotoUrls({}); + onClose(); + }; + + return ( + + {record ? ( +
+
+

Запись посещения

+
+
+

+ {record.employeeName} +

+

+ {record.date} · {record.displayText || "Нет записи"} +

+
+ + {record.statusLabel} + +
+ {record.detailLines?.length ? ( +
+ {record.detailLines.slice(0, 8).map((line) => ( +

{line}

+ ))} +
+ ) : null} + {record.issues?.length ? ( +
+ {record.issues.map((issue) => ( + + {issue} + + ))} +
+ ) : null} + {record.isManuallyEdited ? ( +
+ Ручная корректировка EUSRR +
+ ) : null} +
+ +
+
+
+ +

События LogStorm за день

+
+ {loadingPhotos ? ( + + + Загрузка фото + + ) : null} +
+ + {error ? ( +
+ {error} +
+ ) : loadingEvents ? ( +
+ +

Загрузка событий...

+
+ ) : events.length === 0 ? ( +
+

+ Событий за день нет +

+

+ LogStorm не вернул проходы для этой даты. +

+
+ ) : ( +
+ {events.map((event) => { + const photoUrl = photoUrls[event.event_key]; + return ( +
+
+ {photoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {`${event.caption}, + ) : ( +
+ + Фото не сохранено +
+ )} +
+
+
+

+ {event.caption} +

+ + + {event.time_label} + +
+

+ {event.device_name || event.device || "Устройство"} · #{event.serial_no} +

+
+
+ ); + })} +
+ )} +
+
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/attendance/AttendanceRecordCommentsModal.tsx b/frontend/src/components/attendance/AttendanceRecordCommentsModal.tsx new file mode 100644 index 00000000..b7ebe1df --- /dev/null +++ b/frontend/src/components/attendance/AttendanceRecordCommentsModal.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { Loader2, MessageSquare, Send } from "lucide-react"; + +import { CommentDeleteButton } from "@/components/shared/CommentControls"; +import { RequestAvatar } from "@/components/requests/RequestAvatar"; +import { Modal } from "@/components/ui/Modal"; +import { apiClient } from "@/lib/api"; +import { displayUserName, formatDateTime } from "@/lib/shared"; +import type { AttendanceRecordComment } from "@/lib/api/attendance"; + +export type AttendanceRecordCommentsPreview = { + recordId: number; + employeeName: string; + date: string; + statusLabel: string; + displayText: string; + detailLines?: string[]; + commentsCount?: number; +}; + +type AttendanceRecordCommentsModalProps = { + currentUserId?: number | null; + isOpen: boolean; + onClose: () => void; + onCommentCountChange?: (recordId: number, count: number) => void; + record: AttendanceRecordCommentsPreview | null; +}; + +function getErrorMessage(error: unknown, fallback: string) { + return String((error as Error)?.message || fallback); +} + +function commentAuthorName(comment: AttendanceRecordComment) { + return displayUserName(comment.author) || comment.author?.email || "Сотрудник"; +} + +function commentFallback(comment: AttendanceRecordComment) { + return ( + comment.author?.last_name?.[0] || + comment.author?.first_name?.[0] || + comment.author?.email?.[0] || + "К" + ).toUpperCase(); +} + +export function AttendanceRecordCommentsModal({ + currentUserId, + isOpen, + onClose, + onCommentCountChange, + record, +}: AttendanceRecordCommentsModalProps) { + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + const [actionId, setActionId] = useState(null); + const [composerExpanded, setComposerExpanded] = useState(false); + + const inputRef = useRef(null); + const commentsBottomRef = useRef(null); + const onCommentCountChangeRef = useRef(onCommentCountChange); + const activeRecordId = record?.recordId; + + useEffect(() => { + onCommentCountChangeRef.current = onCommentCountChange; + }, [onCommentCountChange]); + + const reset = useCallback(() => { + setDraft(""); + setError(null); + setActionId(null); + }, []); + + const scrollToBottom = useCallback((smooth = true) => { + requestAnimationFrame(() => { + commentsBottomRef.current?.scrollIntoView({ + behavior: smooth ? "smooth" : "auto", + block: "end", + }); + }); + }, []); + + const loadComments = useCallback(async (recordId: number) => { + try { + setLoading(true); + setError(null); + const nextComments = await apiClient.getAttendanceRecordComments(recordId); + setComments(nextComments); + onCommentCountChangeRef.current?.(recordId, nextComments.length); + } catch (loadError) { + setComments([]); + setError(getErrorMessage(loadError, "Не удалось загрузить комментарии")); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!isOpen || !activeRecordId) return; + reset(); + void loadComments(activeRecordId); + }, [activeRecordId, isOpen, loadComments, reset]); + + useEffect(() => { + if (!isOpen || loading) return; + scrollToBottom(false); + }, [comments.length, isOpen, loading, scrollToBottom]); + + useLayoutEffect(() => { + const input = inputRef.current; + if (!input) return; + const minHeight = 38; + const maxHeight = 128; + input.style.height = "0px"; + const nextHeight = Math.min(input.scrollHeight, maxHeight); + const resolvedHeight = Math.max(nextHeight, minHeight); + input.style.height = `${resolvedHeight}px`; + input.style.overflowY = input.scrollHeight > maxHeight ? "auto" : "hidden"; + setComposerExpanded(resolvedHeight > minHeight + 2); + }, [draft]); + + const submitComment = useCallback(async () => { + if (!record) return; + const text = draft.trim(); + if (!text) { + setError("Введите текст комментария"); + return; + } + try { + setSending(true); + setError(null); + const saved = await apiClient.addAttendanceRecordComment(record.recordId, text); + const nextComments = [...comments, saved]; + setComments(nextComments); + setDraft(""); + onCommentCountChange?.(record.recordId, nextComments.length); + scrollToBottom(); + } catch (submitError) { + setError(getErrorMessage(submitError, "Не удалось отправить комментарий")); + } finally { + setSending(false); + } + }, [comments, draft, onCommentCountChange, record, scrollToBottom]); + + const deleteComment = useCallback( + async (comment: AttendanceRecordComment) => { + if (!record) return; + try { + setActionId(comment.id); + await apiClient.deleteAttendanceRecordComment(record.recordId, comment.id); + const nextComments = comments.filter((item) => item.id !== comment.id); + setComments(nextComments); + onCommentCountChange?.(record.recordId, nextComments.length); + } catch (deleteError) { + setError(getErrorMessage(deleteError, "Не удалось удалить комментарий")); + } finally { + setActionId(null); + } + }, + [comments, onCommentCountChange, record], + ); + + return ( + + {record ? ( +
+
+
+

Запись посещения

+
+
+
+

+ {record.employeeName} +

+

+ {record.date} · {record.displayText || "Нет записи"} +

+
+ + {record.statusLabel} + +
+ {record.detailLines?.length ? ( +
+ {record.detailLines.slice(0, 6).map((line) => ( +

{line}

+ ))} +
+ ) : null} +
+ Комментариев: {Math.max(comments.length, record.commentsCount || 0)} +
+
+
+ + {error ? ( +
+ {error} +
+ ) : null} +
+ +
+
+
+ +

Обсуждение

+
+ + {loading ? ( +
+ +

+ Загрузка комментариев... +

+
+ ) : comments.length === 0 ? ( +
+

+ Комментариев пока нет +

+

+ Добавьте пояснение к записи посещения. +

+
+ ) : ( +
+ {comments.map((comment) => { + const authorName = commentAuthorName(comment); + const isOwnComment = Boolean( + comment.author?.id && + currentUserId && + comment.author.id === currentUserId, + ); + return ( +
+
+ +
+
+
+

+ {authorName} +

+

+ {formatDateTime(comment.created_at)} +

+
+ {isOwnComment ? ( + void deleteComment(comment)} + /> + ) : null} +
+

+ {comment.text} +

+
+
+
+ ); + })} +
+
+ )} +
+
+ +
+
+
+