feat: Redesign TimeClockPage as robust shift tracking system
- Add live session timer with active duration display - Add stats grid: today, this week, shifts, average - Scaffold clock-in methods: manual (active), QR/NFC/GPS (coming soon) - Modern shift history with method tracking - Full CSS variable theming - Framer Motion animations
This commit is contained in:
parent
4b37e9fa84
commit
22ed334fb3
1 changed files with 330 additions and 97 deletions
|
|
@ -1,24 +1,82 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Clock, LogIn, LogOut } from 'lucide-react';
|
import {
|
||||||
|
Clock, LogIn, LogOut, QrCode, Smartphone, MapPin,
|
||||||
|
Timer, CalendarDays, Users, TrendingUp, ChevronRight,
|
||||||
|
CheckCircle2, AlertCircle, MoreHorizontal, History
|
||||||
|
} from 'lucide-react';
|
||||||
import api from '../lib/api';
|
import api from '../lib/api';
|
||||||
import { PageHeader } from '../components/ui/LinearPrimitives';
|
import { Card } from '../components/ui/card';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
export default function TimeclockPage() {
|
// Types
|
||||||
const [logs, setLogs] = useState<any[]>([]);
|
interface TimeLog {
|
||||||
|
id: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime?: string;
|
||||||
|
activityType: string;
|
||||||
|
method?: 'manual' | 'qr' | 'nfc' | 'gps';
|
||||||
|
location?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShiftStats {
|
||||||
|
hoursToday: number;
|
||||||
|
hoursThisWeek: number;
|
||||||
|
shiftsThisWeek: number;
|
||||||
|
averageShift: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clock-in method types (scaffolded)
|
||||||
|
type ClockMethod = 'manual' | 'qr' | 'nfc' | 'gps';
|
||||||
|
|
||||||
|
const CLOCK_METHODS: { id: ClockMethod; label: string; icon: any; available: boolean; description: string }[] = [
|
||||||
|
{ id: 'manual', label: 'Manual', icon: Clock, available: true, description: 'Standard clock in/out' },
|
||||||
|
{ id: 'qr', label: 'QR Code', icon: QrCode, available: false, description: 'Scan facility QR code' },
|
||||||
|
{ id: 'nfc', label: 'NFC Tap', icon: Smartphone, available: false, description: 'Tap your badge' },
|
||||||
|
{ id: 'gps', label: 'Location', icon: MapPin, available: false, description: 'Verify by location' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TimeClockPage() {
|
||||||
|
const [logs, setLogs] = useState<TimeLog[]>([]);
|
||||||
const [status, setStatus] = useState<'CLOCKED_OUT' | 'CLOCKED_IN'>('CLOCKED_OUT');
|
const [status, setStatus] = useState<'CLOCKED_OUT' | 'CLOCKED_IN'>('CLOCKED_OUT');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [clockLoading, setClockLoading] = useState(false);
|
||||||
|
const [activeMethod, setActiveMethod] = useState<ClockMethod>('manual');
|
||||||
|
const [currentSession, setCurrentSession] = useState<TimeLog | null>(null);
|
||||||
|
const [elapsedTime, setElapsedTime] = useState(0);
|
||||||
|
|
||||||
|
// Stats (mock - would come from API)
|
||||||
|
const stats: ShiftStats = {
|
||||||
|
hoursToday: currentSession ? elapsedTime / 3600 : 0,
|
||||||
|
hoursThisWeek: 32.5,
|
||||||
|
shiftsThisWeek: 4,
|
||||||
|
averageShift: 8.1
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLogs();
|
fetchLogs();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Timer for active session
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentSession && !currentSession.endTime) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const start = new Date(currentSession.startTime).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
setElapsedTime(Math.floor((now - start) / 1000));
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [currentSession]);
|
||||||
|
|
||||||
const fetchLogs = async () => {
|
const fetchLogs = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/timeclock/logs');
|
const { data } = await api.get('/timeclock/logs');
|
||||||
setLogs(data);
|
setLogs(data);
|
||||||
const active = data.find((l: any) => !l.endTime);
|
const active = data.find((l: TimeLog) => !l.endTime);
|
||||||
setStatus(active ? 'CLOCKED_IN' : 'CLOCKED_OUT');
|
setStatus(active ? 'CLOCKED_IN' : 'CLOCKED_OUT');
|
||||||
|
setCurrentSession(active || null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -27,117 +85,292 @@ export default function TimeclockPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClock = async (action: 'in' | 'out') => {
|
const handleClock = async (action: 'in' | 'out') => {
|
||||||
|
setClockLoading(true);
|
||||||
try {
|
try {
|
||||||
if (action === 'in') {
|
if (action === 'in') {
|
||||||
await api.post('/timeclock/clock-in', { activityType: 'General' });
|
await api.post('/timeclock/clock-in', {
|
||||||
|
activityType: 'General',
|
||||||
|
method: activeMethod
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await api.post('/timeclock/clock-out', {});
|
await api.post('/timeclock/clock-out', {});
|
||||||
}
|
}
|
||||||
await fetchLogs();
|
await fetchLogs();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.response?.data?.message || 'Error clocking');
|
console.error(e.response?.data?.message || 'Error clocking');
|
||||||
|
} finally {
|
||||||
|
setClockLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatHours = (hours: number) => hours.toFixed(1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto space-y-6 animate-in">
|
<div className="space-y-8 pb-12">
|
||||||
<PageHeader
|
{/* Header */}
|
||||||
title="Time Clock"
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||||
subtitle={new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
|
<div>
|
||||||
/>
|
<h1 className="text-2xl font-bold tracking-tight text-[var(--color-text-primary)]">
|
||||||
|
Shift Tracking
|
||||||
{/* Status Card */}
|
</h1>
|
||||||
<div className="card p-8 text-center">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
<div className="mb-6">
|
<span className="text-[var(--color-text-tertiary)] text-sm">
|
||||||
<span className="text-xs font-medium text-tertiary uppercase tracking-wider">
|
{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
|
||||||
Current Status
|
|
||||||
</span>
|
</span>
|
||||||
<div className={`
|
<div className={cn(
|
||||||
text-xl font-semibold mt-2
|
"flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider border",
|
||||||
${status === 'CLOCKED_IN' ? 'text-success' : 'text-secondary'}
|
status === 'CLOCKED_IN'
|
||||||
`}>
|
? "bg-[var(--color-primary)]/10 text-[var(--color-primary)] border-[var(--color-primary)]/20"
|
||||||
{status === 'CLOCKED_IN' ? 'Clocked In' : 'Clocked Out'}
|
: "bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)] border-[var(--color-border-subtle)]"
|
||||||
|
)}>
|
||||||
|
<div className={cn(
|
||||||
|
"w-1.5 h-1.5 rounded-full",
|
||||||
|
status === 'CLOCKED_IN' ? "bg-[var(--color-primary)] animate-pulse" : "bg-[var(--color-text-tertiary)]"
|
||||||
|
)} />
|
||||||
|
{status === 'CLOCKED_IN' ? 'On Shift' : 'Off Shift'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 justify-center">
|
{/* Stats Grid */}
|
||||||
<button
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
onClick={() => handleClock('in')}
|
<StatCard
|
||||||
disabled={status === 'CLOCKED_IN'}
|
label="Today"
|
||||||
className={`
|
value={formatHours(stats.hoursToday) + 'h'}
|
||||||
w-32 h-32 rounded-full font-semibold text-sm flex flex-col items-center justify-center gap-2
|
icon={Timer}
|
||||||
transition-all duration-normal
|
active={status === 'CLOCKED_IN'}
|
||||||
disabled:opacity-40 disabled:cursor-not-allowed
|
/>
|
||||||
${status !== 'CLOCKED_IN'
|
<StatCard
|
||||||
? 'bg-success text-white hover:scale-105 shadow-lg'
|
label="This Week"
|
||||||
: 'bg-tertiary text-tertiary'
|
value={formatHours(stats.hoursThisWeek) + 'h'}
|
||||||
}
|
icon={CalendarDays}
|
||||||
`}
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Shifts"
|
||||||
|
value={stats.shiftsThisWeek.toString()}
|
||||||
|
icon={Users}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Avg Shift"
|
||||||
|
value={formatHours(stats.averageShift) + 'h'}
|
||||||
|
icon={TrendingUp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
|
{/* Left: Clock Controls */}
|
||||||
|
<div className="lg:col-span-5 space-y-6">
|
||||||
|
{/* Active Session Timer */}
|
||||||
|
<Card className="p-8 bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)] text-center">
|
||||||
|
{status === 'CLOCKED_IN' && currentSession ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
>
|
>
|
||||||
<LogIn size={24} />
|
<p className="text-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-2">
|
||||||
Clock In
|
Current Session
|
||||||
|
</p>
|
||||||
|
<div className="text-5xl font-mono font-bold text-[var(--color-primary)] tracking-tight mb-4">
|
||||||
|
{formatDuration(elapsedTime)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--color-text-tertiary)]">
|
||||||
|
Started {new Date(currentSession.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-2">
|
||||||
|
Ready to Clock In
|
||||||
|
</p>
|
||||||
|
<div className="text-5xl font-mono font-bold text-[var(--color-text-quaternary)] tracking-tight mb-4">
|
||||||
|
00:00:00
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--color-text-tertiary)]">
|
||||||
|
Select a method below
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Clock Method Selector */}
|
||||||
|
<Card className="p-4 bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)]">
|
||||||
|
<p className="text-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-4 px-2">
|
||||||
|
Clock-In Method
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{CLOCK_METHODS.map(method => (
|
||||||
|
<button
|
||||||
|
key={method.id}
|
||||||
|
onClick={() => method.available && setActiveMethod(method.id)}
|
||||||
|
disabled={!method.available}
|
||||||
|
className={cn(
|
||||||
|
"p-4 rounded-xl text-left transition-all border",
|
||||||
|
method.available && activeMethod === method.id
|
||||||
|
? "bg-[var(--color-primary)]/10 border-[var(--color-primary)] text-[var(--color-primary)]"
|
||||||
|
: method.available
|
||||||
|
? "bg-[var(--color-bg-tertiary)] border-[var(--color-border-subtle)] text-[var(--color-text-secondary)] hover:border-[var(--color-primary)]/50"
|
||||||
|
: "bg-[var(--color-bg-tertiary)]/50 border-[var(--color-border-subtle)] text-[var(--color-text-quaternary)] cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<method.icon size={18} />
|
||||||
|
<span className="font-medium text-sm">{method.label}</span>
|
||||||
|
{!method.available && (
|
||||||
|
<span className="text-[8px] font-bold uppercase bg-[var(--color-accent)]/10 text-[var(--color-accent)] px-1.5 py-0.5 rounded">
|
||||||
|
Soon
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-[var(--color-text-tertiary)]">
|
||||||
|
{method.description}
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Clock Action Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleClock('out')}
|
onClick={() => handleClock(status === 'CLOCKED_IN' ? 'out' : 'in')}
|
||||||
disabled={status === 'CLOCKED_OUT'}
|
disabled={clockLoading}
|
||||||
className={`
|
className={cn(
|
||||||
w-32 h-32 rounded-full font-semibold text-sm flex flex-col items-center justify-center gap-2
|
"w-full h-16 rounded-2xl font-bold text-lg flex items-center justify-center gap-3 transition-all shadow-lg",
|
||||||
transition-all duration-normal
|
status === 'CLOCKED_IN'
|
||||||
disabled:opacity-40 disabled:cursor-not-allowed
|
? "bg-[var(--color-error)] hover:bg-[var(--color-error)]/80 text-white"
|
||||||
${status === 'CLOCKED_IN'
|
: "bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-[var(--color-text-inverse)]"
|
||||||
? 'bg-destructive text-white hover:scale-105 shadow-lg'
|
)}
|
||||||
: 'bg-tertiary text-tertiary'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<LogOut size={24} />
|
{clockLoading ? (
|
||||||
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
) : status === 'CLOCKED_IN' ? (
|
||||||
|
<>
|
||||||
|
<LogOut size={22} />
|
||||||
Clock Out
|
Clock Out
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogIn size={22} />
|
||||||
|
Clock In
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Logs Table */}
|
{/* Right: History */}
|
||||||
<div className="card overflow-hidden">
|
<div className="lg:col-span-7">
|
||||||
<div className="p-4 border-b border-subtle flex items-center gap-2">
|
<Card className="bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)] overflow-hidden">
|
||||||
<Clock size={16} className="text-tertiary" />
|
<div className="px-5 py-4 border-b border-[var(--color-border-subtle)] flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-primary">Recent Logs</h3>
|
<div className="flex items-center gap-2">
|
||||||
|
<History size={16} className="text-[var(--color-text-tertiary)]" />
|
||||||
|
<h3 className="font-medium text-[var(--color-text-primary)]">Recent Shifts</h3>
|
||||||
|
</div>
|
||||||
|
<button className="text-xs font-medium text-[var(--color-primary)] hover:underline">
|
||||||
|
View All
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<div className="skeleton w-8 h-8 rounded-full mx-auto" />
|
<div className="w-8 h-8 border-2 border-[var(--color-border-default)] border-t-[var(--color-primary)] rounded-full animate-spin mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
) : logs.length === 0 ? (
|
) : logs.length === 0 ? (
|
||||||
<div className="p-8 text-center text-tertiary text-sm">
|
<div className="p-12 text-center">
|
||||||
No time logs yet
|
<Clock size={32} className="mx-auto text-[var(--color-text-quaternary)] mb-3" />
|
||||||
|
<p className="text-sm text-[var(--color-text-tertiary)]">No shifts recorded yet</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-subtle">
|
<div className="divide-y divide-[var(--color-border-subtle)]">
|
||||||
{logs.slice(0, 10).map(log => (
|
<AnimatePresence>
|
||||||
<div key={log.id} className="flex items-center justify-between p-4 hover:bg-tertiary transition-colors duration-fast">
|
{logs.slice(0, 8).map((log, idx) => (
|
||||||
<div>
|
<motion.div
|
||||||
<div className="text-sm text-primary font-medium">
|
key={log.id}
|
||||||
{new Date(log.startTime).toLocaleDateString()}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: idx * 0.05 }}
|
||||||
|
className="flex items-center justify-between px-5 py-4 hover:bg-[var(--color-bg-tertiary)]/50 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={cn(
|
||||||
|
"w-10 h-10 rounded-xl flex items-center justify-center",
|
||||||
|
!log.endTime
|
||||||
|
? "bg-[var(--color-primary)]/10 text-[var(--color-primary)]"
|
||||||
|
: "bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)]"
|
||||||
|
)}>
|
||||||
|
{!log.endTime ? <CheckCircle2 size={18} /> : <Clock size={18} />}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-tertiary">
|
<div>
|
||||||
|
<p className="font-medium text-sm text-[var(--color-text-primary)]">
|
||||||
|
{new Date(log.startTime).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||||
{log.activityType}
|
{log.activityType}
|
||||||
|
{log.method && log.method !== 'manual' && (
|
||||||
|
<span className="ml-2 text-[var(--color-accent)]">• {log.method.toUpperCase()}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-sm text-primary">
|
<p className="font-mono text-sm text-[var(--color-text-primary)]">
|
||||||
{new Date(log.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
{new Date(log.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
{' → '}
|
{' → '}
|
||||||
{log.endTime
|
{log.endTime
|
||||||
? new Date(log.endTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
? new Date(log.endTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
: <span className="text-success">Active</span>
|
: <span className="text-[var(--color-primary)] font-bold">Active</span>
|
||||||
}
|
}
|
||||||
|
</p>
|
||||||
|
{log.endTime && (
|
||||||
|
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
{((new Date(log.endTime).getTime() - new Date(log.startTime).getTime()) / 3600000).toFixed(1)}h
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ChevronRight size={16} className="text-[var(--color-text-quaternary)] opacity-0 group-hover:opacity-100 transition-opacity ml-2" />
|
||||||
</div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, icon: Icon, active = false }: { label: string; value: string; icon: any; active?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Card className={cn(
|
||||||
|
"p-4 border-[var(--color-border-subtle)]",
|
||||||
|
active ? "bg-[var(--color-primary)]/5 border-[var(--color-primary)]/20" : "bg-[var(--color-bg-elevated)]"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider">{label}</p>
|
||||||
|
<p className={cn(
|
||||||
|
"text-2xl font-bold mt-1",
|
||||||
|
active ? "text-[var(--color-primary)]" : "text-[var(--color-text-primary)]"
|
||||||
|
)}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
"p-2.5 rounded-lg",
|
||||||
|
active
|
||||||
|
? "bg-[var(--color-primary)]/10 text-[var(--color-primary)]"
|
||||||
|
: "bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)]"
|
||||||
|
)}>
|
||||||
|
<Icon size={18} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue