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 { 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 { 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() {
|
||||
const [logs, setLogs] = useState<any[]>([]);
|
||||
// Types
|
||||
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 [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(() => {
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get('/timeclock/logs');
|
||||
setLogs(data);
|
||||
const active = data.find((l: any) => !l.endTime);
|
||||
const active = data.find((l: TimeLog) => !l.endTime);
|
||||
setStatus(active ? 'CLOCKED_IN' : 'CLOCKED_OUT');
|
||||
setCurrentSession(active || null);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
|
|
@ -27,117 +85,292 @@ export default function TimeclockPage() {
|
|||
};
|
||||
|
||||
const handleClock = async (action: 'in' | 'out') => {
|
||||
setClockLoading(true);
|
||||
try {
|
||||
if (action === 'in') {
|
||||
await api.post('/timeclock/clock-in', { activityType: 'General' });
|
||||
await api.post('/timeclock/clock-in', {
|
||||
activityType: 'General',
|
||||
method: activeMethod
|
||||
});
|
||||
} else {
|
||||
await api.post('/timeclock/clock-out', {});
|
||||
}
|
||||
await fetchLogs();
|
||||
} 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 (
|
||||
<div className="max-w-2xl mx-auto space-y-6 animate-in">
|
||||
<PageHeader
|
||||
title="Time Clock"
|
||||
subtitle={new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
|
||||
/>
|
||||
|
||||
{/* Status Card */}
|
||||
<div className="card p-8 text-center">
|
||||
<div className="mb-6">
|
||||
<span className="text-xs font-medium text-tertiary uppercase tracking-wider">
|
||||
Current Status
|
||||
<div className="space-y-8 pb-12">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-[var(--color-text-primary)]">
|
||||
Shift Tracking
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<span className="text-[var(--color-text-tertiary)] text-sm">
|
||||
{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
|
||||
</span>
|
||||
<div className={`
|
||||
text-xl font-semibold mt-2
|
||||
${status === 'CLOCKED_IN' ? 'text-success' : 'text-secondary'}
|
||||
`}>
|
||||
{status === 'CLOCKED_IN' ? 'Clocked In' : 'Clocked Out'}
|
||||
<div className={cn(
|
||||
"flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider border",
|
||||
status === 'CLOCKED_IN'
|
||||
? "bg-[var(--color-primary)]/10 text-[var(--color-primary)] border-[var(--color-primary)]/20"
|
||||
: "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 className="flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => handleClock('in')}
|
||||
disabled={status === 'CLOCKED_IN'}
|
||||
className={`
|
||||
w-32 h-32 rounded-full font-semibold text-sm flex flex-col items-center justify-center gap-2
|
||||
transition-all duration-normal
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
${status !== 'CLOCKED_IN'
|
||||
? 'bg-success text-white hover:scale-105 shadow-lg'
|
||||
: 'bg-tertiary text-tertiary'
|
||||
}
|
||||
`}
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Today"
|
||||
value={formatHours(stats.hoursToday) + 'h'}
|
||||
icon={Timer}
|
||||
active={status === 'CLOCKED_IN'}
|
||||
/>
|
||||
<StatCard
|
||||
label="This Week"
|
||||
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} />
|
||||
Clock In
|
||||
<p className="text-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-2">
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Clock Action Button */}
|
||||
<button
|
||||
onClick={() => handleClock('out')}
|
||||
disabled={status === 'CLOCKED_OUT'}
|
||||
className={`
|
||||
w-32 h-32 rounded-full font-semibold text-sm flex flex-col items-center justify-center gap-2
|
||||
transition-all duration-normal
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
${status === 'CLOCKED_IN'
|
||||
? 'bg-destructive text-white hover:scale-105 shadow-lg'
|
||||
: 'bg-tertiary text-tertiary'
|
||||
}
|
||||
`}
|
||||
onClick={() => handleClock(status === 'CLOCKED_IN' ? 'out' : 'in')}
|
||||
disabled={clockLoading}
|
||||
className={cn(
|
||||
"w-full h-16 rounded-2xl font-bold text-lg flex items-center justify-center gap-3 transition-all shadow-lg",
|
||||
status === 'CLOCKED_IN'
|
||||
? "bg-[var(--color-error)] hover:bg-[var(--color-error)]/80 text-white"
|
||||
: "bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-[var(--color-text-inverse)]"
|
||||
)}
|
||||
>
|
||||
<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
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn size={22} />
|
||||
Clock In
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-4 border-b border-subtle flex items-center gap-2">
|
||||
<Clock size={16} className="text-tertiary" />
|
||||
<h3 className="text-sm font-medium text-primary">Recent Logs</h3>
|
||||
{/* Right: History */}
|
||||
<div className="lg:col-span-7">
|
||||
<Card className="bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)] overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[var(--color-border-subtle)] flex items-center justify-between">
|
||||
<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>
|
||||
|
||||
{loading ? (
|
||||
<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>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="p-8 text-center text-tertiary text-sm">
|
||||
No time logs yet
|
||||
<div className="p-12 text-center">
|
||||
<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 className="divide-y divide-subtle">
|
||||
{logs.slice(0, 10).map(log => (
|
||||
<div key={log.id} className="flex items-center justify-between p-4 hover:bg-tertiary transition-colors duration-fast">
|
||||
<div>
|
||||
<div className="text-sm text-primary font-medium">
|
||||
{new Date(log.startTime).toLocaleDateString()}
|
||||
<div className="divide-y divide-[var(--color-border-subtle)]">
|
||||
<AnimatePresence>
|
||||
{logs.slice(0, 8).map((log, idx) => (
|
||||
<motion.div
|
||||
key={log.id}
|
||||
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 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.method && log.method !== 'manual' && (
|
||||
<span className="ml-2 text-[var(--color-accent)]">• {log.method.toUpperCase()}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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' })}
|
||||
{' → '}
|
||||
{log.endTime
|
||||
? 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" />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</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