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
This commit is contained in:
@@ -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 <span ref={numberRef} className={className}>0</span>;
|
||||
};
|
||||
|
||||
export default AnimatedNumber;
|
||||
+319
-39
@@ -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 (
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Team Schedule</h1>
|
||||
<p className="text-slate-400">Manage field agent appointments</p>
|
||||
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white mb-2">Team Schedule</h1>
|
||||
<p className="text-zinc-600 dark:text-slate-400">Manage field agent appointments</p>
|
||||
</header>
|
||||
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl shadow-xl overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-800">
|
||||
<h2 className="text-xl font-bold text-white flex items-center">
|
||||
{/* Filters Section */}
|
||||
<div className="mb-6 flex flex-wrap gap-4 items-center">
|
||||
{/* Date Filter */}
|
||||
<div className="relative" ref={dateDropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowDateDropdown(!showDateDropdown)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-slate-800 border border-zinc-200 dark:border-slate-700 rounded-lg text-sm font-medium text-zinc-700 dark:text-slate-200 hover:bg-zinc-50 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<Calendar size={16} />
|
||||
{dateFilter === 'all' ? 'All Dates' :
|
||||
dateFilter === 'today' ? 'Today' :
|
||||
dateFilter === 'week' ? 'This Week' :
|
||||
dateFilter === 'month' ? 'This Month' :
|
||||
dateFilter === 'quarter' ? 'This Quarter' :
|
||||
'Custom Range'}
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
|
||||
{showDateDropdown && (
|
||||
<div className="absolute top-full mt-2 left-0 bg-white dark:bg-slate-800 border border-zinc-200 dark:border-slate-700 rounded-lg shadow-xl z-50 min-w-[200px]">
|
||||
<button
|
||||
onClick={() => { setDateFilter('all'); setShowDateDropdown(false); }}
|
||||
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
All Dates
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setDateFilter('today'); setShowDateDropdown(false); }}
|
||||
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setDateFilter('week'); setShowDateDropdown(false); }}
|
||||
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
This Week
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setDateFilter('month'); setShowDateDropdown(false); }}
|
||||
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
This Month
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setDateFilter('quarter'); setShowDateDropdown(false); }}
|
||||
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
This Quarter
|
||||
</button>
|
||||
<div className="border-t border-zinc-200 dark:border-slate-700 p-3">
|
||||
<p className="text-xs font-semibold text-zinc-500 dark:text-slate-400 mb-2">Custom Range</p>
|
||||
<input
|
||||
type="date"
|
||||
value={customDateRange.start}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={customDateRange.end}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={() => { setDateFilter('custom'); setShowDateDropdown(false); }}
|
||||
disabled={!customDateRange.start || !customDateRange.end}
|
||||
className="w-full px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="relative" ref={statusDropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowStatusDropdown(!showStatusDropdown)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-slate-800 border border-zinc-200 dark:border-slate-700 rounded-lg text-sm font-medium text-zinc-700 dark:text-slate-200 hover:bg-zinc-50 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<Filter size={16} />
|
||||
Status {statusFilter.length > 0 && `(${statusFilter.length})`}
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
|
||||
{showStatusDropdown && (
|
||||
<div className="absolute top-full mt-2 left-0 bg-white dark:bg-slate-800 border border-zinc-200 dark:border-slate-700 rounded-lg shadow-xl z-50 min-w-[200px]">
|
||||
{availableStatuses.map(status => (
|
||||
<label
|
||||
key={status}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 cursor-pointer transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={statusFilter.includes(status)}
|
||||
onChange={() => toggleStatusFilter(status)}
|
||||
className="rounded border-zinc-300 dark:border-slate-600"
|
||||
/>
|
||||
{status}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{(dateFilter !== 'all' || statusFilter.length > 0) && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Results Count */}
|
||||
<div className="ml-auto text-sm text-zinc-600 dark:text-slate-400">
|
||||
Showing {filteredMeetings.length} of {meetings.length} appointments
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-900 border border-zinc-200 dark:border-slate-800 rounded-3xl shadow-xl overflow-hidden">
|
||||
<div className="p-6 border-b border-zinc-200 dark:border-slate-800">
|
||||
<h2 className="text-xl font-bold text-zinc-900 dark:text-white flex items-center">
|
||||
<Calendar size={20} className="text-blue-500 mr-2" />
|
||||
All Active Appointments
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm text-slate-400">
|
||||
<thead className="bg-slate-950 text-slate-200 uppercase font-bold text-xs">
|
||||
<table className="w-full text-left text-sm text-zinc-600 dark:text-slate-400">
|
||||
<thead className="bg-zinc-100 dark:bg-slate-950 text-zinc-700 dark:text-slate-200 uppercase font-bold text-xs">
|
||||
<tr>
|
||||
<th className="px-6 py-4">Agent</th>
|
||||
<th className="px-6 py-4">Customer</th>
|
||||
<th className="px-6 py-4">Property</th>
|
||||
<th className="px-6 py-4">Date & Time</th>
|
||||
<th className="px-6 py-4">Status</th>
|
||||
<th className="px-6 py-4 text-right">Actions</th>
|
||||
<th className="px-6 py-4 min-w-[150px]">Agent</th>
|
||||
<th className="px-6 py-4 min-w-[180px]">Customer</th>
|
||||
<th className="px-6 py-4 min-w-[120px]">Property</th>
|
||||
<th className="px-6 py-4 min-w-[160px]">Date & Time</th>
|
||||
<th className="px-6 py-4 min-w-[120px]">Status</th>
|
||||
<th className="px-6 py-4 text-right min-w-[150px]">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{meetings.map((meeting) => (
|
||||
<tr key={meeting.id} className="hover:bg-slate-800/50 transition-colors">
|
||||
<tbody className="divide-y divide-zinc-200 dark:divide-slate-800">
|
||||
{filteredMeetings.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="6" className="px-6 py-12 text-center text-zinc-500 dark:text-slate-400">
|
||||
No appointments found matching the selected filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredMeetings.map((meeting) => (
|
||||
<tr key={meeting.id} className="hover:bg-zinc-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-700 flex items-center justify-center text-slate-300 font-bold text-xs">
|
||||
<div className="w-8 h-8 rounded-full bg-zinc-200 dark:bg-slate-700 flex items-center justify-center text-zinc-700 dark:text-slate-300 font-bold text-xs">
|
||||
{meeting.agentId}
|
||||
</div>
|
||||
<span className="font-medium text-slate-200">Agent {meeting.agentId}</span>
|
||||
<span className="font-medium text-zinc-900 dark:text-slate-200 whitespace-normal">Agent {meeting.agentId}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-slate-300">{meeting.customerName}</td>
|
||||
<td className="px-6 py-4">{meeting.propertyId}</td>
|
||||
<td className="px-6 py-4 text-zinc-900 dark:text-slate-300 whitespace-normal">{meeting.customerName}</td>
|
||||
<td className="px-6 py-4 text-zinc-700 dark:text-slate-400">{meeting.propertyId}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-slate-200">{meeting.date}</span>
|
||||
<span className="text-xs">{meeting.time}</span>
|
||||
<span className="text-zinc-900 dark:text-slate-200">{meeting.date}</span>
|
||||
<span className="text-xs text-zinc-600 dark:text-slate-400">{meeting.time}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-1 text-xs font-semibold rounded ${meeting.status === 'Completed' ? 'bg-green-500/20 text-green-400' :
|
||||
meeting.status === 'Scheduled' ? 'bg-blue-500/20 text-blue-400' :
|
||||
'bg-yellow-500/20 text-yellow-400'
|
||||
<span className={`px-2 py-1 text-xs font-semibold rounded whitespace-nowrap ${meeting.status === 'Completed' ? 'bg-green-500/20 text-green-600 dark:text-green-400' :
|
||||
meeting.status === 'Scheduled' ? 'bg-blue-500/20 text-blue-600 dark:text-blue-400' :
|
||||
meeting.status === 'Converted' ? 'bg-purple-500/20 text-purple-600 dark:text-purple-400' :
|
||||
meeting.status === 'Cancelled' ? 'bg-red-500/20 text-red-600 dark:text-red-400' :
|
||||
'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400'
|
||||
}`}>
|
||||
{meeting.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button className="text-blue-400 hover:text-blue-300 text-xs font-bold mr-3">Reschedule</button>
|
||||
<button className="text-slate-600 hover:text-slate-400">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 text-xs font-bold">
|
||||
Reschedule
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpenDropdownId(openDropdownId === meeting.id ? null : meeting.id)}
|
||||
className="text-zinc-600 dark:text-slate-400 hover:text-zinc-900 dark:hover:text-slate-200 transition-colors"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
|
||||
{openDropdownId === meeting.id && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-white dark:bg-slate-800 border border-zinc-200 dark:border-slate-700 rounded-lg shadow-xl z-50 min-w-[160px]">
|
||||
<button
|
||||
onClick={() => handleActionClick(meeting.id, 'view')}
|
||||
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleActionClick(meeting.id, 'reschedule')}
|
||||
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
Reschedule
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleActionClick(meeting.id, 'complete')}
|
||||
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
Mark Complete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleActionClick(meeting.id, 'cancel')}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -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 }) =>
|
||||
<span className={`text-[10px] uppercase font-bold tracking-wider ${trendColor} bg-zinc-100 dark:bg-white/5 px-2.5 py-1 rounded-full border border-zinc-200 dark:border-white/5 backdrop-blur-md`}>{trend}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-3xl font-extrabold text-zinc-900 dark:text-white mb-2 tracking-tight drop-shadow-sm">{value}</h3>
|
||||
<h3 className="text-3xl font-extrabold text-zinc-900 dark:text-white mb-2 tracking-tight drop-shadow-sm">
|
||||
<AnimatedNumber value={value} duration={1.5} useLocaleString={typeof value === 'number'} />
|
||||
</h3>
|
||||
<p className="text-zinc-500 dark:text-zinc-500 text-xs font-semibold uppercase tracking-widest">{title}</p>
|
||||
</div>
|
||||
|
||||
@@ -432,7 +435,7 @@ const RealWeatherWidget = ({ weather, forecast, loading, error, lastUpdated, onR
|
||||
</div>
|
||||
|
||||
<h2 className="text-5xl font-black tracking-tighter text-transparent bg-clip-text bg-gradient-to-b from-zinc-800 to-zinc-400 dark:from-white dark:to-white/50 flex items-start">
|
||||
{Math.round(weather.main.temp)}
|
||||
<AnimatedNumber value={Math.round(weather.main.temp)} duration={1.8} />
|
||||
<span className="text-2xl text-zinc-400 dark:text-white/50 mt-2 font-bold tracking-normal">°F</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-zinc-400 mt-1 font-mono">
|
||||
@@ -452,14 +455,14 @@ const RealWeatherWidget = ({ weather, forecast, loading, error, lastUpdated, onR
|
||||
<Wind size={14} className="mr-2" />
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider">Wind</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold">{Math.round(weather.wind.speed)} <span className="text-sm font-normal text-zinc-500">mph</span></span>
|
||||
<span className="text-2xl font-bold"><AnimatedNumber value={Math.round(weather.wind.speed)} duration={1.6} /> <span className="text-sm font-normal text-zinc-500">mph</span></span>
|
||||
</div>
|
||||
<div className="bg-zinc-100 dark:bg-white/5 rounded-2xl p-4 border border-zinc-200 dark:border-white/5 backdrop-blur-sm">
|
||||
<div className="flex items-center text-zinc-500 dark:text-zinc-400 mb-2">
|
||||
<Droplets size={14} className="mr-2" />
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider">Humidity</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold">{weather.main.humidity}<span className="text-sm font-normal text-zinc-500">%</span></span>
|
||||
<span className="text-2xl font-bold"><AnimatedNumber value={weather.main.humidity} duration={1.6} /><span className="text-sm font-normal text-zinc-500">%</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -475,7 +478,7 @@ const RealWeatherWidget = ({ weather, forecast, loading, error, lastUpdated, onR
|
||||
<div className="my-2 opacity-70 group-hover:opacity-100 transition-opacity">
|
||||
{getMiniIcon(item.weather[0].id)}
|
||||
</div>
|
||||
<span className="text-sm font-bold text-zinc-700 dark:text-zinc-300">{Math.round(item.main.temp)}°</span>
|
||||
<span className="text-sm font-bold text-zinc-700 dark:text-zinc-300"><AnimatedNumber value={Math.round(item.main.temp)} duration={1.4} delay={index * 0.15} />°</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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 = () => {
|
||||
</div>
|
||||
<h3 className="font-bold text-sm md:text-base text-zinc-900 dark:text-white truncate max-w-[100px]">{agent.name}</h3>
|
||||
<p className={`font-mono font-bold text-sm md:text-lg ${color.replace('border-', 'text-')}`}>
|
||||
{formatValue(agent[metric], metric)}
|
||||
{metric === 'revenue' && <AnimatedNumber value={agent[metric]} prefix="$" useLocaleString duration={1.5} delay={rank * 0.2} />}
|
||||
{metric === 'volume' && <><AnimatedNumber value={agent[metric]} duration={1.5} delay={rank * 0.2} /> Deals</>}
|
||||
{metric === 'winRate' && <><AnimatedNumber value={agent[metric]} decimals={1} duration={1.5} delay={rank * 0.2} />%</>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -203,13 +206,13 @@ const LeaderboardPage = () => {
|
||||
</div>
|
||||
</td>
|
||||
<td className={`py-4 text-right font-mono font-medium ${metric === 'revenue' ? 'text-blue-600 dark:text-blue-400 font-bold' : 'text-zinc-500 dark:text-zinc-400'}`}>
|
||||
{formatValue(agent.revenue, 'revenue')}
|
||||
<AnimatedNumber value={agent.revenue} prefix="$" useLocaleString duration={1.2} delay={index * 0.05} />
|
||||
</td>
|
||||
<td className={`py-4 text-right font-mono font-medium ${metric === 'volume' ? 'text-blue-600 dark:text-blue-400 font-bold' : 'text-zinc-500 dark:text-zinc-400'}`}>
|
||||
{formatValue(agent.volume, 'volume')}
|
||||
<AnimatedNumber value={agent.volume} duration={1.2} delay={index * 0.05} /> Deals
|
||||
</td>
|
||||
<td className={`py-4 text-right font-mono font-medium ${metric === 'winRate' ? 'text-blue-600 dark:text-blue-400 font-bold' : 'text-zinc-500 dark:text-zinc-400'}`}>
|
||||
{formatValue(agent.winRate, 'winRate')}
|
||||
<AnimatedNumber value={agent.winRate} decimals={1} duration={1.2} delay={index * 0.05} />%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user