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 (
-

Team Schedule

-

Manage field agent appointments

+

Team Schedule

+

Manage field agent appointments

-
-
-

+ {/* Filters Section */} +
+ {/* Date Filter */} +
+ + + {showDateDropdown && ( +
+ + + + + +
+

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" + /> + +
+
+ )} +
+ + {/* Status Filter */} +
+ + + {showStatusDropdown && ( +
+ {availableStatuses.map(status => ( + + ))} +
+ )} +
+ + {/* Clear Filters */} + {(dateFilter !== 'all' || statusFilter.length > 0) && ( + + )} + + {/* Results Count */} +
+ Showing {filteredMeetings.length} of {meetings.length} appointments +
+
+ +
+
+

All Active Appointments

- - +
+ - - - - - - + + + + + + - - {meetings.map((meeting) => ( - - - - - - - + {filteredMeetings.length === 0 ? ( + + - ))} + ) : ( + filteredMeetings.map((meeting) => ( + + + + + + + + + )) + )}
AgentCustomerPropertyDate & TimeStatusActionsAgentCustomerPropertyDate & TimeStatusActions
-
-
- {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 && ( +
+ + + + +
+ )} +
+
+
diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 5e23fbc..aff44aa 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -16,6 +16,7 @@ import { RevenueByNeighborhood } from '../components/dashboard/RevenueByNeighbor import { GoldenLeadsScatter } from '../components/dashboard/GoldenLeadsScatter'; import { ActionCenterWidget } from '../components/dashboard/ActionCenterWidget'; import { WeatherRiskGauge } from '../components/dashboard/WeatherRiskGauge'; +import AnimatedNumber from '../components/AnimatedNumber'; import Loader from '../components/Loader'; @@ -360,7 +361,9 @@ const MetricCard = ({ title, value, trend, icon: Icon, trendColor, bgGlow }) => {trend}
-

{value}

+

+ +

{title}

@@ -432,7 +435,7 @@ const RealWeatherWidget = ({ weather, forecast, loading, error, lastUpdated, onR

- {Math.round(weather.main.temp)} + °F

@@ -452,14 +455,14 @@ const RealWeatherWidget = ({ weather, forecast, loading, error, lastUpdated, onR Wind

- {Math.round(weather.wind.speed)} mph + mph
Humidity
- {weather.main.humidity}% + %
@@ -475,7 +478,7 @@ const RealWeatherWidget = ({ weather, forecast, loading, error, lastUpdated, onR
{getMiniIcon(item.weather[0].id)}
- {Math.round(item.main.temp)}° + ° ) })} diff --git a/src/pages/LeaderboardPage.jsx b/src/pages/LeaderboardPage.jsx index af2e4fc..6fa08da 100644 --- a/src/pages/LeaderboardPage.jsx +++ b/src/pages/LeaderboardPage.jsx @@ -2,6 +2,7 @@ import React, { useState, useMemo } from 'react'; import { useMockStore } from '../data/mockStore'; import { Trophy, TrendingUp, DollarSign, Target, Calendar, Medal, ArrowUpRight, Crown } from 'lucide-react'; import { SpotlightCard } from '../components/SpotlightCard'; +import AnimatedNumber from '../components/AnimatedNumber'; const LeaderboardPage = () => { const { users, salesHistory } = useMockStore(); @@ -82,7 +83,9 @@ const LeaderboardPage = () => {

{agent.name}

- {formatValue(agent[metric], metric)} + {metric === 'revenue' && } + {metric === 'volume' && <> Deals} + {metric === 'winRate' && <>%}

@@ -184,9 +187,9 @@ const LeaderboardPage = () => {
{index + 1}
@@ -203,13 +206,13 @@ const LeaderboardPage = () => { - {formatValue(agent.revenue, 'revenue')} + - {formatValue(agent.volume, 'volume')} + Deals - {formatValue(agent.winRate, 'winRate')} + % ))}