171 lines
11 KiB
TypeScript
171 lines
11 KiB
TypeScript
// app/admin/AttendanceTab.tsx
|
|
"use client";
|
|
|
|
import { CalendarRange, CheckCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
import React, { useEffect, useState } from 'react';
|
|
import { type AttendanceDataMap, getAttendanceKey, type Period, toIsoDate } from './adminTypes';
|
|
import { TeamAttendanceCard } from './TeamAttendanceCard';
|
|
|
|
interface Props {
|
|
periods: Period[]; attendance: AttendanceDataMap;
|
|
setManualAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON', status: 'Absent' | 'Late' | 'Present', hours: number, note?: string) => void;
|
|
bulkSetManualAttendance: (records: any[]) => void;
|
|
addPendingAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
|
|
removeAttendanceEntry: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
|
|
activePeriodId: string; setActivePeriodId: (id: string) => void;
|
|
scheduleData: any[];
|
|
}
|
|
|
|
export const getTeamShiftInfo = (daySchedule: any, team: 'PF' | 'TU') => {
|
|
if (!daySchedule) return { time: 'Ledig', shiftId: 'MORNING' as 'MORNING' | 'AFTERNOON' };
|
|
const pfTime = daySchedule.pilgrimsfalkarna?.time || 'Ledig';
|
|
const tuTime = daySchedule.tumlarna?.time || 'Ledig';
|
|
const pfStart = pfTime !== 'Ledig' ? parseInt(pfTime.match(/(\d+):/)?.[1] || '99') : 99;
|
|
const tuStart = tuTime !== 'Ledig' ? parseInt(tuTime.match(/(\d+):/)?.[1] || '99') : 99;
|
|
if (team === 'PF') return { time: pfTime, shiftId: (pfStart > tuStart) ? 'AFTERNOON' : 'MORNING' as 'MORNING' | 'AFTERNOON' };
|
|
else return { time: tuTime, shiftId: (tuStart > pfStart) ? 'AFTERNOON' : (pfStart === tuStart ? 'AFTERNOON' : 'MORNING') as 'MORNING' | 'AFTERNOON' };
|
|
};
|
|
|
|
const getDaysInPeriod = (start: string, end: string) => {
|
|
const days = []; let curr = new Date(start + 'T12:00:00'); const endDate = new Date(end + 'T12:00:00');
|
|
while (curr <= endDate) { days.push(toIsoDate(curr)); curr.setDate(curr.getDate() + 1); }
|
|
return days;
|
|
};
|
|
|
|
const getDailyCompletionStats = (date: string, period: Period, attendance: AttendanceDataMap, scheduleData: any[]) => {
|
|
const dayNameStr = new Date(date + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase();
|
|
const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr);
|
|
if (!daySchedule) return { expected: 0, completed: 0, isComplete: true, hasWork: false };
|
|
|
|
let expected = 0; let completed = 0; let hasWork = false;
|
|
if (daySchedule.pilgrimsfalkarna && daySchedule.pilgrimsfalkarna.time !== 'Ledig') {
|
|
hasWork = true; const { shiftId } = getTeamShiftInfo(daySchedule, 'PF');
|
|
const pfYouth = period.youthList.filter(y => y.team === 'PF'); expected += pfYouth.length;
|
|
pfYouth.forEach(y => { const entry = attendance[getAttendanceKey(date, y.id, shiftId)]; if (entry && entry.status !== 'Pending') completed++; });
|
|
}
|
|
if (daySchedule.tumlarna && daySchedule.tumlarna.time !== 'Ledig') {
|
|
hasWork = true; const { shiftId } = getTeamShiftInfo(daySchedule, 'TU');
|
|
const tuYouth = period.youthList.filter(y => y.team === 'TU'); expected += tuYouth.length;
|
|
tuYouth.forEach(y => { const entry = attendance[getAttendanceKey(date, y.id, shiftId)]; if (entry && entry.status !== 'Pending') completed++; });
|
|
}
|
|
return { expected, completed, isComplete: expected > 0 && completed >= expected, hasWork };
|
|
};
|
|
|
|
export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry, activePeriodId, setActivePeriodId, scheduleData }) => {
|
|
const activePeriod = periods.find(p => p.id === activePeriodId);
|
|
const [currentDate, setCurrentDate] = useState<string>(() => {
|
|
const today = toIsoDate(new Date());
|
|
if (activePeriod && today >= activePeriod.startDate && today <= activePeriod.endDate) return today;
|
|
return activePeriod ? activePeriod.startDate : today;
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (activePeriod) {
|
|
setCurrentDate(prevDate => {
|
|
if (prevDate >= activePeriod.startDate && prevDate <= activePeriod.endDate) {
|
|
return prevDate;
|
|
}
|
|
const today = toIsoDate(new Date());
|
|
if (today >= activePeriod.startDate && today <= activePeriod.endDate) return today;
|
|
return activePeriod.startDate;
|
|
});
|
|
}
|
|
}, [activePeriodId, activePeriod]);
|
|
|
|
if (!activePeriod) return <p className="text-center font-bold text-slate-teal mt-10">Ingen period aktiv.</p>;
|
|
|
|
const changeDate = (days: number) => {
|
|
const newDateObj = new Date(currentDate + 'T12:00:00'); newDateObj.setDate(newDateObj.getDate() + days);
|
|
const startObj = new Date(activePeriod.startDate + 'T12:00:00'); const endObj = new Date(activePeriod.endDate + 'T12:00:00');
|
|
if (newDateObj >= startObj && newDateObj <= endObj) setCurrentDate(toIsoDate(newDateObj));
|
|
};
|
|
|
|
const dayNameStr = new Date(currentDate + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' });
|
|
const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr.toLowerCase());
|
|
const timelineDays = getDaysInPeriod(activePeriod.startDate, activePeriod.endDate);
|
|
const todayIso = toIsoDate(new Date());
|
|
const currentDayStats = getDailyCompletionStats(currentDate, activePeriod, attendance, scheduleData);
|
|
|
|
const teamsToRender = ['PF', 'TU'].sort((a, b) => {
|
|
const timeA = getTeamShiftInfo(daySchedule, a as 'PF' | 'TU').time;
|
|
const timeB = getTeamShiftInfo(daySchedule, b as 'PF' | 'TU').time;
|
|
const startA = timeA !== 'Ledig' ? parseInt(timeA.match(/(\d+):/)?.[1] || '99') : 99;
|
|
const startB = timeB !== 'Ledig' ? parseInt(timeB.match(/(\d+):/)?.[1] || '99') : 99;
|
|
return startA - startB;
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-6 animate-fade-in">
|
|
<div className="rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md">
|
|
<div className="flex justify-between items-center mb-3">
|
|
<h2 className="text-sm font-black text-ebony uppercase tracking-widest flex items-center mb-2">
|
|
<CalendarRange className="mr-2 text-slate-teal" size={20} /> Periodöversikt
|
|
</h2>
|
|
<select value={activePeriodId} onChange={(e) => setActivePeriodId(e.target.value)} className="bg-white border border-slate-teal/10 py-1.5 px-3 rounded-xl font-bold text-slate-teal text-xs cursor-pointer focus:outline-none shadow-sm">
|
|
{periods.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="flex overflow-x-auto gap-2.5 pb-3 pt-1 px-2 scrollbar-hide">
|
|
{timelineDays.map(day => {
|
|
const stats = getDailyCompletionStats(day, activePeriod, attendance, scheduleData);
|
|
const isSelected = day === currentDate;
|
|
let bgClass = "bg-white text-ebony border-slate-teal/10";
|
|
if (!stats.hasWork) bgClass = "bg-slate-teal/5 text-ebony/40 border-transparent";
|
|
else if (stats.isComplete) bgClass = "bg-moss text-white border-moss shadow-inner";
|
|
else if (day < todayIso || day === todayIso) bgClass = "bg-goldenrod text-white border-goldenrod shadow-inner";
|
|
|
|
return (
|
|
<button key={day} onClick={() => setCurrentDate(day)} className={`flex flex-col items-center justify-center min-w-14 p-2 rounded-xl border transition-colors ${bgClass} ${isSelected ? 'ring-2 ring-slate-teal ring-offset-2 ring-offset-eggshell' : 'hover:brightness-95 shadow-sm'}`}>
|
|
<span className="text-[10px] font-black uppercase tracking-wider">{new Date(day + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'short' })}</span>
|
|
<span className="text-sm font-black">{new Date(day + 'T12:00:00').getDate()}/{new Date(day + 'T12:00:00').getMonth() + 1}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-row justify-between items-center rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md gap-4">
|
|
<button onClick={() => changeDate(-1)} className="p-3 text-slate-teal bg-slate-teal/10 hover:bg-slate-teal/30 rounded-2xl transition-colors"><ChevronLeft size={24} /></button>
|
|
<div className="text-center flex-1">
|
|
<h2 className="text-lg font-black text-ebony capitalize mb-0.5">{dayNameStr}</h2>
|
|
<p className="text-[11px] font-black uppercase tracking-widest text-moss">{currentDate}</p>
|
|
</div>
|
|
<button onClick={() => changeDate(1)} className="p-3 text-slate-teal bg-slate-teal/10 hover:bg-slate-teal/30 rounded-2xl transition-colors"><ChevronRight size={24} /></button>
|
|
</div>
|
|
|
|
{currentDayStats.hasWork && (
|
|
<div className={`px-5 py-3 rounded-2xl font-black text-xs uppercase tracking-widest flex items-center justify-center shadow-sm border transition-colors ${currentDayStats.isComplete ? 'bg-moss/10 text-moss border-moss/20' : 'bg-goldenrod/10 text-goldenrod border-goldenrod/20'}`}>
|
|
{currentDayStats.isComplete ? <><CheckCircle size={16} className="mr-2" /> All närvaro rapporterad</> : `Ifyllt: ${currentDayStats.completed} av ${currentDayStats.expected} pers`}
|
|
</div>
|
|
)}
|
|
|
|
{!daySchedule || !currentDayStats.hasWork ? (
|
|
<div className="bg-eggshell border border-slate-teal/10 p-8 rounded-3xl text-center shadow-sm">
|
|
<p className="font-black text-sm text-ebony">Inga schemalagda pass denna dag.</p>
|
|
</div>
|
|
) : (
|
|
teamsToRender.map(teamStr => {
|
|
const team = teamStr as 'PF' | 'TU';
|
|
const { time: standardTime, shiftId } = getTeamShiftInfo(daySchedule, team);
|
|
if (standardTime === 'Ledig') return null;
|
|
|
|
return (
|
|
<TeamAttendanceCard
|
|
key={team}
|
|
team={team}
|
|
currentDate={currentDate}
|
|
shiftId={shiftId}
|
|
standardTime={standardTime}
|
|
periodYouthList={activePeriod.youthList}
|
|
attendance={attendance}
|
|
setManualAttendance={setManualAttendance}
|
|
bulkSetManualAttendance={bulkSetManualAttendance}
|
|
addPendingAttendance={addPendingAttendance}
|
|
removeAttendanceEntry={removeAttendanceEntry}
|
|
/>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
);
|
|
}; |