bettary power attendance

This commit is contained in:
2025-03-28 21:48:05 +03:00
parent 870ac5348b
commit bda0d1673e
13 changed files with 859 additions and 410 deletions

View File

@@ -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<AttendanceStatsProps> = ({
lessonDate,
studentsPresent,
totalStudents,
isLoading,
isPulsing
}) => {
const { t } = useTranslation()
const attendanceColors = getAttendanceColor(studentsPresent, totalStudents)
return (
<Box pb={3}>
{formatDate(lessonDate, t('journal.pl.lesson.dateFormat'))}{' '}
{t('journal.pl.common.marked')} -
{!isLoading && (
<Box
as="span"
px={2}
py={1}
ml={2}
borderRadius="md"
fontWeight="bold"
bg={attendanceColors.bg}
color={attendanceColors.color}
_dark={{
bg: attendanceColors.dark.bg,
color: attendanceColors.dark.color
}}
position="relative"
animation={isPulsing ? "pulse 1.5s ease-in-out" : "none"}
sx={{
'@keyframes pulse': {
'0%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.15)', boxShadow: '0 0 10px rgba(66, 153, 225, 0.7)' },
'100%': { transform: 'scale(1)' }
}
}}
>
{studentsPresent} / {totalStudents}
</Box>
)}
{isLoading && (
<span> {studentsPresent}</span>
)}{' '}
{t('journal.pl.common.people')}
</Box>
)
}

View File

@@ -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<QRCodeDisplayProps> = ({
url,
isFetching,
isSuccess
}) => {
const canvRef = useRef(null)
// Use the QR code hook
useQRCode(canvRef, url, isFetching, isSuccess)
return (
<a href={url}>
<QRCanvas ref={canvRef} />
</a>
)
}

View File

@@ -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 (
<Box
position="absolute"
top="8px"
right="8px"
width="20px"
height="10px"
zIndex="1"
>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Battery outline */}
<rect
x="2"
y="6"
width="18"
height="12"
rx="2"
stroke={color}
strokeWidth="1.5"
fill="transparent"
/>
{/* Battery cap */}
<path
d="M20 10H22V14H20V10Z"
fill={color}
/>
{/* Battery segments */}
<rect x="4" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(0)} />
<rect x="7" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(1)} />
<rect x="10" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(2)} />
<rect x="13" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(3)} />
<rect x="16" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(4)} />
{/* Lightning icon if recently joined or fully charged */}
{(student.recentlyPresent || batteryLevel === 5) && (
<path
d="M11.5 7.5L8 12H11L9.5 16.5L13 12H10L11.5 7.5Z"
fill={color}
stroke={color}
strokeWidth="0.3"
opacity="0.9"
/>
)}
</svg>
</Box>
);
};
interface StudentCardProps {
student: User & { present?: boolean; recentlyPresent?: boolean }
onAddUser: (user: User) => void
reaction?: Reaction
}
export const StudentCard: React.FC<StudentCardProps> = ({
student,
onAddUser,
reaction
}) => {
return (
<motion.li
key={student.sub}
animate={{
rotateY: student.present ? 0 : 180,
boxShadow: student.recentlyPresent
? ['0 0 0 0 rgba(66, 153, 225, 0)', '0 0 20px 5px rgba(66, 153, 225, 0.7)', '0 0 0 0 rgba(66, 153, 225, 0)']
: '0 0 0 0 rgba(0, 0, 0, 0)'
}}
transition={{
rotateY: { type: "spring", stiffness: 300, damping: 20 },
boxShadow: {
repeat: student.recentlyPresent ? 3 : 0,
duration: 1.5
}
}}
style={{
transformStyle: "preserve-3d",
perspective: "1000px",
aspectRatio: "1",
width: "100%",
display: "block"
}}
>
{/* Container for 3D effect */}
<Box
position="relative"
width="100%"
height="100%"
style={{
transformStyle: "preserve-3d"
}}
>
{/* Front side - visible when present */}
<Box
position="absolute"
top="0"
left="0"
width="100%"
height="100%"
style={{
backfaceVisibility: "hidden",
transform: "rotateY(0deg)",
zIndex: student.present ? 1 : 0
}}
>
<UserCard
wrapperAS="div"
student={student}
present={student.present}
recentlyPresent={student.recentlyPresent}
onAddUser={onAddUser}
reaction={reaction}
/>
{/* Battery indicator in corner */}
<BatteryIndicator student={student} />
</Box>
{/* Back side */}
<StudentCardBack student={student} />
</Box>
</motion.li>
)
}

View File

@@ -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<StudentCardBackProps> = ({ 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 (
<Flex
position="absolute"
top="0"
left="0"
width="100%"
height="100%"
bg={colorMode === "light" ? "white" : "gray.700"}
borderRadius="12px"
align="center"
justify="center"
p={4}
overflow="hidden"
style={{
backfaceVisibility: "hidden",
transform: "rotateY(180deg)",
zIndex: 0,
aspectRatio: "1"
}}
>
<Box
position="absolute"
top="0"
left="0"
right="0"
bottom="0"
opacity="0.15"
className="animated-bg"
sx={{
background: `linear-gradient(135deg,
${colors.secondary}, ${colors.primary}, ${colors.accent})`,
backgroundSize: "400% 400%",
animation: "gradientAnimation 8s ease infinite",
"@keyframes gradientAnimation": {
"0%": { backgroundPosition: "0% 50%" },
"50%": { backgroundPosition: "100% 50%" },
"100%": { backgroundPosition: "0% 50%" }
}
}}
/>
<Box
position="relative"
textAlign="center"
zIndex="1"
>
<Box
width="70px"
height="70px"
mx="auto"
mb={3}
position="relative"
>
{isRecentlyJoined && (
<Box
position="absolute"
top="-5px"
left="-5px"
right="-5px"
bottom="-5px"
borderRadius="full"
zIndex="0"
sx={{
animation: "pulse 2s infinite",
background: `radial-gradient(circle, ${colors.accent} 0%, transparent 70%)`,
"@keyframes pulse": {
"0%": { opacity: 0.7, transform: "scale(0.95)" },
"70%": { opacity: 0, transform: "scale(1.2)" },
"100%": { opacity: 0, transform: "scale(1.5)" }
}
}}
/>
)}
{/* Battery icon with 6 segments */}
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Battery outline */}
<rect
x="2"
y="6"
width="18"
height="12"
rx="2"
stroke={colors.primary}
strokeWidth="1.5"
fill="transparent"
/>
{/* Battery cap */}
<path
d="M20 10H22V14H20V10Z"
fill={colors.primary}
/>
{/* Battery segments - from lowest to highest */}
<rect x="4" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(0)} />
<rect x="7" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(1)} />
<rect x="10" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(2)} />
<rect x="13" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(3)} />
<rect x="16" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(4)} />
{/* Lightning icon if recently joined or fully charged */}
{(isRecentlyJoined || batteryLevel === 5) && (
<path
d="M11.5 7.5L8 12H11L9.5 16.5L13 12H10L11.5 7.5Z"
fill={colors.accent}
stroke={colors.primary}
strokeWidth="0.3"
opacity="0.9"
/>
)}
</svg>
</Box>
<Box fontSize="sm" fontWeight="medium" color={colorMode === "light" ? "gray.800" : "white"}>
{student.name || student.preferred_username}
</Box>
<Box
fontSize="xs"
mt={1}
color={colors.primary}
fontWeight="medium"
>
{isRecentlyJoined
? t('journal.pl.lesson.recentlyJoined')
: t(colors.text || 'journal.pl.lesson.attendance')}
</Box>
</Box>
</Flex>
)
}

View File

@@ -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<string, Reaction[]>
}
export const StudentList: React.FC<StudentListProps> = ({
students,
onAddUser,
studentReactions
}) => {
return (
<StyledStudentList>
<AnimatePresence initial={false}>
{students.map((student) => (
<StudentCard
key={student.sub}
student={student}
onAddUser={onAddUser}
reaction={studentReactions?.[student.sub]?.[0]}
/>
))}
</AnimatePresence>
</StyledStudentList>
)
}