From 3d3ab6b26871fd480f04a934c33aa0c6011c3b89 Mon Sep 17 00:00:00 2001 From: WilliamSoderberg Date: Fri, 13 Mar 2026 18:02:55 +0100 Subject: [PATCH] Added Admin page --- .gitignore | 2 + src/components/PhoneLinks.tsx | 32 +++ src/pages/Emergency.tsx | 19 +- src/pages/Home.tsx | 18 +- src/pages/admin/AdminDashboard.tsx | 135 ++++++------- src/pages/admin/AttendanceTab.tsx | 303 +++++++++++++++++++++++++++++ src/pages/admin/ReportTab.tsx | 225 +++++++++++++++++++++ src/pages/admin/SetupTab.tsx | 108 ++++++++++ src/pages/admin/adminTypes.ts | 80 ++++++++ src/pages/admin/useAdminState.ts | 120 ++++++++++++ 10 files changed, 958 insertions(+), 84 deletions(-) create mode 100644 src/components/PhoneLinks.tsx create mode 100644 src/pages/admin/AttendanceTab.tsx create mode 100644 src/pages/admin/ReportTab.tsx create mode 100644 src/pages/admin/SetupTab.tsx create mode 100644 src/pages/admin/adminTypes.ts create mode 100644 src/pages/admin/useAdminState.ts diff --git a/.gitignore b/.gitignore index a547bf3..4262019 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +*.csv + # Logs logs *.log diff --git a/src/components/PhoneLinks.tsx b/src/components/PhoneLinks.tsx new file mode 100644 index 0000000..14fc7e6 --- /dev/null +++ b/src/components/PhoneLinks.tsx @@ -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 = ({ + 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 | React.TouchEvent) => { + e.currentTarget.href = `tel:${fullNumber}`; + }; + + return ( + + {display} + + ); +}; \ No newline at end of file diff --git a/src/pages/Emergency.tsx b/src/pages/Emergency.tsx index 0319877..407e157 100644 --- a/src/pages/Emergency.tsx +++ b/src/pages/Emergency.tsx @@ -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 = () => {

När situationen är under kontroll, meddela alltid arbetsledaren om vad som inträffat.

-
-
+
+
William Söderberg - -
+
Oliver Nilsson -
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index f8f556a..839b06e 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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 = () => {

Snabbkontakt

-
+
William Söderberg - -
+
Oliver Nilsson -
diff --git a/src/pages/admin/AdminDashboard.tsx b/src/pages/admin/AdminDashboard.tsx index 534879f..c3b08a1 100644 --- a/src/pages/admin/AdminDashboard.tsx +++ b/src/pages/admin/AdminDashboard.tsx @@ -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(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 (
-
- -
-

Admin Login

-

Ange pinkod för att hantera schema och närvaro.

- -
- 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 &&

{error}

} -
@@ -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 (
-
-

- - Admin Dashboard -

-

Hantera personal, scheman och närvaro.

+

Admin

+
+

Inloggad: {currentUser.name}

+
-
- {/* Attendance Tracker Section Shell */} -
-
-

- - Närvarorapport -

- - {/* Fake Upload Button for now */} - + {availableTabs.length > 1 && ( +
+ {availableTabs.map(tab => ( + + ))}
+ )} -
-

Ingen data uppladdad ännu.

-

Ladda upp Timeedit CSV-filen för att visa interaktiv närvarolista här.

-
-
+ {activeTab === 'setup' && currentUser.role === 'Admin' && } + + {activeTab === 'today' && (currentUser.role === 'Admin' || currentUser.role === 'Staff') && ( + + )} + + {activeTab === 'report' && ( + + )}
); }; \ No newline at end of file diff --git a/src/pages/admin/AttendanceTab.tsx b/src/pages/admin/AttendanceTab.tsx new file mode 100644 index 0000000..bf29016 --- /dev/null +++ b/src/pages/admin/AttendanceTab.tsx @@ -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 = ({ periods, attendance, setManualAttendance, addPendingAttendance, removeAttendanceEntry, activePeriodId, setActivePeriodId }) => { + const activePeriod = periods.find(p => p.id === activePeriodId); + + const [currentDate, setCurrentDate] = useState(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

Ingen period aktiv.

; + + 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 ( +
+ {/* Timeline Overview */} +
+
+

Periodöversikt

+ +
+ +
+ {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 ( + + ); + })} +
+
+ + {/* Date Navigation */} +
+
+ +
+

{dayNameStr}

+

{currentDate}

+
+ +
+ + {currentDayStats.hasWork && ( +
+ {currentDayStats.isComplete ? <> Dagen är komplett! : `Ifyllt: ${currentDayStats.completed} av ${currentDayStats.expected} pers`} +
+ )} +
+ + {/* Attendance Lists */} + {!daySchedule || !currentDayStats.hasWork ? ( +

Inga schemalagda pass denna dag.

+ ) : ( + ['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 ( +
+
+

+ + {team === 'PF' ? 'Pilgrimsfalkarna' : 'Tumlarna'} +

+
+ + {standardShift.time} ({formatTimeHHMM(rawDuration)}) + {isWknd && Helg (-30m lunch) x1.5 = +{weightedDuration.toFixed(1)}t pott} +
+
+ +
+ {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 ( +
+ +
+ {isCompleted ? :
} + + {youth.name} + {isExtra && Extra pass} + +
+ +
+ {/* Status Display Area */} + {isPending && ( + + Väntar på tid... + + )} + {isCompleted && ( + + {entry.status === 'Absent' ? entry.note : `${formatTimeHHMM(entry.hoursWorked)} arbetat (+${entry.weightedHours.toFixed(1)}t pott)`} + + )} + + {/* Action Buttons */} +
+ {(!entry || isPending) && ( + <> + + + + + )} + + {entry && ( + + )} +
+
+
+ ); + })} + + {availableExtras.length > 0 && ( +
+ +
+ )} +
+
+ ); + }) + )} +
+ ); +}; \ No newline at end of file diff --git a/src/pages/admin/ReportTab.tsx b/src/pages/admin/ReportTab.tsx new file mode 100644 index 0000000..e158806 --- /dev/null +++ b/src/pages/admin/ReportTab.tsx @@ -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 = ({ periods, attendance, activePeriodId, setActivePeriodId, currentUserRole }) => { + const activePeriod = periods.find(p => p.id === activePeriodId); + + const [expandedYouthId, setExpandedYouthId] = useState(null); + + if (!activePeriod) return

Ingen period tillgänglig.

; + + 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 ( +
+ {/* Header / Period Selector */} +
+ +
+

{activePeriod.name}

+

{activePeriod.startDate} — {activePeriod.endDate}

+
+
+ + {/* PERIOD HOUR POT REPORT */} +
+
+

+ + Timpott (Max {POT_HOUR_LIMIT}t) +

+ + {currentUserRole !== 'Viewer' && ( + + )} +
+ +
+ {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 ( +
+
+
+
+ + {youth.name} ({youth.team}) +
+ +
+ +
+ {warningStatus === 'red' && } +
+
+ {formatHours(total)} / {POT_HOUR_LIMIT}t +
+

Viktade timmar

+
+
+
+ + {isExpanded && ( +
+

+ Arbetspass & Frånvaro +

+ {timeline.length === 0 ? ( +

Ingen närvaro loggad ännu.

+ ) : ( +
+ {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 ( +
+
+ {entry.date} + + {entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag'} + + + {/* NEW: Display the extra shift badge! */} + {isExtra && ( + + Extra pass + + )} + + {isWknd && Helg} +
+ +
+ {entry.status === 'Pending' ? ( + Väntar på registrering + ) : entry.status === 'Absent' ? ( + {entry.note || 'Frånvarande'} + ) : ( + {entry.status === 'Late' ? 'Manuell tid' : 'Närvarande'} + )} + +
+ {formatTimeHHMM(entry.hoursWorked)} arbetat + → +{entry.weightedHours.toFixed(1)} pott +
+
+
+ ); + })} +
+ )} +
+ )} +
+ ); + })} + {activePeriod.youthList.length === 0 && ( +

Inga ungdomar i denna period.

+ )} +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/pages/admin/SetupTab.tsx b/src/pages/admin/SetupTab.tsx new file mode 100644 index 0000000..41a7f9f --- /dev/null +++ b/src/pages/admin/SetupTab.tsx @@ -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 = ({ periods, createPeriod, deletePeriod, bulkAddYouth, removeYouth }) => { + const [bulkText, setBulkText] = useState(''); + const [bulkTeam, setBulkTeam] = useState<'PF' | 'TU'>('PF'); + const [expandedPeriod, setExpandedPeriod] = useState(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 ( +
+ {/* Create New Period */} +
+

+ + Skapa Ny Period +

+
+ + + +
+
+ + {/* List Existing Periods */} + {periods.map(period => ( +
+
setExpandedPeriod(expandedPeriod === period.id ? null : period.id)}> +
+

{period.name}

+

{period.startDate} till {period.endDate} • {period.youthList.length} ungdomar

+
+ +
+ + {/* Expandable Youth Management */} + {expandedPeriod === period.id && ( +
+

Bulk-lägg till ungdomar

+
+