Files
app/app/admin/AttendanceTab.tsx
T

273 lines
21 KiB
TypeScript

// app/admin/AttendanceTab.tsx
"use client";
import { CheckCircle, ChevronLeft, ChevronRight, ClipboardCheck, Edit3, FileText, Undo, Zap } from 'lucide-react';
import Image from 'next/image';
import React, { useEffect, useState } from 'react';
import falconIcon from '../assets/falcon.svg';
import porpoiseIcon from '../assets/porpoise.svg';
import scheduleData from '../data/schedule.json';
import { type AttendanceDataMap, calculateShiftDuration, formatTimeHHMM, getAttendanceKey, isWeekend, parseTimeInput, type Period, toIsoDate } from './adminTypes';
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;
}
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) => {
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 }) => {
const activePeriod = periods.find(p => p.id === activePeriodId);
const [currentDate, setCurrentDate] = useState<string>(activePeriod ? activePeriod.startDate : toIsoDate(new Date()));
useEffect(() => {
if (activePeriod) {
const today = toIsoDate(new Date());
if (today >= activePeriod.startDate && today <= activePeriod.endDate) setCurrentDate(today);
else setCurrentDate(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);
const markStandardAttendance = (youthId: string, team: 'PF' | 'TU', shiftId: 'MORNING' | 'AFTERNOON') => {
const { time: shiftTime } = getTeamShiftInfo(daySchedule, team);
const rawHours = calculateShiftDuration(shiftTime);
const actualHours = isWeekend(currentDate) ? Math.max(0, rawHours - 0.5) : rawHours;
if (actualHours > 0) setManualAttendance(currentDate, youthId, shiftId, 'Present', actualHours);
};
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="bg-eggshell border border-slate-teal/10 p-3 sm:p-5 rounded-3xl shadow-sm">
<div className="flex justify-between items-center mb-3">
<h3 className="text-xs font-black text-slate-teal uppercase tracking-widest">Periodöversikt</h3>
<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);
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 bg-white border border-slate-teal/10 p-3 sm:p-4 rounded-3xl shadow-sm gap-4">
<button onClick={() => changeDate(-1)} className="p-3 text-slate-teal bg-eggshell/50 hover:bg-slate-teal/10 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-eggshell/50 hover:bg-slate-teal/10 rounded-2xl transition-colors"><ChevronRight size={24} /></button>
</div>
{/* FIX: Status banner is fully restored and green when done! */}
{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;
const rawDuration = calculateShiftDuration(standardTime);
const isWknd = isWeekend(currentDate);
const actualDuration = isWknd ? Math.max(0, rawDuration - 0.5) : rawDuration;
const weight = isWknd ? 1.5 : 1.0;
const weightedDuration = actualDuration * weight;
const scheduledYouth = activePeriod.youthList.filter(y => y.team === team);
const extraYouth = activePeriod.youthList.filter(y => y.team !== team && attendance[getAttendanceKey(currentDate, y.id, shiftId)]);
const displayYouth = [...scheduledYouth, ...extraYouth].sort((a, b) => a.name.localeCompare(b.name));
const availableExtras = activePeriod.youthList.filter(y => !displayYouth.some(dy => dy.id === y.id)).sort((a, b) => a.name.localeCompare(b.name));
const handleQuickLogAll = () => {
const recordsToUpdate: any[] = [];
displayYouth.forEach(y => {
const entry = attendance[getAttendanceKey(currentDate, y.id, shiftId)];
if (!entry || entry.status === 'Pending') {
recordsToUpdate.push({ date: currentDate, youthId: y.id, shiftId, status: 'Present', hoursWorked: actualDuration, weightedHours: weightedDuration, note: '' });
}
});
if (recordsToUpdate.length > 0) bulkSetManualAttendance(recordsToUpdate);
};
return (
<div key={team} className="bg-eggshell border border-slate-teal/10 p-4 md:p-5 rounded-3xl shadow-sm">
<div className="flex flex-wrap justify-between items-center mb-4 gap-3">
<div className="flex items-center">
<div className={`flex items-center justify-center w-10 h-10 rounded-xl ${team === 'PF' ? 'bg-gold' : 'bg-seafoam'} mr-3 shadow-inner shrink-0`}>
<Image src={team === 'PF' ? falconIcon : porpoiseIcon} alt={team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare'} width={18} height={18} />
</div>
<h2 className="text-base font-black text-ebony uppercase tracking-widest">{team === 'PF' ? 'Pilgrimsfalkarna' : 'Tumlarna'}</h2>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 bg-white px-3 py-1.5 rounded-xl border border-slate-teal/5 text-xs text-ebony font-bold shadow-sm">
<FileText size={14} className="text-slate-teal" /> {standardTime} ({formatTimeHHMM(rawDuration)})
</div>
<button onClick={handleQuickLogAll} className="flex items-center gap-1.5 bg-moss/10 text-moss hover:bg-moss hover:text-white px-3 py-1.5 rounded-xl font-black uppercase tracking-widest text-[10px] transition-colors shadow-sm">
<Zap size={14} /> Alla närvarande
</button>
</div>
</div>
<div className="space-y-2">
{displayYouth.map(youth => {
const entry = attendance[getAttendanceKey(currentDate, youth.id, shiftId)];
const isExtra = youth.team !== team;
const isPending = entry?.status === 'Pending';
const isCompleted = entry && !isPending;
return (
<div key={youth.id} className={`flex flex-col md:flex-row md:justify-between md:items-center p-3 rounded-2xl border transition-colors ${isCompleted ? 'bg-moss/5 border-moss/20' : (isPending ? 'bg-goldenrod/5 border-goldenrod/30' : 'bg-white border-slate-teal/5 shadow-sm')}`}>
<div className="flex items-center gap-3 mb-3 md:mb-0">
{isCompleted ? <CheckCircle size={20} className="text-moss shrink-0" /> : <div className="w-5 h-5 rounded-full border-2 border-slate-teal/20 shrink-0 bg-white"></div>}
<div className={`flex items-center justify-center w-6 h-6 rounded-full ${youth.team === 'PF' ? 'bg-gold' : 'bg-seafoam'} shrink-0 shadow-inner`}>
<Image src={youth.team === 'PF' ? falconIcon : porpoiseIcon} alt={youth.team === 'PF' ? 'PF' : 'TU'} width={12} height={12} />
</div>
<span className={`font-bold text-base ${isCompleted ? 'text-moss' : 'text-ebony'}`}>
{youth.name} {isExtra && <span className="ml-2 text-[10px] text-slate-teal bg-slate-teal/10 px-2 py-0.5 rounded-lg uppercase tracking-widest">Extra pass</span>}
</span>
</div>
<div className="flex items-center justify-end gap-3 w-full md:w-auto flex-wrap">
{isPending && <span className="text-[10px] font-black uppercase tracking-widest bg-goldenrod/10 text-goldenrod px-3 py-1.5 rounded-xl">Väntar...</span>}
{isCompleted && (
<span className="text-xs font-black uppercase tracking-widest bg-white text-moss px-3 py-1.5 rounded-xl border border-moss/10 shadow-sm">
{entry.status === 'Absent' ? entry.note : `${formatTimeHHMM(entry.hoursWorked)} (+${entry.weightedHours.toFixed(1)}h)`}
</span>
)}
{(!entry || isPending) && (
<>
<button onClick={() => markStandardAttendance(youth.id, team as 'PF' | 'TU', shiftId)} className="bg-slate-teal/10 text-slate-teal hover:bg-slate-teal hover:text-white px-3 py-1.5 rounded-xl font-black uppercase tracking-widest text-[10px] flex items-center gap-1.5 transition-colors shadow-sm">
<ClipboardCheck size={14} /> Hela passet
</button>
<button onClick={() => {
const input = prompt(`Timmar arbetade:`, formatTimeHHMM(actualDuration));
const hrs = input ? parseTimeInput(input) : 0;
if (hrs > 0) setManualAttendance(currentDate, youth.id, shiftId, 'Present', hrs);
}} className="bg-goldenrod/10 text-goldenrod hover:bg-goldenrod hover:text-white p-1.5 rounded-xl transition-colors shadow-sm">
<Edit3 size={16} />
</button>
<select
value=""
onChange={(e) => {
if (!e.target.value) return;
let reason = e.target.value;
if (reason === 'Custom') reason = prompt('Ange anledning:') || 'Frånvarande';
setManualAttendance(currentDate, youth.id, shiftId, 'Absent', 0, reason);
}}
className="bg-goldenrod/10 text-goldenrod px-2 py-1.5 rounded-xl font-black uppercase tracking-widest text-[10px] outline-none shadow-sm"
>
<option value="">+ Frånvaro</option>
<option value="Sjuk">Sjuk</option><option value="Uteblev">Uteblev</option><option value="Ledig">Ledig</option><option value="Custom">Annan...</option>
</select>
</>
)}
{entry && (
<button onClick={() => removeAttendanceEntry(currentDate, youth.id, shiftId)} className="text-emergency/60 hover:text-emergency p-1.5 bg-white rounded-xl border border-emergency/10 transition-colors shadow-sm">
<Undo size={16} />
</button>
)}
</div>
</div>
);
})}
{availableExtras.length > 0 && (
<div className="pt-3 mt-3 border-t border-slate-teal/5">
<select value="" onChange={(e) => { if (e.target.value) addPendingAttendance(currentDate, e.target.value, shiftId); }} className="bg-white border border-slate-teal/10 p-3 rounded-2xl font-bold text-slate-teal text-sm w-full outline-none shadow-sm">
<option value="">+ Lägg till extra person...</option>
{availableExtras.map(y => <option key={y.id} value={y.id}>{y.name}</option>)}
</select>
</div>
)}
</div>
</div>
);
})
)}
</div>
);
};