Added Admin page
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
*.csv
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -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
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Phone, AlertTriangle, MapPin, HeartPulse } from 'lucide-react';
|
import { Phone, AlertTriangle, MapPin, HeartPulse } from 'lucide-react';
|
||||||
|
import { SafePhoneLink } from '../components/PhoneLinks';
|
||||||
|
|
||||||
export const Emergency: React.FC = () => {
|
export const Emergency: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
@@ -65,19 +66,19 @@ export const Emergency: React.FC = () => {
|
|||||||
<p className="text-ebony/80 font-medium mb-4 text-sm">
|
<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.
|
När situationen är under kontroll, meddela alltid arbetsledaren om vad som inträffat.
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div className="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>
|
<span className="text-base font-bold text-ebony">William Söderberg</span>
|
||||||
<div className='flex gap-2 flex-wrap justify-end'>
|
<div className='flex gap-2 flex-wrap md: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>
|
<SafePhoneLink parts={['072', '247', '02', '91']} display="072-247 02 91" />
|
||||||
<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>
|
<SafePhoneLink parts={['078', '389', '355', '81', '0604']} display="078-389 35 58 10 604" />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<span className="text-base font-bold text-ebony">Oliver Nilsson</span>
|
||||||
<div className='flex gap-2 flex-wrap justify-end'>
|
<div className='flex gap-2 flex-wrap md: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>
|
<SafePhoneLink parts={['072', '717', '74', '40']} display="072-717 74 40" />
|
||||||
<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>
|
<SafePhoneLink parts={['078', '389', '355', '81', '0605']} display="078-389 35 58 10 605" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+10
-8
@@ -3,6 +3,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { AlertTriangle, PhoneCall, ArrowRight } from 'lucide-react';
|
import { AlertTriangle, PhoneCall, ArrowRight } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
// Import the component!
|
||||||
|
import { SafePhoneLink } from '../components/PhoneLinks';
|
||||||
|
|
||||||
export const Home: React.FC = () => {
|
export const Home: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
@@ -53,18 +55,18 @@ export const Home: React.FC = () => {
|
|||||||
<h3 className="font-black text-ebony uppercase tracking-widest text-sm">Snabbkontakt</h3>
|
<h3 className="font-black text-ebony uppercase tracking-widest text-sm">Snabbkontakt</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-4">
|
<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>
|
<span className="text-base font-bold text-ebony">William Söderberg</span>
|
||||||
<div className='flex gap-2 flex-wrap justify-end'>
|
<div className='flex gap-2 flex-wrap md: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>
|
<SafePhoneLink parts={['072', '247', '02', '91']} display="072-247 02 91" />
|
||||||
<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>
|
<SafePhoneLink parts={['078', '389', '355', '81', '0604']} display="078-389 35 58 10 604" />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<span className="text-base font-bold text-ebony">Oliver Nilsson</span>
|
||||||
<div className='flex gap-2 flex-wrap justify-end'>
|
<div className='flex gap-2 flex-wrap md: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>
|
<SafePhoneLink parts={['072', '717', '74', '40']} display="072-717 74 40" />
|
||||||
<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>
|
<SafePhoneLink parts={['078', '389', '355', '81', '0605']} display="078-389 35 58 10 605" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,53 +1,55 @@
|
|||||||
// src/pages/admin/AdminDashboard.tsx
|
// src/pages/admin/AdminDashboard.tsx
|
||||||
|
|
||||||
|
import { CalendarRange, ClipboardCheck, Loader2, Lock, Unlock, Users as UsersIcon } from 'lucide-react';
|
||||||
import React, { useState } from '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 = () => {
|
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 [pin, setPin] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<'setup' | 'today' | 'report'>('report');
|
||||||
const CORRECT_PIN = "1234"; // You can change this later
|
const adminState = useAdminState();
|
||||||
|
|
||||||
const handleLogin = (e: React.FormEvent) => {
|
const handleLogin = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (pin === CORRECT_PIN) {
|
setIsLoading(true);
|
||||||
setIsAuthenticated(true);
|
setTimeout(() => {
|
||||||
setError('');
|
const user = MOCK_USERS.find(u => u.id === selectedUserId && u.pin === pin);
|
||||||
} else {
|
if (user) {
|
||||||
setError('Ogiltig pinkod.');
|
setCurrentUser(user);
|
||||||
setPin('');
|
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 (!currentUser) {
|
||||||
if (!isAuthenticated) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-20 animate-fade-in px-4">
|
<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-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">
|
<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>
|
||||||
<Lock size={32} className="text-slate-teal" />
|
<h2 className="text-2xl font-black text-ebony uppercase mb-6">Admin Login</h2>
|
||||||
</div>
|
<form onSubmit={handleLogin} className="space-y-4 text-left">
|
||||||
<h2 className="text-2xl font-black text-ebony tracking-wide uppercase mb-2">Admin Login</h2>
|
<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">
|
||||||
<p className="text-sm font-medium text-ebony/70 mb-6">Ange pinkod för att hantera schema och närvaro.</p>
|
{MOCK_USERS.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
|
</select>
|
||||||
<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" />
|
||||||
<input
|
<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">
|
||||||
type="password"
|
{isLoading ? <Loader2 className="animate-spin mx-auto" /> : 'Logga in'}
|
||||||
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
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="space-y-8 animate-fade-in w-full">
|
<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 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 uppercase flex items-center"><Unlock className="mr-3 text-seafoam" size={28} /> Admin</h1>
|
||||||
<h1 className="text-3xl font-black text-slate-teal tracking-tight uppercase flex items-center">
|
<div className="text-right">
|
||||||
<Unlock className="mr-3 text-seafoam" size={28} />
|
<p className="text-xs font-bold text-moss mb-1">Inloggad: {currentUser.name}</p>
|
||||||
Admin Dashboard
|
<button onClick={() => { setCurrentUser(null); setPin(''); }} className="text-sm font-bold text-ebony/60 hover:text-goldenrod">Logga ut</button>
|
||||||
</h1>
|
|
||||||
<p className="text-moss font-bold mt-2 ml-10">Hantera personal, scheman och närvaro.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => setIsAuthenticated(false)}
|
|
||||||
className="text-sm font-bold text-ebony/60 hover:text-goldenrod transition-colors"
|
|
||||||
>
|
|
||||||
Logga ut
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Attendance Tracker Section Shell */}
|
{availableTabs.length > 1 && (
|
||||||
<section className="bg-eggshell border-2 border-slate-teal/20 rounded-2xl p-6 shadow-sm">
|
<div className="border-b border-slate-teal/20 flex gap-2">
|
||||||
<div className="flex justify-between items-center mb-6">
|
{availableTabs.map(tab => (
|
||||||
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center">
|
<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'}`}>
|
||||||
<Users className="mr-3 text-slate-teal" size={24} />
|
<tab.icon size={16} className="mr-2 hidden md:block" /> {tab.label}
|
||||||
Närvarorapport
|
</button>
|
||||||
</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>
|
|
||||||
</div>
|
</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">
|
{activeTab === 'setup' && currentUser.role === 'Admin' && <SetupTab {...adminState} />}
|
||||||
<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>
|
{activeTab === 'today' && (currentUser.role === 'Admin' || currentUser.role === 'Staff') && (
|
||||||
</div>
|
<AttendanceTab periods={adminState.periods} attendance={adminState.attendance} setManualAttendance={adminState.setManualAttendance} addPendingAttendance={adminState.addPendingAttendance} removeAttendanceEntry={adminState.removeAttendanceEntry} activePeriodId={adminState.activePeriodId} setActivePeriodId={adminState.setActivePeriodId} />
|
||||||
</section>
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'report' && (
|
||||||
|
<ReportTab periods={adminState.periods} attendance={adminState.attendance} activePeriodId={adminState.activePeriodId} setActivePeriodId={adminState.setActivePeriodId} currentUserRole={currentUser.role} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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 på 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 på detta pass...</option>
|
||||||
|
{availableExtras.map(y => (
|
||||||
|
<option key={y.id} value={y.id}>{y.name} ({y.team})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 på 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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. 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user