191 lines
7.6 KiB
TypeScript
191 lines
7.6 KiB
TypeScript
'use client';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
import { useEffect, useState } from 'react';
|
|
import { useAuth } from '@/lib/auth-context';
|
|
import { attendanceApi } from '@/lib/api';
|
|
import { getDaysInMonth, monthNames } from '@/lib/utils';
|
|
import Topbar from '@/components/layout/Topbar';
|
|
|
|
const statusColors: Record<string, string> = {
|
|
present: 'bg-green-500',
|
|
absent: 'bg-red-400',
|
|
wfh: 'bg-blue-500',
|
|
half_day: 'bg-yellow-500',
|
|
holiday: 'bg-purple-500',
|
|
};
|
|
|
|
const statusLabels: Record<string, string> = {
|
|
present: 'Present',
|
|
absent: 'Absent',
|
|
wfh: 'WFH',
|
|
half_day: 'Half Day',
|
|
holiday: 'Holiday',
|
|
};
|
|
|
|
export default function AttendancePage() {
|
|
const { user } = useAuth();
|
|
const today = new Date();
|
|
const [year, setYear] = useState(today.getFullYear());
|
|
const [month, setMonth] = useState(today.getMonth() + 1);
|
|
const [records, setRecords] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [markedToday, setMarkedToday] = useState(false);
|
|
const [marking, setMarking] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
setLoading(true);
|
|
attendanceApi.getAll({ month, year }).then((res) => {
|
|
setRecords(res.data);
|
|
const todayStr = today.toISOString().split('T')[0];
|
|
setMarkedToday(res.data.some((r: any) => {
|
|
const rDate = new Date(r.date).toISOString().split('T')[0];
|
|
return rDate === todayStr;
|
|
}));
|
|
setLoading(false);
|
|
}).catch(() => setLoading(false));
|
|
}, [user, month, year]);
|
|
|
|
const handleMark = async () => {
|
|
setMarking(true);
|
|
try {
|
|
await attendanceApi.mark({ status: 'present' });
|
|
setMarkedToday(true);
|
|
const res = await attendanceApi.getAll({ month, year });
|
|
setRecords(res.data);
|
|
} catch (err: any) {
|
|
alert(err.response?.data?.message || 'Failed to mark attendance');
|
|
}
|
|
setMarking(false);
|
|
};
|
|
|
|
const getRecordForDay = (day: number) => {
|
|
return records.find((r) => {
|
|
const d = new Date(r.date);
|
|
return d.getDate() === day && d.getMonth() + 1 === month && d.getFullYear() === year;
|
|
});
|
|
};
|
|
|
|
const daysInMonth = getDaysInMonth(year, month);
|
|
const firstDay = new Date(year, month - 1, 1).getDay();
|
|
const summary = records.reduce((acc, r) => {
|
|
acc[r.status] = (acc[r.status] || 0) + 1;
|
|
return acc;
|
|
}, {} as Record<string, number>);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#F8FAFC]">
|
|
<Topbar title="Attendance" />
|
|
<div className="p-6">
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-100 p-5">
|
|
{/* Month Selector */}
|
|
<div className="flex items-center justify-between mb-5">
|
|
<h3 className="font-semibold text-gray-800">{monthNames[month]} {year}</h3>
|
|
<div className="flex gap-2">
|
|
<select
|
|
value={month}
|
|
onChange={(e) => setMonth(Number(e.target.value))}
|
|
className="text-sm border border-gray-300 rounded-lg px-2 py-1"
|
|
>
|
|
{monthNames.slice(1).map((m, i) => (
|
|
<option key={i + 1} value={i + 1}>{m}</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
value={year}
|
|
onChange={(e) => setYear(Number(e.target.value))}
|
|
className="text-sm border border-gray-300 rounded-lg px-2 py-1"
|
|
>
|
|
{[2023, 2024, 2025, 2026].map((y) => (
|
|
<option key={y} value={y}>{y}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Calendar */}
|
|
<div className="grid grid-cols-7 gap-1 mb-2">
|
|
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
|
|
<div key={d} className="text-center text-xs font-medium text-gray-400 py-2">{d}</div>
|
|
))}
|
|
</div>
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{Array.from({ length: firstDay }).map((_, i) => (
|
|
<div key={`empty-${i}`} />
|
|
))}
|
|
{Array.from({ length: daysInMonth }, (_, i) => i + 1).map((day) => {
|
|
const record = getRecordForDay(day);
|
|
const isToday = day === today.getDate() && month === today.getMonth() + 1 && year === today.getFullYear();
|
|
const isWeekend = new Date(year, month - 1, day).getDay() === 0 || new Date(year, month - 1, day).getDay() === 6;
|
|
return (
|
|
<div
|
|
key={day}
|
|
className={`aspect-square rounded-lg flex items-center justify-center text-xs font-medium relative
|
|
${isToday ? 'ring-2 ring-indigo-500' : ''}
|
|
${record ? statusColors[record.status] + ' text-white' : isWeekend ? 'bg-gray-50 text-gray-400' : 'bg-gray-100 text-gray-500'}
|
|
`}
|
|
>
|
|
{day}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Legend */}
|
|
<div className="flex flex-wrap gap-3 mt-4">
|
|
{Object.entries(statusColors).map(([status, color]) => (
|
|
<div key={status} className="flex items-center gap-1.5">
|
|
<div className={`w-3 h-3 rounded ${color}`}></div>
|
|
<span className="text-xs text-gray-500">{statusLabels[status]}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Sidebar */}
|
|
<div className="space-y-4">
|
|
{/* Mark Today */}
|
|
{!markedToday && month === today.getMonth() + 1 && year === today.getFullYear() && (
|
|
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-5">
|
|
<p className="text-sm font-semibold text-indigo-800 mb-2">Mark Today's Attendance</p>
|
|
<p className="text-xs text-indigo-600 mb-3">You haven't marked attendance for today.</p>
|
|
<button
|
|
onClick={handleMark}
|
|
disabled={marking}
|
|
className="w-full bg-indigo-600 text-white text-sm py-2 rounded-lg hover:bg-indigo-700 transition disabled:opacity-50"
|
|
>
|
|
{marking ? 'Marking...' : 'Mark Present'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{markedToday && (
|
|
<div className="bg-green-50 border border-green-200 rounded-xl p-5">
|
|
<p className="text-sm font-semibold text-green-800">Attendance Marked!</p>
|
|
<p className="text-xs text-green-600 mt-1">You are marked as present today.</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Monthly Summary */}
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
|
|
<h3 className="font-semibold text-gray-800 mb-4">Monthly Summary</h3>
|
|
<div className="space-y-3">
|
|
{Object.entries(statusLabels).map(([status, label]) => (
|
|
<div key={status} className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-2.5 h-2.5 rounded-full ${statusColors[status]}`}></div>
|
|
<span className="text-sm text-gray-600">{label}</span>
|
|
</div>
|
|
<span className="text-sm font-bold text-gray-800">{summary[status] || 0}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|