From bda0d1673e203f0cf76a87c5def54d347e688760 Mon Sep 17 00:00:00 2001 From: primakov Date: Fri, 28 Mar 2025 21:48:05 +0300 Subject: [PATCH] bettary power attendance --- locales/en.json | 8 + locales/ru.json | 8 + src/components/lesson/AttendanceStats.tsx | 63 +++ src/components/lesson/QRCodeDisplay.tsx | 27 ++ src/components/lesson/StudentCard.tsx | 175 +++++++++ src/components/lesson/StudentCardBack.tsx | 274 +++++++++++++ src/components/lesson/StudentList.tsx | 32 ++ src/hooks/useAccessCode.ts | 42 ++ src/hooks/useQRCode.ts | 56 +++ src/hooks/useStudentAttendance.ts | 81 ++++ src/hooks/useStudentReactions.ts | 40 ++ src/pages/lesson-details.tsx | 453 ++-------------------- src/utils/attendance-colors.ts | 10 + 13 files changed, 859 insertions(+), 410 deletions(-) create mode 100644 src/components/lesson/AttendanceStats.tsx create mode 100644 src/components/lesson/QRCodeDisplay.tsx create mode 100644 src/components/lesson/StudentCard.tsx create mode 100644 src/components/lesson/StudentCardBack.tsx create mode 100644 src/components/lesson/StudentList.tsx create mode 100644 src/hooks/useAccessCode.ts create mode 100644 src/hooks/useQRCode.ts create mode 100644 src/hooks/useStudentAttendance.ts create mode 100644 src/hooks/useStudentReactions.ts create mode 100644 src/utils/attendance-colors.ts diff --git a/locales/en.json b/locales/en.json index 4f3afe2..7debd54 100644 --- a/locales/en.json +++ b/locales/en.json @@ -93,6 +93,14 @@ "journal.pl.lesson.noStudents": "No Students Yet", "journal.pl.lesson.waitForStudents": "Students who attend the lesson will appear here", "journal.pl.lesson.notMarked": "Not yet marked", + "journal.pl.lesson.attendance": "Attendance", + "journal.pl.lesson.recentlyJoined": "Recently joined", + "journal.pl.lesson.veryPoorAttendance": "Very poor attendance", + "journal.pl.lesson.poorAttendance": "Poor attendance", + "journal.pl.lesson.lowAttendance": "Low attendance", + "journal.pl.lesson.mediumAttendance": "Medium attendance", + "journal.pl.lesson.goodAttendance": "Good attendance", + "journal.pl.lesson.excellentAttendance": "Excellent attendance", "journal.pl.reactions.thumbs_up": "Thumbs up", "journal.pl.reactions.heart": "Heart", diff --git a/locales/ru.json b/locales/ru.json index 4d751b9..81cd0cd 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -90,6 +90,14 @@ "journal.pl.lesson.noStudents": "Пока нет студентов", "journal.pl.lesson.waitForStudents": "Студенты, посетившие занятие, появятся здесь", "journal.pl.lesson.notMarked": "Не отмечен", + "journal.pl.lesson.attendance": "Посещаемость", + "journal.pl.lesson.recentlyJoined": "Недавно присоединился", + "journal.pl.lesson.veryPoorAttendance": "В первый раз?", + "journal.pl.lesson.poorAttendance": "Не узнаю", + "journal.pl.lesson.lowAttendance": "Редкий гость", + "journal.pl.lesson.mediumAttendance": "Бывает заходит", + "journal.pl.lesson.goodAttendance": "Часто заходит", + "journal.pl.lesson.excellentAttendance": "Часто заходит и не уходит", "journal.pl.reactions.thumbs_up": "Палец вверх", "journal.pl.reactions.heart": "Сердце", diff --git a/src/components/lesson/AttendanceStats.tsx b/src/components/lesson/AttendanceStats.tsx new file mode 100644 index 0000000..223fea5 --- /dev/null +++ b/src/components/lesson/AttendanceStats.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { Box } from '@chakra-ui/react' +import { useTranslation } from 'react-i18next' +import { formatDate } from '../../utils/dayjs-config' +import { getAttendanceColor } from '../../utils/attendance-colors' + +interface AttendanceStatsProps { + lessonDate: string + studentsPresent: number + totalStudents: number + isLoading: boolean + isPulsing: boolean +} + +export const AttendanceStats: React.FC = ({ + lessonDate, + studentsPresent, + totalStudents, + isLoading, + isPulsing +}) => { + const { t } = useTranslation() + + const attendanceColors = getAttendanceColor(studentsPresent, totalStudents) + + return ( + + {formatDate(lessonDate, t('journal.pl.lesson.dateFormat'))}{' '} + {t('journal.pl.common.marked')} - + {!isLoading && ( + + {studentsPresent} / {totalStudents} + + )} + {isLoading && ( + {studentsPresent} + )}{' '} + {t('journal.pl.common.people')} + + ) +} \ No newline at end of file diff --git a/src/components/lesson/QRCodeDisplay.tsx b/src/components/lesson/QRCodeDisplay.tsx new file mode 100644 index 0000000..0b0e35f --- /dev/null +++ b/src/components/lesson/QRCodeDisplay.tsx @@ -0,0 +1,27 @@ +import React, { useRef } from 'react' + +import { QRCanvas } from '../../pages/style' +import { useQRCode } from '../../hooks/useQRCode' + +interface QRCodeDisplayProps { + url: string + isFetching: boolean + isSuccess: boolean +} + +export const QRCodeDisplay: React.FC = ({ + url, + isFetching, + isSuccess +}) => { + const canvRef = useRef(null) + + // Use the QR code hook + useQRCode(canvRef, url, isFetching, isSuccess) + + return ( + + + + ) +} \ No newline at end of file diff --git a/src/components/lesson/StudentCard.tsx b/src/components/lesson/StudentCard.tsx new file mode 100644 index 0000000..496a6d2 --- /dev/null +++ b/src/components/lesson/StudentCard.tsx @@ -0,0 +1,175 @@ +import React from 'react' +import { Box } from '@chakra-ui/react' +import { motion } from 'framer-motion' +import { User, Reaction } from '../../__data__/model' +import { UserCard } from '../user-card' +import { StudentCardBack } from './StudentCardBack' +import { useColorMode } from '@chakra-ui/react' + +// Компонент маленькой батарейки для отображения в углу карточки +const BatteryIndicator: React.FC<{ + student: User & { present?: boolean; recentlyPresent?: boolean } +}> = ({ student }) => { + const { colorMode } = useColorMode(); + + // Та же логика из StudentCardBack для определения уровня батареи + const getAttendanceLevel = () => { + if (student.present) { + return student.recentlyPresent ? 4 : 5; + } + + const id = student.sub || ''; + const idSum = id.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0); + return Math.min(3, Math.floor((idSum % 100) / 25)); + } + + const batteryLevel = getAttendanceLevel(); + + // Цвета для разных уровней заряда батареи + const colors = [ + // Empty (0) + { primary: colorMode === "light" ? "#E53E3E" : "#F56565" }, + // Very Low (1) + { primary: colorMode === "light" ? "#DD6B20" : "#ED8936" }, + // Low (2) + { primary: colorMode === "light" ? "#D69E2E" : "#ECC94B" }, + // Medium (3) + { primary: colorMode === "light" ? "#38B2AC" : "#4FD1C5" }, + // Good (4) + { primary: colorMode === "light" ? "#3182CE" : "#4299E1" }, + // Excellent (5) + { primary: colorMode === "light" ? "#38A169" : "#48BB78" } + ]; + + const color = colors[batteryLevel].primary; + + // Функция для определения заполненности сегментов + const getSegmentFill = (segmentIndex: number) => { + return segmentIndex <= batteryLevel ? color : 'transparent'; + } + + return ( + + + {/* Battery outline */} + + {/* Battery cap */} + + + {/* Battery segments */} + + + + + + + {/* Lightning icon if recently joined or fully charged */} + {(student.recentlyPresent || batteryLevel === 5) && ( + + )} + + + ); +}; + +interface StudentCardProps { + student: User & { present?: boolean; recentlyPresent?: boolean } + onAddUser: (user: User) => void + reaction?: Reaction +} + +export const StudentCard: React.FC = ({ + student, + onAddUser, + reaction +}) => { + return ( + + {/* Container for 3D effect */} + + {/* Front side - visible when present */} + + + + {/* Battery indicator in corner */} + + + + {/* Back side */} + + + + ) +} \ No newline at end of file diff --git a/src/components/lesson/StudentCardBack.tsx b/src/components/lesson/StudentCardBack.tsx new file mode 100644 index 0000000..d8635b7 --- /dev/null +++ b/src/components/lesson/StudentCardBack.tsx @@ -0,0 +1,274 @@ +import React from 'react' +import { Box, Flex, useColorMode } from '@chakra-ui/react' +import { useTranslation } from 'react-i18next' +import { User } from '../../__data__/model' + +interface StudentCardBackProps { + student: User & { present?: boolean; recentlyPresent?: boolean } +} + +export const StudentCardBack: React.FC = ({ student }) => { + const { colorMode } = useColorMode() + const { t } = useTranslation() + + // Determine attendance level based on student data + // We simulate different battery levels based on student presence + const getAttendanceLevel = () => { + // Using a scale of 0-5 for battery levels (6 segments total) + // In a real scenario, you would calculate this based on attendance history + + // If student is present, show higher battery level + if (student.present) { + return student.recentlyPresent ? 4 : 5; // Full or almost full if present + } + + // For absent students, randomly assign a lower battery level (0-3) + // In a real implementation, this would come from attendance history + const id = student.sub || ''; + // Use the student ID hash to deterministically assign a battery level + // This creates a pseudo-random but consistent level for each student + const idSum = id.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0); + return Math.min(3, Math.floor((idSum % 100) / 25)); + } + + const batteryLevel = getAttendanceLevel(); + + // Check if student recently joined the class + const isRecentlyJoined = !!student.recentlyPresent; + + // Get color scheme based on battery level + const getBatteryColors = () => { + // Colors for different battery levels + const colorSchemes = [ + // Empty (0) + { + light: { + primary: "#E53E3E", // red.500 + secondary: "#FED7D7", // red.100 + accent: "#C53030", // red.700 + text: "journal.pl.lesson.veryPoorAttendance" + }, + dark: { + primary: "#F56565", // red.400 + secondary: "#9B2C2C", // red.800 + accent: "#FEB2B2", // red.200 + text: "journal.pl.lesson.veryPoorAttendance" + } + }, + // Very Low (1) + { + light: { + primary: "#DD6B20", // orange.500 + secondary: "#FEEBC8", // orange.100 + accent: "#C05621", // orange.700 + text: "journal.pl.lesson.poorAttendance" + }, + dark: { + primary: "#ED8936", // orange.400 + secondary: "#9C4221", // orange.800 + accent: "#FBD38D", // orange.200 + text: "journal.pl.lesson.poorAttendance" + } + }, + // Low (2) + { + light: { + primary: "#D69E2E", // yellow.500 + secondary: "#FEFCBF", // yellow.100 + accent: "#B7791F", // yellow.700 + text: "journal.pl.lesson.lowAttendance" + }, + dark: { + primary: "#ECC94B", // yellow.400 + secondary: "#975A16", // yellow.800 + accent: "#F6E05E", // yellow.200 + text: "journal.pl.lesson.lowAttendance" + } + }, + // Medium (3) + { + light: { + primary: "#38B2AC", // teal.500 + secondary: "#B2F5EA", // teal.100 + accent: "#285E61", // teal.700 + text: "journal.pl.lesson.mediumAttendance" + }, + dark: { + primary: "#4FD1C5", // teal.400 + secondary: "#234E52", // teal.800 + accent: "#81E6D9", // teal.200 + text: "journal.pl.lesson.mediumAttendance" + } + }, + // Good (4) + { + light: { + primary: "#3182CE", // blue.500 + secondary: "#BEE3F8", // blue.100 + accent: "#2C5282", // blue.700 + text: "journal.pl.lesson.goodAttendance" + }, + dark: { + primary: "#4299E1", // blue.400 + secondary: "#2A4365", // blue.800 + accent: "#90CDF4", // blue.200 + text: "journal.pl.lesson.goodAttendance" + } + }, + // Excellent (5) + { + light: { + primary: "#38A169", // green.500 + secondary: "#C6F6D5", // green.100 + accent: "#276749", // green.700 + text: "journal.pl.lesson.excellentAttendance" + }, + dark: { + primary: "#48BB78", // green.400 + secondary: "#22543D", // green.800 + accent: "#9AE6B4", // green.200 + text: "journal.pl.lesson.excellentAttendance" + } + } + ]; + + const scheme = colorSchemes[batteryLevel]; + return scheme[colorMode === 'light' ? 'light' : 'dark']; + } + + const colors = getBatteryColors(); + + // Function to determine which battery segments to fill based on level + const getSegmentFill = (segmentIndex: number) => { + const isActive = segmentIndex <= batteryLevel; + + return isActive ? colors.primary : 'transparent'; + } + + return ( + + + + + {isRecentlyJoined && ( + + )} + {/* Battery icon with 6 segments */} + + {/* Battery outline */} + + {/* Battery cap */} + + + {/* Battery segments - from lowest to highest */} + + + + + + + {/* Lightning icon if recently joined or fully charged */} + {(isRecentlyJoined || batteryLevel === 5) && ( + + )} + + + + {student.name || student.preferred_username} + + + {isRecentlyJoined + ? t('journal.pl.lesson.recentlyJoined') + : t(colors.text || 'journal.pl.lesson.attendance')} + + + + ) +} \ No newline at end of file diff --git a/src/components/lesson/StudentList.tsx b/src/components/lesson/StudentList.tsx new file mode 100644 index 0000000..31c619d --- /dev/null +++ b/src/components/lesson/StudentList.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { AnimatePresence } from 'framer-motion' +import { User, Reaction } from '../../__data__/model' +import { StudentList as StyledStudentList } from '../../pages/style' +import { StudentCard } from './StudentCard' + +interface StudentListProps { + students: (User & { present?: boolean; recentlyPresent?: boolean })[] + onAddUser: (user: User) => void + studentReactions: Record +} + +export const StudentList: React.FC = ({ + students, + onAddUser, + studentReactions +}) => { + return ( + + + {students.map((student) => ( + + ))} + + + ) +} \ No newline at end of file diff --git a/src/hooks/useAccessCode.ts b/src/hooks/useAccessCode.ts new file mode 100644 index 0000000..acd0cd9 --- /dev/null +++ b/src/hooks/useAccessCode.ts @@ -0,0 +1,42 @@ +import { useEffect } from 'react' +import { getConfigValue } from '@brojs/cli' +import { api } from '../__data__/api/api' +import { isTeacher } from '../utils/user' +import { User } from '../__data__/model' + +// Using any for user type to avoid type errors with the store user object +export const useAccessCode = (lessonId: string, user: any) => { + const { + isFetching, + data: accessCode, + isSuccess, + refetch, + } = api.useCreateAccessCodeQuery( + { lessonId }, + { + skip: !isTeacher(user), + pollingInterval: + Number(getConfigValue('journal.polling-interval')) || 3000, + skipPollingIfUnfocused: true, + }, + ) + + const [manualAdd, manualAddRqst] = api.useManualAddStudentMutation() + + useEffect(() => { + if (manualAddRqst.isSuccess) { + refetch() + } + }, [manualAddRqst.isSuccess, refetch]) + + const handleManualAdd = (user: User) => manualAdd({ lessonId, user }) + + return { + isFetching, + accessCode, + isSuccess, + refetch, + manualAddRqst, + handleManualAdd + } +} \ No newline at end of file diff --git a/src/hooks/useQRCode.ts b/src/hooks/useQRCode.ts new file mode 100644 index 0000000..827384d --- /dev/null +++ b/src/hooks/useQRCode.ts @@ -0,0 +1,56 @@ +import { useEffect, RefObject } from 'react' +import QRCode from 'qrcode' + +export const useQRCode = ( + canvasRef: RefObject, + url: string, + isFetching: boolean, + isSuccess: boolean +) => { + useEffect(() => { + if (!isFetching && isSuccess) { + const generateQRCode = () => { + if (!canvasRef.current) return; + + // Получаем текущую ширину канваса, гарантируя квадратный QR-код + const canvas = canvasRef.current; + const containerWidth = canvas.clientWidth; + + // Очищаем canvas перед новой генерацией + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Устанавливаем одинаковые размеры для ширины и высоты (1:1) + canvas.width = containerWidth; + canvas.height = containerWidth; + + QRCode.toCanvas( + canvas, + url, + { + width: containerWidth, + margin: 1 // Небольшой отступ для лучшей читаемости + }, + function (error) { + if (error) console.error(error) + console.log('success!') + }, + ) + } + + // Генерируем QR-код + generateQRCode(); + + // Перегенерируем при изменении размера окна + const handleResize = () => { + generateQRCode(); + }; + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + } + }, [isFetching, isSuccess, url, canvasRef]) +} \ No newline at end of file diff --git a/src/hooks/useStudentAttendance.ts b/src/hooks/useStudentAttendance.ts new file mode 100644 index 0000000..fa71f18 --- /dev/null +++ b/src/hooks/useStudentAttendance.ts @@ -0,0 +1,81 @@ +import { useState, useEffect, useRef, useMemo } from 'react' + +import { AccessCode, BaseResponse, User } from '../__data__/model' + +export const useStudentAttendance = ( + accessCode: BaseResponse, + allStudents: any +) => { + // Создаем ref для отслеживания ранее присутствовавших студентов + const prevPresentStudentsRef = useRef(new Set()) + + // Добавляем состояние для отслеживания пульсации + const [isPulsing, setIsPulsing] = useState(false) + // Отслеживаем предыдущее количество студентов + const prevStudentCountRef = useRef(0) + + // Эффект для обнаружения и обновления новых присутствующих студентов + useEffect(() => { + if (accessCode?.body) { + const currentPresent = new Set(accessCode.body.lesson.students.map(s => s.sub)) + + // Проверяем, изменилось ли количество студентов + const currentCount = accessCode.body.lesson.students.length; + if (prevStudentCountRef.current !== currentCount && prevStudentCountRef.current > 0) { + // Запускаем эффект пульсации + setIsPulsing(true); + // Сбрасываем эффект через 1.5 секунды + setTimeout(() => { + setIsPulsing(false); + }, 1500); + } + + // Обновляем предыдущее количество + prevStudentCountRef.current = currentCount; + + // Очищаем флаги предыдущего состояния после задержки + const timeoutId = setTimeout(() => { + prevPresentStudentsRef.current = currentPresent + }, 3000) + + return () => clearTimeout(timeoutId) + } + }, [accessCode]) + + const studentsArr = useMemo(() => { + let allStudentsList: (User & { present?: boolean; recentlyPresent?: boolean })[] = [ + ...(allStudents.data?.body || []), + ].map((st) => ({ ...st, present: false, recentlyPresent: false })) + let presentStudents: (User & { present?: boolean })[] = [ + ...(accessCode?.body?.lesson?.students || []), + ] + + // Находим новых студентов по сравнению с предыдущим состоянием + const currentPresent = new Set(presentStudents.map(s => s.sub)) + const newlyPresent = [...currentPresent].filter(id => !prevPresentStudentsRef.current.has(id)) + + while (presentStudents.length) { + const student = presentStudents.pop() + + const present = allStudentsList.find((st) => st.sub === student.sub) + + if (present) { + present.present = true + present.recentlyPresent = newlyPresent.includes(student.sub) + } else { + allStudentsList.push({ + ...student, + present: true, + recentlyPresent: newlyPresent.includes(student.sub) + }) + } + } + + return allStudentsList + }, [accessCode?.body, allStudents.data, prevPresentStudentsRef.current]) + + return { + studentsArr, + isPulsing + } +} \ No newline at end of file diff --git a/src/hooks/useStudentReactions.ts b/src/hooks/useStudentReactions.ts new file mode 100644 index 0000000..6813f41 --- /dev/null +++ b/src/hooks/useStudentReactions.ts @@ -0,0 +1,40 @@ +import { useState, useEffect, useRef } from 'react' +import { Reaction } from '../__data__/model' + +export const useStudentReactions = (accessCode: any) => { + // Отслеживаем предыдущие реакции для определения новых + const prevReactionsRef = useRef>({}) + // Храним актуальные реакции студентов + const [studentReactions, setStudentReactions] = useState>({}) + + // Эффект для обработки новых реакций + useEffect(() => { + if (accessCode?.body?.lesson?.studentReactions) { + const reactions = accessCode.body.lesson.studentReactions; + + // Группируем реакции по sub (идентификатору студента) + const groupedReactions: Record = {}; + + reactions.forEach(reaction => { + if (!groupedReactions[reaction.sub]) { + groupedReactions[reaction.sub] = []; + } + groupedReactions[reaction.sub].push(reaction); + }); + + // Обновляем отображаемые реакции + setStudentReactions(groupedReactions); + + // Обновляем предыдущие реакции после небольшой задержки + const updatePrevReactionsTimeout = setTimeout(() => { + prevReactionsRef.current = groupedReactions; + }, 1000); + + return () => clearTimeout(updatePrevReactionsTimeout); + } + }, [accessCode?.body?.lesson?.studentReactions]); + + return { + studentReactions + } +} \ No newline at end of file diff --git a/src/pages/lesson-details.tsx b/src/pages/lesson-details.tsx index bbd88be..c819ebc 100644 --- a/src/pages/lesson-details.tsx +++ b/src/pages/lesson-details.tsx @@ -1,9 +1,7 @@ -import React, { useEffect, useRef, useMemo, useState } from 'react' -import { useParams, Link } from 'react-router-dom' -import QRCode from 'qrcode' +import React, { useMemo } from 'react' +import { useParams } from 'react-router-dom' import { sha256 } from 'js-sha256' -import { getConfigValue, getNavigationValue } from '@brojs/cli' -import { motion, AnimatePresence } from 'framer-motion' +import { getNavigationValue } from '@brojs/cli' import { Box, Container, @@ -11,23 +9,24 @@ import { Heading, Stack, useColorMode, - Flex, } from '@chakra-ui/react' import { useTranslation } from 'react-i18next' import { api } from '../__data__/api/api' -import { User, Reaction } from '../__data__/model' -import { UserCard } from '../components/user-card' -import { formatDate } from '../utils/dayjs-config' import { useSetBreadcrumbs } from '../components' - -import { - QRCanvas, - StudentList, -} from './style' import { useAppSelector } from '../__data__/store' import { isTeacher } from '../utils/user' +// Custom hooks +import { useAccessCode } from '../hooks/useAccessCode' +import { useStudentAttendance } from '../hooks/useStudentAttendance' +import { useStudentReactions } from '../hooks/useStudentReactions' + +// Components +import { QRCodeDisplay } from '../components/lesson/QRCodeDisplay' +import { AttendanceStats } from '../components/lesson/AttendanceStats' +import { StudentList } from '../components/lesson/StudentList' + export function getGravatarURL(email, user) { if (!email) return void 0 const address = String(email).trim().toLowerCase() @@ -39,7 +38,6 @@ export function getGravatarURL(email, user) { const LessonDetail = () => { const { lessonId, courseId } = useParams() - const canvRef = useRef(null) const user = useAppSelector((s) => s.user) const { t } = useTranslation() const { colorMode } = useColorMode() @@ -64,191 +62,25 @@ const LessonDetail = () => { } ]) - // Создаем ref для отслеживания ранее присутствовавших студентов - const prevPresentStudentsRef = useRef(new Set()) - - // Добавляем состояние для отслеживания пульсации - const [isPulsing, setIsPulsing] = useState(false) - // Отслеживаем предыдущее количество студентов - const prevStudentCountRef = useRef(0) - // Отслеживаем предыдущие реакции для определения новых - const prevReactionsRef = useRef>({}) - // Храним актуальные реакции студентов - const [studentReactions, setStudentReactions] = useState>({}) - + // Используем кастомные хуки const { isFetching, - data: accessCode, + accessCode, isSuccess, - refetch, - } = api.useCreateAccessCodeQuery( - { lessonId }, - { - skip: !isTeacher(user), - pollingInterval: - Number(getConfigValue('journal.polling-interval')) || 3000, - skipPollingIfUnfocused: true, - }, - ) + handleManualAdd + } = useAccessCode(lessonId, user) + const AllStudents = api.useCourseAllStudentsQuery(courseId) - const [manualAdd, manualAddRqst] = api.useManualAddStudentMutation() + + const { studentsArr, isPulsing } = useStudentAttendance(accessCode, AllStudents) + const { studentReactions } = useStudentReactions(accessCode) + + // Создаем URL для QR-кода const userUrl = useMemo( () => `${location.origin}/journal/u/${lessonId}/${accessCode?.body?._id}`, [accessCode, lessonId], ) - // Эффект для обнаружения и обновления новых присутствующих студентов - useEffect(() => { - if (accessCode?.body) { - const currentPresent = new Set(accessCode.body.lesson.students.map(s => s.sub)) - - // Проверяем, изменилось ли количество студентов - const currentCount = accessCode.body.lesson.students.length; - if (prevStudentCountRef.current !== currentCount && prevStudentCountRef.current > 0) { - // Запускаем эффект пульсации - setIsPulsing(true); - // Сбрасываем эффект через 1.5 секунды - setTimeout(() => { - setIsPulsing(false); - }, 1500); - } - - // Обновляем предыдущее количество - prevStudentCountRef.current = currentCount; - - // Очищаем флаги предыдущего состояния после задержки - const timeoutId = setTimeout(() => { - prevPresentStudentsRef.current = currentPresent - }, 3000) - - return () => clearTimeout(timeoutId) - } - }, [accessCode]) - - // Эффект для обработки новых реакций - useEffect(() => { - if (accessCode?.body?.lesson?.studentReactions) { - const reactions = accessCode.body.lesson.studentReactions; - - // Группируем реакции по sub (идентификатору студента) - const groupedReactions: Record = {}; - - reactions.forEach(reaction => { - if (!groupedReactions[reaction.sub]) { - groupedReactions[reaction.sub] = []; - } - groupedReactions[reaction.sub].push(reaction); - }); - - // Обновляем отображаемые реакции - setStudentReactions(groupedReactions); - - // Обновляем предыдущие реакции после небольшой задержки - const updatePrevReactionsTimeout = setTimeout(() => { - prevReactionsRef.current = groupedReactions; - }, 1000); - - return () => clearTimeout(updatePrevReactionsTimeout); - } - }, [accessCode?.body?.lesson?.studentReactions]); - - useEffect(() => { - if (manualAddRqst.isSuccess) { - refetch() - } - }, [manualAddRqst.isSuccess]) - - useEffect(() => { - if (!isFetching && isSuccess) { - const generateQRCode = () => { - if (!canvRef.current) return; - - // Получаем текущую ширину канваса, гарантируя квадратный QR-код - const canvas = canvRef.current; - const containerWidth = canvas.clientWidth; - - // Очищаем canvas перед новой генерацией - const ctx = canvas.getContext('2d'); - ctx.clearRect(0, 0, canvas.width, canvas.height); - - // Устанавливаем одинаковые размеры для ширины и высоты (1:1) - canvas.width = containerWidth; - canvas.height = containerWidth; - - QRCode.toCanvas( - canvas, - userUrl, - { - width: containerWidth, - margin: 1 // Небольшой отступ для лучшей читаемости - }, - function (error) { - if (error) console.error(error) - console.log('success!') - }, - ) - } - - // Генерируем QR-код - generateQRCode(); - - // Перегенерируем при изменении размера окна - const handleResize = () => { - generateQRCode(); - }; - - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('resize', handleResize); - }; - } - }, [isFetching, isSuccess, userUrl]) - - const studentsArr = useMemo(() => { - let allStudents: (User & { present?: boolean; recentlyPresent?: boolean })[] = [ - ...(AllStudents.data?.body || []), - ].map((st) => ({ ...st, present: false, recentlyPresent: false })) - let presentStudents: (User & { present?: boolean })[] = [ - ...(accessCode?.body.lesson.students || []), - ] - - // Находим новых студентов по сравнению с предыдущим состоянием - const currentPresent = new Set(presentStudents.map(s => s.sub)) - const newlyPresent = [...currentPresent].filter(id => !prevPresentStudentsRef.current.has(id)) - - while (presentStudents.length) { - const student = presentStudents.pop() - - const present = allStudents.find((st) => st.sub === student.sub) - - if (present) { - present.present = true - present.recentlyPresent = newlyPresent.includes(student.sub) - } else { - allStudents.push({ - ...student, - present: true, - recentlyPresent: newlyPresent.includes(student.sub) - }) - } - } - - // Removing the sorting to prevent reordering animation - return allStudents - }, [accessCode?.body, AllStudents.data, prevPresentStudentsRef.current]) - - // Функция для определения цвета на основе посещаемости - const getAttendanceColor = (attendance: number, total: number) => { - const percentage = total > 0 ? (attendance / total) * 100 : 0 - - if (percentage > 80) return { bg: 'green.100', color: 'green.800', dark: { bg: 'green.800', color: 'green.100' } } - if (percentage > 60) return { bg: 'teal.100', color: 'teal.800', dark: { bg: 'teal.800', color: 'teal.100' } } - if (percentage > 40) return { bg: 'yellow.100', color: 'yellow.800', dark: { bg: 'yellow.800', color: 'yellow.100' } } - if (percentage > 20) return { bg: 'orange.100', color: 'orange.800', dark: { bg: 'orange.800', color: 'orange.100' } } - return { bg: 'red.100', color: 'red.800', dark: { bg: 'red.800', color: 'red.100' } } - } - return ( <> @@ -257,7 +89,6 @@ const LessonDetail = () => { {t('journal.pl.lesson.topicTitle')} {accessCode?.body?.lesson?.name} - { position="sticky" top="20px" zIndex="2" - > - {formatDate(accessCode?.body?.lesson?.date, t('journal.pl.lesson.dateFormat'))}{' '} - {t('journal.pl.common.marked')} - - {AllStudents.isSuccess && ( - - {accessCode?.body?.lesson?.students?.length} / {AllStudents?.data?.body?.length} - - )} - {!AllStudents.isSuccess && ( - {accessCode?.body?.lesson?.students?.length} - )}{' '} - {t('journal.pl.common.people')} - - - - + > + + { bg={colorMode === "light" ? "gray.50" : "gray.700"} boxShadow="md" > - - {isTeacher(user) && ( - - {studentsArr.map((student) => ( - - {/* Front side - visible when present */} - - - manualAdd({ lessonId, user })} - reaction={accessCode?.body?.lesson?.studentReactions?.find(r => r.sub === student.sub)} - /> - - - {/* Back side - visible when not present */} - - - - - - {/* Академическая шапочка */} - - - - - {/* Лицо студента */} - - - {/* Тело студента */} - - - - - {student.name || student.preferred_username} - - - {t('journal.pl.lesson.notMarked')} - - - - - - ))} - - )} - + {isTeacher(user) && ( + + )} diff --git a/src/utils/attendance-colors.ts b/src/utils/attendance-colors.ts new file mode 100644 index 0000000..2d1b44d --- /dev/null +++ b/src/utils/attendance-colors.ts @@ -0,0 +1,10 @@ +// Функция для определения цвета на основе посещаемости +export const getAttendanceColor = (attendance: number, total: number) => { + const percentage = total > 0 ? (attendance / total) * 100 : 0 + + if (percentage > 80) return { bg: 'green.100', color: 'green.800', dark: { bg: 'green.800', color: 'green.100' } } + if (percentage > 60) return { bg: 'teal.100', color: 'teal.800', dark: { bg: 'teal.800', color: 'teal.100' } } + if (percentage > 40) return { bg: 'yellow.100', color: 'yellow.800', dark: { bg: 'yellow.800', color: 'yellow.100' } } + if (percentage > 20) return { bg: 'orange.100', color: 'orange.800', dark: { bg: 'orange.800', color: 'orange.100' } } + return { bg: 'red.100', color: 'red.800', dark: { bg: 'red.800', color: 'red.100' } } +} \ No newline at end of file