Added Admin page

This commit is contained in:
2026-03-13 18:02:55 +01:00 Verified
parent 88c534a2d6
commit 3d3ab6b268
10 changed files with 958 additions and 84 deletions
+2
View File
@@ -1,3 +1,5 @@
*.csv
# Logs
logs
*.log
+32
View File
@@ -0,0 +1,32 @@
// src/components/PhoneLinks.tsx
import React from 'react';
interface SafePhoneLinkProps {
parts: string[];
display: string;
className?: string;
}
export const SafePhoneLink: React.FC<SafePhoneLinkProps> = ({
parts,
display,
className = "text-sm text-slate-teal font-mono font-bold bg-seafoam/20 hover:bg-seafoam/40 px-3 py-1.5 rounded-lg transition-colors cursor-pointer"
}) => {
const fullNumber = parts.join('');
const handleClick = (e: React.MouseEvent<HTMLAnchorElement> | React.TouchEvent<HTMLAnchorElement>) => {
e.currentTarget.href = `tel:${fullNumber}`;
};
return (
<a
href="#"
onMouseDown={handleClick}
onTouchStart={handleClick}
className={className}
>
{display}
</a>
);
};
+10 -9
View File
@@ -2,6 +2,7 @@
import React from 'react';
import { Phone, AlertTriangle, MapPin, HeartPulse } from 'lucide-react';
import { SafePhoneLink } from '../components/PhoneLinks';
export const Emergency: React.FC = () => {
return (
@@ -65,19 +66,19 @@ export const Emergency: React.FC = () => {
<p className="text-ebony/80 font-medium mb-4 text-sm">
När situationen är under kontroll, meddela alltid arbetsledaren om vad som inträffat.
</p>
<div>
<div className="flex justify-between items-center pb-4 border-b border-moss/20">
<div className="space-y-4">
<div className="flex flex-col md:flex-row md:justify-between md:items-center pb-4 border-b border-moss/20 gap-3">
<span className="text-base font-bold text-ebony">William Söderberg</span>
<div className='flex gap-2 flex-wrap justify-end'>
<a href="tel:0722470291" className="text-sm text-slate-teal font-mono font-bold bg-seafoam/20 hover:bg-seafoam/40 px-3 py-1.5 rounded-lg transition-colors">072-247 02 91</a>
<a href="tel:078389355810604" className="text-sm text-slate-teal font-mono font-bold bg-seafoam/20 hover:bg-seafoam/40 px-3 py-1.5 rounded-lg transition-colors">078-389 35 58 10 604</a>
<div className='flex gap-2 flex-wrap md:justify-end'>
<SafePhoneLink parts={['072', '247', '02', '91']} display="072-247 02 91" />
<SafePhoneLink parts={['078', '389', '355', '81', '0604']} display="078-389 35 58 10 604" />
</div>
</div>
<div className="flex justify-between items-center pt-4">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-3">
<span className="text-base font-bold text-ebony">Oliver Nilsson</span>
<div className='flex gap-2 flex-wrap justify-end'>
<a href="tel:0727177440" className="text-sm text-slate-teal font-mono font-bold bg-seafoam/20 hover:bg-seafoam/40 px-3 py-1.5 rounded-lg transition-colors">072-717 74 40</a>
<a href="tel:078389355810605" className="text-sm text-slate-teal font-mono font-bold bg-seafoam/20 hover:bg-seafoam/40 px-3 py-1.5 rounded-lg transition-colors">078-389 35 58 10 605</a>
<div className='flex gap-2 flex-wrap md:justify-end'>
<SafePhoneLink parts={['072', '717', '74', '40']} display="072-717 74 40" />
<SafePhoneLink parts={['078', '389', '355', '81', '0605']} display="078-389 35 58 10 605" />
</div>
</div>
</div>
+10 -8
View File
@@ -3,6 +3,8 @@
import React from 'react';
import { AlertTriangle, PhoneCall, ArrowRight } from 'lucide-react';
import { Link } from 'react-router-dom';
// Import the component!
import { SafePhoneLink } from '../components/PhoneLinks';
export const Home: React.FC = () => {
return (
@@ -53,18 +55,18 @@ export const Home: React.FC = () => {
<h3 className="font-black text-ebony uppercase tracking-widest text-sm">Snabbkontakt</h3>
</div>
<div className="p-6 space-y-4">
<div className="flex justify-between items-center pb-4 border-b border-moss/20">
<div className="flex flex-col md:flex-row md:justify-between md:items-center pb-4 border-b border-moss/20 gap-3">
<span className="text-base font-bold text-ebony">William Söderberg</span>
<div className='flex gap-2 flex-wrap justify-end'>
<a href="tel:0722470291" className="text-sm text-slate-teal font-mono font-bold bg-seafoam/20 hover:bg-seafoam/40 px-3 py-1.5 rounded-lg transition-colors">072-247 02 91</a>
<a href="tel:078389355810604" className="text-sm text-slate-teal font-mono font-bold bg-seafoam/20 hover:bg-seafoam/40 px-3 py-1.5 rounded-lg transition-colors">078-389 35 58 10 604</a>
<div className='flex gap-2 flex-wrap md:justify-end'>
<SafePhoneLink parts={['072', '247', '02', '91']} display="072-247 02 91" />
<SafePhoneLink parts={['078', '389', '355', '81', '0604']} display="078-389 35 58 10 604" />
</div>
</div>
<div className="flex justify-between items-center">
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-3">
<span className="text-base font-bold text-ebony">Oliver Nilsson</span>
<div className='flex gap-2 flex-wrap justify-end'>
<a href="tel:0727177440" className="text-sm text-slate-teal font-mono font-bold bg-seafoam/20 hover:bg-seafoam/40 px-3 py-1.5 rounded-lg transition-colors">072-717 74 40</a>
<a href="tel:078389355810605" className="text-sm text-slate-teal font-mono font-bold bg-seafoam/20 hover:bg-seafoam/40 px-3 py-1.5 rounded-lg transition-colors">078-389 35 58 10 605</a>
<div className='flex gap-2 flex-wrap md:justify-end'>
<SafePhoneLink parts={['072', '717', '74', '40']} display="072-717 74 40" />
<SafePhoneLink parts={['078', '389', '355', '81', '0605']} display="078-389 35 58 10 605" />
</div>
</div>
</div>
+68 -67
View File
@@ -1,53 +1,55 @@
// src/pages/admin/AdminDashboard.tsx
import { CalendarRange, ClipboardCheck, Loader2, Lock, Unlock, Users as UsersIcon } from 'lucide-react';
import React, { useState } from 'react';
import { Lock, Unlock, Upload, Users } from 'lucide-react';
import { MOCK_USERS } from './adminTypes';
import { AttendanceTab } from './AttendanceTab';
import { ReportTab } from './ReportTab';
import { SetupTab } from './SetupTab';
import { useAdminState } from './useAdminState';
export const Admin: React.FC = () => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [currentUser, setCurrentUser] = useState<typeof MOCK_USERS[0] | null>(null);
const [selectedUserId, setSelectedUserId] = useState(MOCK_USERS[0].id);
const [pin, setPin] = useState('');
const [error, setError] = useState('');
const CORRECT_PIN = "1234"; // You can change this later
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState<'setup' | 'today' | 'report'>('report');
const adminState = useAdminState();
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
if (pin === CORRECT_PIN) {
setIsAuthenticated(true);
setError('');
} else {
setError('Ogiltig pinkod.');
setPin('');
}
setIsLoading(true);
setTimeout(() => {
const user = MOCK_USERS.find(u => u.id === selectedUserId && u.pin === pin);
if (user) {
setCurrentUser(user);
if (user.role === 'Viewer') {
setActiveTab('report');
} else if (user.role === 'Staff') {
setActiveTab('today');
} else {
setActiveTab(adminState.periods.length > 0 ? 'today' : 'setup');
}
} else {
setPin('');
}
setIsLoading(false);
}, 600);
};
// --- VIEW: LOCKED ---
if (!isAuthenticated) {
if (!currentUser) {
return (
<div className="flex flex-col items-center justify-center py-20 animate-fade-in px-4">
<div className="bg-eggshell border-2 border-slate-teal/20 p-8 rounded-2xl shadow-lg w-full max-w-sm text-center">
<div className="bg-slate-teal/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<Lock size={32} className="text-slate-teal" />
</div>
<h2 className="text-2xl font-black text-ebony tracking-wide uppercase mb-2">Admin Login</h2>
<p className="text-sm font-medium text-ebony/70 mb-6">Ange pinkod för att hantera schema och närvaro.</p>
<form onSubmit={handleLogin} className="space-y-4">
<input
type="password"
inputMode="numeric"
value={pin}
onChange={(e) => setPin(e.target.value)}
placeholder="••••"
className="w-full bg-eggshell/50 border-2 border-slate-teal/30 text-center text-2xl tracking-[0.5em] text-ebony font-mono p-3 rounded-xl focus:outline-none focus:border-slate-teal focus:ring-1 focus:ring-slate-teal transition-all"
autoFocus
/>
{error && <p className="text-goldenrod font-bold text-sm">{error}</p>}
<button
type="submit"
className="w-full bg-slate-teal text-eggshell font-black uppercase tracking-widest py-3 rounded-xl hover:bg-ebony transition-colors shadow-md shadow-slate-teal/20"
>
Lås upp
<div className="bg-slate-teal/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4"><Lock size={32} className="text-slate-teal" /></div>
<h2 className="text-2xl font-black text-ebony uppercase mb-6">Admin Login</h2>
<form onSubmit={handleLogin} className="space-y-4 text-left">
<select value={selectedUserId} onChange={(e) => setSelectedUserId(e.target.value)} className="w-full bg-eggshell/50 border-2 border-slate-teal/30 text-ebony font-bold p-3 rounded-xl focus:outline-none focus:border-slate-teal">
{MOCK_USERS.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
<input type="password" inputMode="numeric" value={pin} onChange={(e) => setPin(e.target.value)} placeholder="••••" className="w-full bg-eggshell/50 border-2 border-slate-teal/30 text-center text-2xl tracking-[0.5em] text-ebony font-mono p-3 rounded-xl focus:outline-none focus:border-slate-teal" />
<button type="submit" disabled={isLoading} className="w-full bg-slate-teal text-eggshell font-black uppercase py-3 rounded-xl hover:bg-ebony transition-colors">
{isLoading ? <Loader2 className="animate-spin mx-auto" /> : 'Logga in'}
</button>
</form>
</div>
@@ -55,45 +57,44 @@ export const Admin: React.FC = () => {
);
}
// --- VIEW: UNLOCKED (ADMIN DASHBOARD) ---
const availableTabs = [];
if (currentUser.role === 'Admin') {
availableTabs.push({ id: 'setup', icon: CalendarRange, label: 'Perioder' });
}
if (currentUser.role === 'Admin' || currentUser.role === 'Staff') {
availableTabs.push({ id: 'today', icon: ClipboardCheck, label: 'Närvaro' });
}
availableTabs.push({ id: 'report', icon: UsersIcon, label: 'Rapport' });
return (
<div className="space-y-8 animate-fade-in w-full">
<div className="flex justify-between items-end border-b-2 border-slate-teal/20 pb-4">
<div>
<h1 className="text-3xl font-black text-slate-teal tracking-tight uppercase flex items-center">
<Unlock className="mr-3 text-seafoam" size={28} />
Admin Dashboard
</h1>
<p className="text-moss font-bold mt-2 ml-10">Hantera personal, scheman och närvaro.</p>
<h1 className="text-3xl font-black text-slate-teal uppercase flex items-center"><Unlock className="mr-3 text-seafoam" size={28} /> Admin</h1>
<div className="text-right">
<p className="text-xs font-bold text-moss mb-1">Inloggad: {currentUser.name}</p>
<button onClick={() => { setCurrentUser(null); setPin(''); }} className="text-sm font-bold text-ebony/60 hover:text-goldenrod">Logga ut</button>
</div>
<button
onClick={() => setIsAuthenticated(false)}
className="text-sm font-bold text-ebony/60 hover:text-goldenrod transition-colors"
>
Logga ut
</button>
</div>
{/* Attendance Tracker Section Shell */}
<section className="bg-eggshell border-2 border-slate-teal/20 rounded-2xl p-6 shadow-sm">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center">
<Users className="mr-3 text-slate-teal" size={24} />
Närvarorapport
</h2>
{/* Fake Upload Button for now */}
<button className="flex items-center bg-seafoam/20 hover:bg-seafoam/40 text-slate-teal font-bold px-4 py-2 rounded-lg transition-colors border border-seafoam/30">
<Upload size={18} className="mr-2" />
Ladda upp CSV
</button>
{availableTabs.length > 1 && (
<div className="border-b border-slate-teal/20 flex gap-2">
{availableTabs.map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id as any)} className={`flex items-center px-4 py-2.5 rounded-t-lg font-bold text-sm transition-colors border-b-2 ${activeTab === tab.id ? 'bg-slate-teal text-eggshell border-slate-teal' : 'bg-eggshell text-slate-teal border-transparent hover:bg-slate-teal/5'}`}>
<tab.icon size={16} className="mr-2 hidden md:block" /> {tab.label}
</button>
))}
</div>
)}
<div className="bg-slate-teal/5 border-2 border-dashed border-slate-teal/30 rounded-xl p-10 text-center flex flex-col items-center justify-center">
<p className="text-ebony/60 font-medium mb-2">Ingen data uppladdad ännu.</p>
<p className="text-sm text-ebony/40 font-medium">Ladda upp Timeedit CSV-filen för att visa interaktiv närvarolista här.</p>
</div>
</section>
{activeTab === 'setup' && currentUser.role === 'Admin' && <SetupTab {...adminState} />}
{activeTab === 'today' && (currentUser.role === 'Admin' || currentUser.role === 'Staff') && (
<AttendanceTab periods={adminState.periods} attendance={adminState.attendance} setManualAttendance={adminState.setManualAttendance} addPendingAttendance={adminState.addPendingAttendance} removeAttendanceEntry={adminState.removeAttendanceEntry} activePeriodId={adminState.activePeriodId} setActivePeriodId={adminState.setActivePeriodId} />
)}
{activeTab === 'report' && (
<ReportTab periods={adminState.periods} attendance={adminState.attendance} activePeriodId={adminState.activePeriodId} setActivePeriodId={adminState.setActivePeriodId} currentUserRole={currentUser.role} />
)}
</div>
);
};
+303
View File
@@ -0,0 +1,303 @@
// src/pages/admin/AttendanceTab.tsx
import { CheckCircle, ChevronLeft, ChevronRight, ClipboardCheck, Edit3, FileText, Undo } from 'lucide-react';
import React, { useEffect, useState } from 'react';
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;
addPendingAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
removeAttendanceEntry: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
activePeriodId: string;
setActivePeriodId: (id: string) => void;
}
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 pfYouth = period.youthList.filter(y => y.team === 'PF');
expected += pfYouth.length;
// Don't count "Pending" as completed!
pfYouth.forEach(y => {
const entry = attendance[getAttendanceKey(date, y.id, 'MORNING')];
if (entry && entry.status !== 'Pending') completed++;
});
}
if (daySchedule.tumlarna && daySchedule.tumlarna.time !== 'Ledig') {
hasWork = true;
const tuYouth = period.youthList.filter(y => y.team === 'TU');
expected += tuYouth.length;
tuYouth.forEach(y => {
const entry = attendance[getAttendanceKey(date, y.id, 'AFTERNOON')];
if (entry && entry.status !== 'Pending') completed++;
});
}
return { expected, completed, isComplete: expected > 0 && completed >= expected, hasWork };
};
export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualAttendance, 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 shiftTime = team === 'PF' ? daySchedule?.pilgrimsfalkarna?.time : daySchedule?.tumlarna?.time;
const rawHours = calculateShiftDuration(shiftTime);
const actualHours = isWeekend(currentDate) ? Math.max(0, rawHours - 0.5) : rawHours;
if (actualHours > 0) setManualAttendance(currentDate, youthId, shiftId, 'Present', actualHours);
};
return (
<div className="space-y-6 animate-fade-in">
{/* Timeline Overview */}
<div className="bg-eggshell border-2 border-slate-teal/20 p-4 md:p-6 rounded-2xl shadow-sm">
<div className="flex justify-between items-center mb-1">
<h3 className="text-xs font-black text-slate-teal uppercase tracking-widest ml-1">Periodöversikt</h3>
<select
value={activePeriodId}
onChange={(e) => setActivePeriodId(e.target.value)}
className="bg-white border border-slate-teal/20 p-1.5 rounded-lg font-bold text-slate-teal text-sm cursor-pointer shadow-sm focus:outline-none"
>
{periods.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div className="flex overflow-x-auto gap-3 pb-6 pt-4 px-2 scrollbar-hide">
{timelineDays.map(day => {
const stats = getDailyCompletionStats(day, activePeriod, attendance);
const isPast = day < todayIso;
const isToday = day === todayIso;
const isSelected = day === currentDate;
let bgClass = "bg-white text-ebony border-slate-teal/20";
if (!stats.hasWork) bgClass = "bg-slate-teal/5 text-ebony/40 border-transparent";
else if (stats.isComplete) bgClass = "bg-moss text-white border-moss";
else if (isPast || isToday) bgClass = "bg-goldenrod text-white border-goldenrod";
return (
<button
key={day}
onClick={() => setCurrentDate(day)}
className={`flex flex-col items-center justify-center min-w-12.5 p-2 rounded-xl border-2 transition-all ${bgClass} ${isSelected ? 'ring-2 ring-slate-teal ring-offset-2 ring-offset-eggshell scale-110 shadow-md' : 'hover:brightness-95'}`}
>
<span className="text-[10px] font-bold uppercase">{new Date(day + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'short' })}</span>
<span className="text-xs font-black">{new Date(day + 'T12:00:00').getDate()}/{new Date(day + 'T12:00:00').getMonth() + 1}</span>
</button>
);
})}
</div>
</div>
{/* Date Navigation */}
<div className="flex flex-col md:flex-row justify-between items-center bg-eggshell border-2 border-slate-teal/20 p-4 md:p-6 rounded-2xl shadow-sm gap-4">
<div className="flex items-center gap-4 w-full md:w-auto justify-between md:justify-start">
<button onClick={() => changeDate(-1)} className="p-2 text-slate-teal hover:bg-slate-teal/10 rounded-full transition-colors"><ChevronLeft size={24} /></button>
<div className="text-center w-40">
<h2 className="text-lg font-black text-ebony capitalize">{dayNameStr}</h2>
<p className="text-xs font-bold text-moss">{currentDate}</p>
</div>
<button onClick={() => changeDate(1)} className="p-2 text-slate-teal hover:bg-slate-teal/10 rounded-full transition-colors"><ChevronRight size={24} /></button>
</div>
{currentDayStats.hasWork && (
<div className={`w-full md:w-auto px-5 py-2.5 rounded-lg font-bold text-sm flex items-center justify-center border ${currentDayStats.isComplete ? 'bg-moss/20 text-moss border-moss/30' : 'bg-goldenrod/10 text-goldenrod border-goldenrod/30'}`}>
{currentDayStats.isComplete ? <><CheckCircle size={18} className="mr-2" /> Dagen är komplett!</> : `Ifyllt: ${currentDayStats.completed} av ${currentDayStats.expected} pers`}
</div>
)}
</div>
{/* Attendance Lists */}
{!daySchedule || !currentDayStats.hasWork ? (
<div className="bg-eggshell border-2 border-slate-teal/20 p-6 rounded-2xl text-center"><p className="font-bold text-ebony">Inga schemalagda pass denna dag.</p></div>
) : (
['PF', 'TU'].map(team => {
const standardShift = team === 'PF' ? daySchedule.pilgrimsfalkarna : daySchedule.tumlarna;
if (!standardShift || standardShift.time === 'Ledig') return null;
const rawDuration = calculateShiftDuration(standardShift.time);
const shiftId = team === 'PF' ? 'MORNING' : 'AFTERNOON';
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];
const availableExtras = activePeriod.youthList.filter(y => !displayYouth.some(dy => dy.id === y.id));
return (
<div key={team} className="bg-eggshell border-2 border-slate-teal/20 p-4 md:p-6 rounded-2xl shadow-sm">
<div className="flex flex-col md:flex-row md:justify-between md:items-center mb-6 gap-3">
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center">
<span className={`w-3 h-3 rounded-full ${team === 'PF' ? 'bg-gold' : 'bg-seafoam'} mr-3`}></span>
{team === 'PF' ? 'Pilgrimsfalkarna' : 'Tumlarna'}
</h2>
<div className="flex flex-wrap items-center gap-3 bg-slate-teal/5 px-4 py-2 rounded-lg border border-slate-teal/10 text-sm">
<FileText size={16} className="text-slate-teal" />
<span className="font-bold text-ebony">{standardShift.time} ({formatTimeHHMM(rawDuration)})</span>
{isWknd && <span className="text-xs font-bold text-goldenrod bg-goldenrod/10 px-2 py-0.5 rounded border border-goldenrod/20">Helg (-30m lunch) x1.5 = +{weightedDuration.toFixed(1)}t pott</span>}
</div>
</div>
<div className="space-y-4">
{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 xl:flex-row xl:justify-between xl:items-center p-3 rounded-xl border-2 transition-colors ${isCompleted ? 'bg-moss/10 border-moss/40' : (isPending ? 'bg-goldenrod/5 border-goldenrod/40' : 'bg-white border-slate-teal/10 shadow-sm')}`}>
<div className="flex items-center gap-3 mb-3 xl: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"></div>}
<span className={`font-bold ${isCompleted ? 'text-moss' : 'text-ebony'}`}>
{youth.name}
{isExtra && <span className="ml-2 text-[10px] text-slate-teal bg-slate-teal/10 px-1.5 py-0.5 rounded uppercase tracking-wider">Extra pass</span>}
</span>
</div>
<div className="flex items-center justify-end gap-3 w-full xl:w-auto flex-wrap">
{/* Status Display Area */}
{isPending && (
<span className="text-xs font-bold bg-goldenrod/10 text-goldenrod px-3 py-1.5 rounded-lg border border-goldenrod/20 shadow-sm animate-pulse">
Väntar tid...
</span>
)}
{isCompleted && (
<span className="text-xs font-bold bg-white text-moss px-3 py-1.5 rounded-lg border border-moss/20 shadow-sm">
{entry.status === 'Absent' ? entry.note : `${formatTimeHHMM(entry.hoursWorked)} arbetat (+${entry.weightedHours.toFixed(1)}t pott)`}
</span>
)}
{/* Action Buttons */}
<div className="flex flex-wrap items-center gap-2">
{(!entry || isPending) && (
<>
<button onClick={() => markStandardAttendance(youth.id, team as 'PF' | 'TU', shiftId)} className="bg-slate-teal/10 text-slate-teal hover:bg-slate-teal/20 px-3 py-1.5 rounded-lg font-bold text-sm flex items-center gap-1.5 transition-colors">
<ClipboardCheck size={16} /> Hela passet
</button>
<button onClick={() => {
const input = prompt(`Timmar arbetade (t.ex. 2:30 eller 2.5):`, 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/20 px-3 py-1.5 rounded-lg font-bold text-sm transition-colors">
<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 border border-goldenrod/20 hover:bg-goldenrod/20 px-2 py-1.5 rounded-lg font-bold text-sm transition-colors cursor-pointer appearance-none text-center outline-none"
>
<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-moss hover:text-goldenrod p-2 bg-white rounded-lg border border-moss/20 shadow-sm transition-colors"
title="Ångra och ta bort närvaro"
>
<Undo size={16} />
</button>
)}
</div>
</div>
</div>
);
})}
{availableExtras.length > 0 && (
<div className="pt-2 border-t border-slate-teal/10 mt-2">
<select
value=""
onChange={(e) => {
if (e.target.value) {
// We now add them safely as Pending!
addPendingAttendance(currentDate, e.target.value, shiftId);
}
}}
className="bg-white border border-slate-teal/20 p-2 rounded-lg font-bold text-slate-teal text-sm w-full md:w-auto cursor-pointer"
>
<option value="">+ Lägg till extra person detta pass...</option>
{availableExtras.map(y => (
<option key={y.id} value={y.id}>{y.name} ({y.team})</option>
))}
</select>
</div>
)}
</div>
</div>
);
})
)}
</div>
);
};
+225
View File
@@ -0,0 +1,225 @@
// src/pages/admin/ReportTab.tsx
import { AlertTriangle, ChevronDown, ChevronUp, Clock, Download, Users } from 'lucide-react';
import React, { useState } from 'react';
import { type AttendanceDataMap, formatTimeHHMM, isWeekend, type Period, type Role } from './adminTypes';
interface Props {
periods: Period[];
attendance: AttendanceDataMap;
activePeriodId: string;
setActivePeriodId: (id: string) => void;
currentUserRole: Role;
}
const POT_HOUR_LIMIT = 90;
const formatHours = (h: number) => Number(h.toFixed(1)).toString();
export const ReportTab: React.FC<Props> = ({ periods, attendance, activePeriodId, setActivePeriodId, currentUserRole }) => {
const activePeriod = periods.find(p => p.id === activePeriodId);
const [expandedYouthId, setExpandedYouthId] = useState<string | null>(null);
if (!activePeriod) return <p className="text-center font-bold text-slate-teal mt-10">Ingen period tillgänglig.</p>;
const getPeriodHoursTotal = (youthId: string): number => {
let total = 0;
for (const key in attendance) {
const entry = attendance[key];
if (entry.youthId === youthId && entry.date >= activePeriod.startDate && entry.date <= activePeriod.endDate) {
total += entry.weightedHours;
}
}
return total;
};
const getTimelineForYouth = (youthId: string) => {
return Object.values(attendance)
.filter(a => a.youthId === youthId && a.date >= activePeriod.startDate && a.date <= activePeriod.endDate)
.sort((a, b) => {
if (a.date !== b.date) return a.date.localeCompare(b.date);
return a.shiftId === 'MORNING' ? -1 : 1;
});
};
const exportToCSV = () => {
if (!activePeriod) return;
let csvContent = "Datum;Pass;Namn;Lag;Status;Arbetad Tid (HH:MM);Viktad Pott;Anteckning\n";
const entries = Object.values(attendance).filter(a => a.date >= activePeriod.startDate && a.date <= activePeriod.endDate);
entries.sort((a, b) => {
if (a.date !== b.date) return a.date.localeCompare(b.date);
if (a.shiftId !== b.shiftId) return a.shiftId.localeCompare(b.shiftId);
const nameA = activePeriod.youthList.find(y => y.id === a.youthId)?.name || '';
const nameB = activePeriod.youthList.find(y => y.id === b.youthId)?.name || '';
return nameA.localeCompare(nameB);
});
entries.forEach(entry => {
const youth = activePeriod.youthList.find(y => y.id === entry.youthId);
if (!youth) return;
const shift = entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag';
const worked = formatTimeHHMM(entry.hoursWorked);
const weighted = entry.weightedHours.toFixed(2).replace('.', ',');
const note = entry.note || '';
csvContent += `${entry.date};${shift};${youth.name};${youth.team};${entry.status};${worked};${weighted};${note}\n`;
});
csvContent += "\nSummering (Timpott)\nNamn;Lag;Total Viktad Pott\n";
activePeriod.youthList.forEach(youth => {
const total = getPeriodHoursTotal(youth.id).toFixed(2).replace('.', ',');
csvContent += `${youth.name};${youth.team};${total}\n`;
});
const blob = new Blob(["\uFEFF" + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", `Narvaro_${activePeriod.name.replace(/ /g, '_')}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<div className="space-y-6 animate-fade-in">
{/* Header / Period Selector */}
<div className="bg-eggshell border-2 border-slate-teal/20 p-4 md:p-6 rounded-2xl shadow-sm text-center flex flex-col md:flex-row justify-between items-center gap-4">
<select
value={activePeriodId}
onChange={(e) => { setActivePeriodId(e.target.value); setExpandedYouthId(null); }}
className="bg-white border border-slate-teal/20 p-2.5 rounded-lg font-bold text-slate-teal w-full md:w-auto focus:outline-none"
>
{periods.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<div>
<h2 className="text-xl font-black text-ebony uppercase tracking-widest">{activePeriod.name}</h2>
<p className="font-bold text-moss">{activePeriod.startDate} {activePeriod.endDate}</p>
</div>
</div>
{/* PERIOD HOUR POT REPORT */}
<div className="bg-eggshell border-2 border-slate-teal/20 p-4 md:p-6 rounded-2xl shadow-sm">
<div className="flex flex-col md:flex-row justify-between md:items-center mb-8 gap-4 px-1 md:px-2">
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center">
<Users className="mr-3 text-slate-teal" size={24} />
Timpott (Max {POT_HOUR_LIMIT}t)
</h2>
{currentUserRole !== 'Viewer' && (
<button
onClick={exportToCSV}
className="flex items-center justify-center gap-2 bg-seafoam/20 hover:bg-seafoam/40 text-slate-teal font-bold px-5 py-2.5 rounded-lg transition-colors border border-seafoam/30 w-full md:w-auto"
>
<Download size={18} />
Exportera till Excel
</button>
)}
</div>
<div className="space-y-4">
{activePeriod.youthList.map(youth => {
const total = getPeriodHoursTotal(youth.id);
const warningStatus: 'none' | 'yellow' | 'red' = total > POT_HOUR_LIMIT ? 'red' : (total >= POT_HOUR_LIMIT - 10 ? 'yellow' : 'none');
const isExpanded = expandedYouthId === youth.id;
const timeline = getTimelineForYouth(youth.id);
return (
<div key={youth.id} className="bg-white border border-slate-teal/10 rounded-xl shadow-inner overflow-hidden">
<div className="flex flex-col md:flex-row md:justify-between md:items-center p-6 md:px-8 gap-6">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<span className={`w-3 h-3 rounded-full ${youth.team === 'PF' ? 'bg-gold' : 'bg-seafoam'}`}></span>
<span className="font-bold text-ebony text-lg">{youth.name} <span className="text-xs font-bold text-moss ml-1">({youth.team})</span></span>
</div>
<button
onClick={() => setExpandedYouthId(isExpanded ? null : youth.id)}
className="flex items-center text-xs font-bold text-slate-teal hover:text-ebony transition-colors w-fit bg-slate-teal/5 px-2.5 py-1.5 rounded mt-1"
>
{isExpanded ? <ChevronUp size={14} className="mr-1" /> : <ChevronDown size={14} className="mr-1" />}
{isExpanded ? 'Dölj detaljer' : 'Visa detaljer'}
</button>
</div>
<div className="flex items-center justify-end gap-5 w-full md:w-auto">
{warningStatus === 'red' && <AlertTriangle className="text-goldenrod shrink-0" size={32} />}
<div className="text-right min-w-30 md:min-w-37.5">
<div className={`text-3xl md:text-4xl font-black tracking-tight ${warningStatus === 'red' ? 'text-goldenrod' : (warningStatus === 'yellow' ? 'text-goldenrod/80' : 'text-slate-teal')}`}>
{formatHours(total)}<span className="text-lg font-bold text-ebony/60"> / {POT_HOUR_LIMIT}t</span>
</div>
<p className="text-[11px] font-bold text-ebony/60 uppercase tracking-widest mt-1 pr-1">Viktade timmar</p>
</div>
</div>
</div>
{isExpanded && (
<div className="bg-slate-teal/5 border-t border-slate-teal/10 p-5 md:p-6">
<h4 className="text-sm font-black text-ebony uppercase tracking-widest mb-4 flex items-center">
<Clock size={16} className="mr-2 text-slate-teal" /> Arbetspass & Frånvaro
</h4>
{timeline.length === 0 ? (
<p className="text-sm font-bold text-slate-teal/60 italic">Ingen närvaro loggad ännu.</p>
) : (
<div className="space-y-2">
{timeline.map((entry, idx) => {
const isWknd = isWeekend(entry.date);
// FEATURE: Figure out if this shift was an extra shift for this youth!
const isExtra = (youth.team === 'PF' && entry.shiftId === 'AFTERNOON') ||
(youth.team === 'TU' && entry.shiftId === 'MORNING');
return (
<div key={idx} className="flex flex-col sm:flex-row sm:justify-between sm:items-center bg-white border border-slate-teal/10 p-3 rounded-lg text-sm">
<div className="flex items-center flex-wrap gap-2 mb-2 sm:mb-0">
<span className="font-bold text-ebony min-w-25">{entry.date}</span>
<span className="text-xs font-bold text-slate-teal bg-slate-teal/10 px-2 py-0.5 rounded">
{entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag'}
</span>
{/* NEW: Display the extra shift badge! */}
{isExtra && (
<span className="text-[10px] font-bold text-slate-teal bg-slate-teal/10 px-1.5 py-0.5 rounded uppercase tracking-wider">
Extra pass
</span>
)}
{isWknd && <span className="text-xs font-bold text-goldenrod bg-goldenrod/10 px-2 py-0.5 rounded border border-goldenrod/20">Helg</span>}
</div>
<div className="flex items-center gap-4">
{entry.status === 'Pending' ? (
<span className="font-bold text-goldenrod bg-goldenrod/10 px-2 py-0.5 rounded">Väntar registrering</span>
) : entry.status === 'Absent' ? (
<span className="font-bold text-goldenrod bg-goldenrod/10 px-2 py-0.5 rounded">{entry.note || 'Frånvarande'}</span>
) : (
<span className="font-bold text-moss bg-moss/10 px-2 py-0.5 rounded">{entry.status === 'Late' ? 'Manuell tid' : 'Närvarande'}</span>
)}
<div className="text-right min-w-37.5">
<span className="font-bold text-ebony">{formatTimeHHMM(entry.hoursWorked)} arbetat</span>
<span className="text-slate-teal font-black ml-2"> +{entry.weightedHours.toFixed(1)} pott</span>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
})}
{activePeriod.youthList.length === 0 && (
<p className="text-sm font-bold text-slate-teal/60 italic text-center">Inga ungdomar i denna period.</p>
)}
</div>
</div>
</div>
);
};
+108
View File
@@ -0,0 +1,108 @@
// src/pages/admin/SetupTab.tsx
import { CalendarRange, Trash2, UserPlus } from 'lucide-react';
import React, { useState } from 'react';
import { type Period } from './adminTypes';
interface Props {
periods: Period[];
createPeriod: (name: string, start: string) => void;
deletePeriod: (id: string) => void;
bulkAddYouth: (periodId: string, text: string, team: 'PF' | 'TU') => void;
removeYouth: (periodId: string, youthId: string) => void;
}
export const SetupTab: React.FC<Props> = ({ periods, createPeriod, deletePeriod, bulkAddYouth, removeYouth }) => {
const [bulkText, setBulkText] = useState('');
const [bulkTeam, setBulkTeam] = useState<'PF' | 'TU'>('PF');
const [expandedPeriod, setExpandedPeriod] = useState<string | null>(null);
const handleCreate = () => {
const name = (document.getElementById('periodName') as HTMLInputElement).value;
const start = (document.getElementById('periodStart') as HTMLInputElement).value;
if (name && start) createPeriod(name, start);
};
// FEATURE: Confirmation Prompts
const handleDeletePeriod = (id: string) => {
if (window.confirm('Är du säker på att du vill ta bort hela perioden? All närvarodata kopplad till perioden kommer försvinna!')) {
deletePeriod(id);
}
};
const handleRemoveYouth = (periodId: string, youthId: string, youthName: string) => {
if (window.confirm(`Är du säker på att du vill ta bort ${youthName} från perioden?`)) {
removeYouth(periodId, youthId);
}
};
return (
<div className="space-y-6 animate-fade-in">
{/* Create New Period */}
<div className="bg-eggshell border-2 border-slate-teal/20 p-6 rounded-2xl shadow-sm">
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center mb-6">
<CalendarRange className="mr-3 text-slate-teal" size={24} />
Skapa Ny Period
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<input type="text" id="periodName" placeholder="T.ex. Period 1" className="w-full bg-white border border-slate-teal/20 p-3 rounded-lg font-bold" />
<input type="date" id="periodStart" className="w-full bg-white border border-slate-teal/20 p-3 rounded-lg font-bold" />
<button onClick={handleCreate} className="bg-slate-teal text-eggshell font-black uppercase px-6 py-3 rounded-lg hover:bg-ebony transition-colors">
Starta
</button>
</div>
</div>
{/* List Existing Periods */}
{periods.map(period => (
<div key={period.id} className="bg-eggshell border-2 border-slate-teal/20 rounded-2xl shadow-sm overflow-hidden">
<div className="p-6 flex justify-between items-center cursor-pointer hover:bg-slate-teal/5" onClick={() => setExpandedPeriod(expandedPeriod === period.id ? null : period.id)}>
<div>
<h3 className="text-lg font-black text-ebony uppercase">{period.name}</h3>
<p className="text-sm font-bold text-moss">{period.startDate} till {period.endDate} {period.youthList.length} ungdomar</p>
</div>
<button onClick={(e) => { e.stopPropagation(); handleDeletePeriod(period.id); }} className="text-goldenrod/80 hover:text-goldenrod p-2">
<Trash2 size={20} />
</button>
</div>
{/* Expandable Youth Management */}
{expandedPeriod === period.id && (
<div className="p-6 border-t border-slate-teal/10 bg-slate-teal/5">
<h4 className="font-black text-ebony mb-4 flex items-center"><UserPlus size={18} className="mr-2 text-slate-teal" /> Bulk-lägg till ungdomar</h4>
<div className="flex flex-col md:flex-row gap-4 mb-6">
<textarea
value={bulkText}
onChange={(e) => setBulkText(e.target.value)}
placeholder="Klistra in namn, ett per rad.&#10;T.ex. 'Anna' eller 'Anna, TU'"
className="w-full h-24 bg-white border border-slate-teal/20 p-3 rounded-lg font-bold resize-none"
/>
<div className="flex flex-col gap-2 shrink-0">
<select value={bulkTeam} onChange={(e) => setBulkTeam(e.target.value as 'PF' | 'TU')} className="bg-white border border-slate-teal/20 p-3 rounded-lg font-bold">
<option value="PF">Standard: PF</option>
<option value="TU">Standard: TU</option>
</select>
<button onClick={() => { bulkAddYouth(period.id, bulkText, bulkTeam); setBulkText(''); }} className="bg-slate-teal text-eggshell font-black uppercase px-6 py-3 rounded-lg hover:bg-ebony transition-colors h-full">
Importera
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{period.youthList.map(youth => (
<div key={youth.id} className="flex justify-between items-center bg-white border border-slate-teal/10 p-3 rounded-lg">
<div className="flex items-center gap-3">
<span className={`w-3 h-3 rounded-full ${youth.team === 'PF' ? 'bg-gold' : 'bg-seafoam'}`}></span>
<span className="font-bold text-ebony">{youth.name} <span className="text-xs text-moss">({youth.team})</span></span>
</div>
<button onClick={() => handleRemoveYouth(period.id, youth.id, youth.name)} className="text-goldenrod hover:text-red-500"><Trash2 size={16} /></button>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
);
};
+80
View File
@@ -0,0 +1,80 @@
// src/pages/admin/adminTypes.ts
export type Role = 'Admin' | 'Staff' | 'Viewer';
export const MOCK_USERS: { id: string, name: string, pin: string, role: Role }[] = [
{ id: '1', name: 'William', pin: '1111', role: 'Admin' },
{ id: '2', name: 'Oliver', pin: '2222', role: 'Admin' },
{ id: '3', name: 'Vikarie / Gäst', pin: '3333', role: 'Staff' },
{ id: '4', name: 'Ungdom (Endast visning)', pin: '0000', role: 'Viewer' }
];
export interface Youth {
id: string;
name: string;
team: 'PF' | 'TU';
}
export interface Period {
id: string;
name: string;
startDate: string;
endDate: string;
youthList: Youth[];
}
export interface AttendanceEntry {
hoursWorked: number;
weightedHours: number;
status: 'Absent' | 'Late' | 'Present' | 'Pending';
note?: string;
date: string;
youthId: string;
shiftId: 'MORNING' | 'AFTERNOON';
}
export type AttendanceDataMap = Record<string, AttendanceEntry>;
export const toIsoDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
export const isWeekend = (dateStr: string): boolean => {
const day = new Date(dateStr + 'T12:00:00').getDay();
return day === 0 || day === 6;
};
export const calculateShiftDuration = (timeStr?: string): number => {
if (!timeStr || timeStr === 'Ledig') return 0;
const matches = timeStr.match(/(\d{1,2}):(\d{2})/g);
if (!matches || matches.length < 2) return 0;
const parse = (t: string) => {
const [h, m] = t.split(':').map(Number);
return h + (m / 60);
};
return parse(matches[1]) - parse(matches[0]);
};
export const getAttendanceKey = (date: string, youthId: string, shiftId: string) => `${date}|${youthId}|${shiftId}`;
export const formatTimeHHMM = (decimalHours: number): string => {
if (decimalHours <= 0) return "0:00";
const hrs = Math.floor(decimalHours);
const mins = Math.round((decimalHours - hrs) * 60);
return `${hrs}:${mins.toString().padStart(2, '0')}`;
};
export const parseTimeInput = (input: string): number => {
if (!input) return 0;
const cleanInput = input.trim().replace(',', '.');
if (cleanInput.includes(':')) {
const [h, m] = cleanInput.split(':').map(Number);
return (h || 0) + ((m || 0) / 60);
}
return parseFloat(cleanInput) || 0;
};
+120
View File
@@ -0,0 +1,120 @@
// src/pages/admin/useAdminState.ts
import { useEffect, useState } from 'react';
import { type AttendanceDataMap, type Period, type Youth, getAttendanceKey, isWeekend, toIsoDate } from './adminTypes';
export const useAdminState = () => {
const [periods, setPeriods] = useState<Period[]>([]);
const [attendance, setAttendance] = useState<AttendanceDataMap>({});
const [activePeriodId, setActivePeriodId] = useState<string>('');
useEffect(() => {
if (!activePeriodId && periods.length > 0) {
setActivePeriodId(periods[0].id);
}
}, [periods, activePeriodId]);
const createPeriod = (name: string, startDateStr: string) => {
const start = new Date(startDateStr + 'T12:00:00');
const end = new Date(start);
end.setDate(start.getDate() + 20);
const newPeriod: Period = {
id: Date.now().toString(),
name,
startDate: toIsoDate(start),
endDate: toIsoDate(end),
youthList: []
};
setPeriods([...periods, newPeriod]);
setActivePeriodId(newPeriod.id);
};
const deletePeriod = (periodId: string) => {
const updatedPeriods = periods.filter(p => p.id !== periodId);
setPeriods(updatedPeriods);
if (activePeriodId === periodId) {
setActivePeriodId(updatedPeriods.length > 0 ? updatedPeriods[0].id : '');
}
};
const bulkAddYouth = (periodId: string, text: string, defaultTeam: 'PF' | 'TU') => {
const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0);
const newYouth: Youth[] = lines.map((line, idx) => {
const parts = line.split(/[,|-]/).map(p => p.trim());
const name = parts[0];
let team = defaultTeam;
if (parts.length > 1) {
const teamInput = parts[1].toUpperCase();
if (teamInput === 'PF' || teamInput === 'TU') team = teamInput;
}
return { id: `bulk-${Date.now()}-${idx}`, name, team };
});
setPeriods(periods.map(p => {
if (p.id === periodId) {
return { ...p, youthList: [...p.youthList, ...newYouth] };
}
return p;
}));
};
const removeYouth = (periodId: string, youthId: string) => {
setPeriods(periods.map(p => {
if (p.id === periodId) {
return { ...p, youthList: p.youthList.filter(y => y.id !== youthId) };
}
return p;
}));
};
const setManualAttendance = (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON', status: 'Absent' | 'Late' | 'Present', hoursManual: number = 0, note?: string) => {
const weight = isWeekend(date) ? 1.5 : 1.0;
let weightedHours = 0;
let hoursWorked = 0;
if (status === 'Late' || status === 'Present') {
hoursWorked = hoursManual;
weightedHours = hoursManual * weight;
}
const key = getAttendanceKey(date, youthId, shiftId);
setAttendance(prev => ({
...prev,
[key]: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: note || '' }
}));
};
// NEW: Adds an entry safely as Pending with 0 hours
const addPendingAttendance = (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => {
const key = getAttendanceKey(date, youthId, shiftId);
setAttendance(prev => ({
...prev,
[key]: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' }
}));
};
const removeAttendanceEntry = (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => {
const key = getAttendanceKey(date, youthId, shiftId);
setAttendance(prev => {
const next = { ...prev };
delete next[key];
return next;
});
};
return {
periods,
attendance,
activePeriodId,
setActivePeriodId,
createPeriod,
deletePeriod,
bulkAddYouth,
removeYouth,
setManualAttendance,
addPendingAttendance,
removeAttendanceEntry
};
};