273 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}; |