From ed79b266af896c67e0fb2d231c287b833ddff3ae Mon Sep 17 00:00:00 2001 From: Satyam <95536056+Satyam-Rastogi@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:48:04 +0530 Subject: [PATCH] feat: Add GSAP animated counters and fix Team Schedule theme issues - Created reusable AnimatedNumber component with GSAP - Integrated animated counters in Dashboard (metrics, weather) - Integrated animated counters in Leaderboard (podium, table) - Fixed AdminSchedule dark mode styling with theme-aware classes - Added responsive columns and functional dropdown to Team Schedule - Implemented date range and status filters for Team Schedule - Improved animation smoothness with expo.out easing --- src/components/AnimatedNumber.jsx | 116 +++++++++ src/pages/AdminSchedule.jsx | 394 +++++++++++++++++++++++++----- src/pages/Dashboard.jsx | 13 +- src/pages/LeaderboardPage.jsx | 17 +- 4 files changed, 471 insertions(+), 69 deletions(-) create mode 100644 src/components/AnimatedNumber.jsx diff --git a/src/components/AnimatedNumber.jsx b/src/components/AnimatedNumber.jsx new file mode 100644 index 0000000..caae96e --- /dev/null +++ b/src/components/AnimatedNumber.jsx @@ -0,0 +1,116 @@ +import React, { useEffect, useRef } from 'react'; +import { gsap } from 'gsap'; + +/** + * AnimatedNumber - A reusable component that animates numbers from 0 to their target value using GSAP + * @param {number|string} value - The target value to animate to + * @param {number} duration - Animation duration in seconds (default: 2) + * @param {string} prefix - Optional prefix (e.g., "$") + * @param {string} suffix - Optional suffix (e.g., "%", "K", "M") + * @param {number} decimals - Number of decimal places (default: 0) + * @param {boolean} useLocaleString - Whether to format with commas (default: false) + * @param {string} className - Additional CSS classes + */ +const AnimatedNumber = ({ + value, + duration = 2, + prefix = '', + suffix = '', + decimals = 0, + useLocaleString = false, + className = '', + delay = 0 +}) => { + const numberRef = useRef(null); + const animationRef = useRef(null); + + useEffect(() => { + if (!numberRef.current) return; + + // Extract numeric value if it's a string like "$4.2M" + let numericValue = value; + let extractedPrefix = prefix; + let extractedSuffix = suffix; + + if (typeof value === 'string') { + // Check for currency symbols + if (value.startsWith('$')) { + extractedPrefix = '$'; + value = value.substring(1); + } + + // Check for suffixes like M, K, B + const lastChar = value.slice(-1); + if (['K', 'M', 'B'].includes(lastChar.toUpperCase())) { + extractedSuffix = lastChar.toUpperCase(); + value = value.slice(0, -1); + } + + // Parse the numeric part + numericValue = parseFloat(value.replace(/,/g, '')); + } + + // Handle non-numeric values + if (isNaN(numericValue)) { + numberRef.current.textContent = value; + return; + } + + // Convert suffix multipliers to actual numbers + let multiplier = 1; + if (extractedSuffix === 'K') multiplier = 1000; + if (extractedSuffix === 'M') multiplier = 1000000; + if (extractedSuffix === 'B') multiplier = 1000000000; + + const targetValue = numericValue * multiplier; + + // Animate from 0 to target value + const obj = { val: 0 }; + + animationRef.current = gsap.to(obj, { + val: numericValue, + duration: duration, + delay: delay, + ease: 'expo.out', + onUpdate: () => { + if (numberRef.current) { + let displayValue = obj.val.toFixed(decimals); + + if (useLocaleString) { + displayValue = parseFloat(displayValue).toLocaleString(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals + }); + } + + numberRef.current.textContent = `${extractedPrefix}${displayValue}${extractedSuffix}`; + } + }, + onComplete: () => { + // Ensure final value is exact + if (numberRef.current) { + let displayValue = numericValue.toFixed(decimals); + + if (useLocaleString) { + displayValue = parseFloat(displayValue).toLocaleString(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals + }); + } + + numberRef.current.textContent = `${extractedPrefix}${displayValue}${extractedSuffix}`; + } + } + }); + + return () => { + if (animationRef.current) { + animationRef.current.kill(); + } + }; + }, [value, duration, prefix, suffix, decimals, useLocaleString, delay]); + + return 0; +}; + +export default AnimatedNumber; diff --git a/src/pages/AdminSchedule.jsx b/src/pages/AdminSchedule.jsx index d8b2f87..ad02dcc 100644 --- a/src/pages/AdminSchedule.jsx +++ b/src/pages/AdminSchedule.jsx @@ -1,82 +1,362 @@ -import React from 'react'; +import React, { useState, useMemo, useEffect, useRef } from 'react'; import { useMockStore } from '../data/mockStore'; -import { useAuth } from '../context/AuthContext'; -import { Calendar, User, Clock, MapPin, MoreHorizontal } from 'lucide-react'; +import { useTheme } from '../context/ThemeContext'; +import { Calendar, User, Clock, MapPin, MoreHorizontal, Filter, X, ChevronDown } from 'lucide-react'; const AdminSchedule = () => { const { meetings } = useMockStore(); - // In a real app we would have a function to update meetings here + const { theme } = useTheme(); - // Group meetings by agent for display - const meetingsByAgent = meetings.reduce((acc, meeting) => { - const agentId = meeting.agentId; - if (!acc[agentId]) acc[agentId] = []; - acc[agentId].push(meeting); - return acc; - }, {}); + // Refs for click-outside detection + const dateDropdownRef = useRef(null); + const statusDropdownRef = useRef(null); + + // Filter states + const [dateFilter, setDateFilter] = useState('all'); // all, today, week, month, quarter, custom + const [customDateRange, setCustomDateRange] = useState({ start: '', end: '' }); + const [statusFilter, setStatusFilter] = useState([]); + const [showDateDropdown, setShowDateDropdown] = useState(false); + const [showStatusDropdown, setShowStatusDropdown] = useState(false); + const [openDropdownId, setOpenDropdownId] = useState(null); + + // Click-outside detection for filter dropdowns + useEffect(() => { + const handleClickOutside = (event) => { + if (dateDropdownRef.current && !dateDropdownRef.current.contains(event.target)) { + setShowDateDropdown(false); + } + if (statusDropdownRef.current && !statusDropdownRef.current.contains(event.target)) { + setShowStatusDropdown(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Click-outside detection for action dropdowns + useEffect(() => { + const handleClickOutside = (event) => { + if (openDropdownId && !event.target.closest('.action-dropdown-container')) { + setOpenDropdownId(null); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [openDropdownId]); + + // Get unique statuses from meetings + const availableStatuses = useMemo(() => { + return [...new Set(meetings.map(m => m.status))]; + }, [meetings]); + + // Filter meetings based on selected filters + const filteredMeetings = useMemo(() => { + let filtered = [...meetings]; + + // Date filtering + if (dateFilter !== 'all') { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + filtered = filtered.filter(meeting => { + const meetingDate = new Date(meeting.date); + + switch (dateFilter) { + case 'today': + return meetingDate.toDateString() === today.toDateString(); + case 'week': + const weekStart = new Date(today); + weekStart.setDate(today.getDate() - today.getDay()); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekStart.getDate() + 6); + return meetingDate >= weekStart && meetingDate <= weekEnd; + case 'month': + return meetingDate.getMonth() === now.getMonth() && + meetingDate.getFullYear() === now.getFullYear(); + case 'quarter': + const quarter = Math.floor(now.getMonth() / 3); + const meetingQuarter = Math.floor(meetingDate.getMonth() / 3); + return meetingQuarter === quarter && + meetingDate.getFullYear() === now.getFullYear(); + case 'custom': + if (customDateRange.start && customDateRange.end) { + const start = new Date(customDateRange.start); + const end = new Date(customDateRange.end); + return meetingDate >= start && meetingDate <= end; + } + return true; + default: + return true; + } + }); + } + + // Status filtering + if (statusFilter.length > 0) { + filtered = filtered.filter(meeting => statusFilter.includes(meeting.status)); + } + + return filtered; + }, [meetings, dateFilter, customDateRange, statusFilter]); + + const toggleStatusFilter = (status) => { + setStatusFilter(prev => + prev.includes(status) + ? prev.filter(s => s !== status) + : [...prev, status] + ); + }; + + const clearFilters = () => { + setDateFilter('all'); + setCustomDateRange({ start: '', end: '' }); + setStatusFilter([]); + }; + + const handleActionClick = (meetingId, action) => { + console.log(`Action ${action} for meeting ${meetingId}`); + // TODO: Implement actual actions + setOpenDropdownId(null); + }; return (
Manage field agent appointments
+Manage field agent appointments
Custom Range
+ setCustomDateRange(prev => ({ ...prev, start: e.target.value }))} + className="w-full px-2 py-1 text-xs bg-white dark:bg-slate-900 border border-zinc-200 dark:border-slate-600 rounded mb-2 text-zinc-900 dark:text-white" + placeholder="Start date" + /> + setCustomDateRange(prev => ({ ...prev, end: e.target.value }))} + className="w-full px-2 py-1 text-xs bg-white dark:bg-slate-900 border border-zinc-200 dark:border-slate-600 rounded mb-2 text-zinc-900 dark:text-white" + placeholder="End date" + /> + +| Agent | -Customer | -Property | -Date & Time | -Status | -Actions | +Agent | +Customer | +Property | +Date & Time | +Status | +Actions |
|---|---|---|---|---|---|---|---|---|---|---|---|
|
-
-
-
- {meeting.agentId}
-
- Agent {meeting.agentId}
- |
- {meeting.customerName} | -{meeting.propertyId} | -
-
- {meeting.date}
- {meeting.time}
-
- |
- - - {meeting.status} - - | -- - + | ||||||
| + No appointments found matching the selected filters. | |||||||||||
|
+
+
+
+ {meeting.agentId}
+
+ Agent {meeting.agentId}
+ |
+ {meeting.customerName} | +{meeting.propertyId} | +
+
+ {meeting.date}
+ {meeting.time}
+
+ |
+ + + {meeting.status} + + | +
+
+
+
+
+
+
+ {openDropdownId === meeting.id && (
+
+
+
+
+
+
+
+ )}
+ |
+ ||||||
{title}
@@ -452,14 +455,14 @@ const RealWeatherWidget = ({ weather, forecast, loading, error, lastUpdated, onR
- {formatValue(agent[metric], metric)}
+ {metric === 'revenue' &&