feat: Redesign TimeClockPage as robust shift tracking system
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

- 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:
fullsizemalt 2025-12-27 13:12:37 -08:00
parent 4b37e9fa84
commit 22ed334fb3

View file

@ -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>
);
}