UI tweaks
This commit is contained in:
+80
-182
@@ -11,76 +11,46 @@ import scheduleData from '../data/schedule.json';
|
|||||||
import { type AttendanceDataMap, calculateShiftDuration, formatTimeHHMM, getAttendanceKey, isWeekend, parseTimeInput, type Period, toIsoDate } from './adminTypes';
|
import { type AttendanceDataMap, calculateShiftDuration, formatTimeHHMM, getAttendanceKey, isWeekend, parseTimeInput, type Period, toIsoDate } from './adminTypes';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
periods: Period[];
|
periods: Period[]; attendance: AttendanceDataMap;
|
||||||
attendance: AttendanceDataMap;
|
|
||||||
setManualAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON', status: 'Absent' | 'Late' | 'Present', hours: number, note?: string) => void;
|
setManualAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON', status: 'Absent' | 'Late' | 'Present', hours: number, note?: string) => void;
|
||||||
bulkSetManualAttendance: (records: any[]) => void;
|
bulkSetManualAttendance: (records: any[]) => void;
|
||||||
addPendingAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
|
addPendingAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
|
||||||
removeAttendanceEntry: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
|
removeAttendanceEntry: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
|
||||||
activePeriodId: string;
|
activePeriodId: string; setActivePeriodId: (id: string) => void;
|
||||||
setActivePeriodId: (id: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: Dynamic Shift Helper
|
|
||||||
export const getTeamShiftInfo = (daySchedule: any, team: 'PF' | 'TU') => {
|
export const getTeamShiftInfo = (daySchedule: any, team: 'PF' | 'TU') => {
|
||||||
if (!daySchedule) return { time: 'Ledig', shiftId: 'MORNING' as 'MORNING' | 'AFTERNOON' };
|
if (!daySchedule) return { time: 'Ledig', shiftId: 'MORNING' as 'MORNING' | 'AFTERNOON' };
|
||||||
|
|
||||||
const pfTime = daySchedule.pilgrimsfalkarna?.time || 'Ledig';
|
const pfTime = daySchedule.pilgrimsfalkarna?.time || 'Ledig';
|
||||||
const tuTime = daySchedule.tumlarna?.time || 'Ledig';
|
const tuTime = daySchedule.tumlarna?.time || 'Ledig';
|
||||||
|
|
||||||
const pfStart = pfTime !== 'Ledig' ? parseInt(pfTime.match(/(\d+):/)?.[1] || '99') : 99;
|
const pfStart = pfTime !== 'Ledig' ? parseInt(pfTime.match(/(\d+):/)?.[1] || '99') : 99;
|
||||||
const tuStart = tuTime !== 'Ledig' ? parseInt(tuTime.match(/(\d+):/)?.[1] || '99') : 99;
|
const tuStart = tuTime !== 'Ledig' ? parseInt(tuTime.match(/(\d+):/)?.[1] || '99') : 99;
|
||||||
|
if (team === 'PF') return { time: pfTime, shiftId: (pfStart > tuStart) ? 'AFTERNOON' : 'MORNING' as 'MORNING' | 'AFTERNOON' };
|
||||||
if (team === 'PF') {
|
else return { time: tuTime, shiftId: (tuStart > pfStart) ? 'AFTERNOON' : (pfStart === tuStart ? 'AFTERNOON' : 'MORNING') as 'MORNING' | 'AFTERNOON' };
|
||||||
return { time: pfTime, shiftId: (pfStart > tuStart) ? 'AFTERNOON' : 'MORNING' as 'MORNING' | 'AFTERNOON' };
|
|
||||||
} else {
|
|
||||||
return { time: tuTime, shiftId: (tuStart > pfStart) ? 'AFTERNOON' : (pfStart === tuStart ? 'AFTERNOON' : 'MORNING') as 'MORNING' | 'AFTERNOON' };
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDaysInPeriod = (start: string, end: string) => {
|
const getDaysInPeriod = (start: string, end: string) => {
|
||||||
const days = [];
|
const days = []; let curr = new Date(start + 'T12:00:00'); const endDate = new Date(end + 'T12:00:00');
|
||||||
let curr = new Date(start + 'T12:00:00');
|
while (curr <= endDate) { days.push(toIsoDate(curr)); curr.setDate(curr.getDate() + 1); }
|
||||||
const endDate = new Date(end + 'T12:00:00');
|
|
||||||
while (curr <= endDate) {
|
|
||||||
days.push(toIsoDate(curr));
|
|
||||||
curr.setDate(curr.getDate() + 1);
|
|
||||||
}
|
|
||||||
return days;
|
return days;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDailyCompletionStats = (date: string, period: Period, attendance: AttendanceDataMap) => {
|
const getDailyCompletionStats = (date: string, period: Period, attendance: AttendanceDataMap) => {
|
||||||
const dayNameStr = new Date(date + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase();
|
const dayNameStr = new Date(date + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase();
|
||||||
const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr);
|
const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr);
|
||||||
|
|
||||||
if (!daySchedule) return { expected: 0, completed: 0, isComplete: true, hasWork: false };
|
if (!daySchedule) return { expected: 0, completed: 0, isComplete: true, hasWork: false };
|
||||||
|
|
||||||
let expected = 0;
|
let expected = 0; let completed = 0; let hasWork = false;
|
||||||
let completed = 0;
|
|
||||||
let hasWork = false;
|
|
||||||
|
|
||||||
// Use dynamic shift checking
|
|
||||||
if (daySchedule.pilgrimsfalkarna && daySchedule.pilgrimsfalkarna.time !== 'Ledig') {
|
if (daySchedule.pilgrimsfalkarna && daySchedule.pilgrimsfalkarna.time !== 'Ledig') {
|
||||||
hasWork = true;
|
hasWork = true; const { shiftId } = getTeamShiftInfo(daySchedule, 'PF');
|
||||||
const { shiftId } = getTeamShiftInfo(daySchedule, 'PF');
|
const pfYouth = period.youthList.filter(y => y.team === 'PF'); expected += pfYouth.length;
|
||||||
const pfYouth = period.youthList.filter(y => y.team === 'PF');
|
pfYouth.forEach(y => { const entry = attendance[getAttendanceKey(date, y.id, shiftId)]; if (entry && entry.status !== 'Pending') completed++; });
|
||||||
expected += pfYouth.length;
|
|
||||||
pfYouth.forEach(y => {
|
|
||||||
const entry = attendance[getAttendanceKey(date, y.id, shiftId)];
|
|
||||||
if (entry && entry.status !== 'Pending') completed++;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (daySchedule.tumlarna && daySchedule.tumlarna.time !== 'Ledig') {
|
if (daySchedule.tumlarna && daySchedule.tumlarna.time !== 'Ledig') {
|
||||||
hasWork = true;
|
hasWork = true; const { shiftId } = getTeamShiftInfo(daySchedule, 'TU');
|
||||||
const { shiftId } = getTeamShiftInfo(daySchedule, 'TU');
|
const tuYouth = period.youthList.filter(y => y.team === 'TU'); expected += tuYouth.length;
|
||||||
const tuYouth = period.youthList.filter(y => y.team === 'TU');
|
tuYouth.forEach(y => { const entry = attendance[getAttendanceKey(date, y.id, shiftId)]; if (entry && entry.status !== 'Pending') completed++; });
|
||||||
expected += tuYouth.length;
|
|
||||||
tuYouth.forEach(y => {
|
|
||||||
const entry = attendance[getAttendanceKey(date, y.id, shiftId)];
|
|
||||||
if (entry && entry.status !== 'Pending') completed++;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { expected, completed, isComplete: expected > 0 && completed >= expected, hasWork };
|
return { expected, completed, isComplete: expected > 0 && completed >= expected, hasWork };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,25 +61,17 @@ export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualA
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activePeriod) {
|
if (activePeriod) {
|
||||||
const today = toIsoDate(new Date());
|
const today = toIsoDate(new Date());
|
||||||
if (today >= activePeriod.startDate && today <= activePeriod.endDate) {
|
if (today >= activePeriod.startDate && today <= activePeriod.endDate) setCurrentDate(today);
|
||||||
setCurrentDate(today);
|
else setCurrentDate(activePeriod.startDate);
|
||||||
} else {
|
|
||||||
setCurrentDate(activePeriod.startDate);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [activePeriodId, activePeriod]);
|
}, [activePeriodId, activePeriod]);
|
||||||
|
|
||||||
if (!activePeriod) return <p className="text-center font-bold text-slate-teal mt-10">Ingen period aktiv.</p>;
|
if (!activePeriod) return <p className="text-center font-bold text-slate-teal mt-10">Ingen period aktiv.</p>;
|
||||||
|
|
||||||
const changeDate = (days: number) => {
|
const changeDate = (days: number) => {
|
||||||
const newDateObj = new Date(currentDate + 'T12:00:00');
|
const newDateObj = new Date(currentDate + 'T12:00:00'); newDateObj.setDate(newDateObj.getDate() + days);
|
||||||
newDateObj.setDate(newDateObj.getDate() + days);
|
const startObj = new Date(activePeriod.startDate + 'T12:00:00'); const endObj = new Date(activePeriod.endDate + 'T12:00:00');
|
||||||
const startObj = new Date(activePeriod.startDate + 'T12:00:00');
|
if (newDateObj >= startObj && newDateObj <= endObj) setCurrentDate(toIsoDate(newDateObj));
|
||||||
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 dayNameStr = new Date(currentDate + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' });
|
||||||
@@ -125,7 +87,6 @@ export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualA
|
|||||||
if (actualHours > 0) setManualAttendance(currentDate, youthId, shiftId, 'Present', actualHours);
|
if (actualHours > 0) setManualAttendance(currentDate, youthId, shiftId, 'Present', actualHours);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sort teams chronologically
|
|
||||||
const teamsToRender = ['PF', 'TU'].sort((a, b) => {
|
const teamsToRender = ['PF', 'TU'].sort((a, b) => {
|
||||||
const timeA = getTeamShiftInfo(daySchedule, a as 'PF' | 'TU').time;
|
const timeA = getTeamShiftInfo(daySchedule, a as 'PF' | 'TU').time;
|
||||||
const timeB = getTeamShiftInfo(daySchedule, b as 'PF' | 'TU').time;
|
const timeB = getTeamShiftInfo(daySchedule, b as 'PF' | 'TU').time;
|
||||||
@@ -136,71 +97,56 @@ export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualA
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* Timeline Overview */}
|
<div className="bg-eggshell border border-slate-teal/10 p-3 sm:p-5 rounded-3xl shadow-sm">
|
||||||
<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-3">
|
||||||
<div className="flex justify-between items-center mb-1">
|
<h3 className="text-xs font-black text-slate-teal uppercase tracking-widest">Periodöversikt</h3>
|
||||||
<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/10 py-1.5 px-3 rounded-xl font-bold text-slate-teal text-xs cursor-pointer focus:outline-none shadow-sm">
|
||||||
<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>)}
|
{periods.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex overflow-x-auto gap-2.5 pb-3 pt-1 px-2 scrollbar-hide">
|
||||||
<div className="flex overflow-x-auto gap-3 pb-6 pt-4 px-2 scrollbar-hide">
|
|
||||||
{timelineDays.map(day => {
|
{timelineDays.map(day => {
|
||||||
const stats = getDailyCompletionStats(day, activePeriod, attendance);
|
const stats = getDailyCompletionStats(day, activePeriod, attendance);
|
||||||
const isPast = day < todayIso;
|
|
||||||
const isToday = day === todayIso;
|
|
||||||
const isSelected = day === currentDate;
|
const isSelected = day === currentDate;
|
||||||
|
let bgClass = "bg-white text-ebony border-slate-teal/10";
|
||||||
let bgClass = "bg-white text-ebony border-slate-teal/20";
|
|
||||||
if (!stats.hasWork) bgClass = "bg-slate-teal/5 text-ebony/40 border-transparent";
|
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 (stats.isComplete) bgClass = "bg-moss text-white border-moss shadow-inner";
|
||||||
else if (isPast || isToday) bgClass = "bg-goldenrod text-white border-goldenrod";
|
else if (day < todayIso || day === todayIso) bgClass = "bg-goldenrod text-white border-goldenrod shadow-inner";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button key={day} onClick={() => setCurrentDate(day)} className={`flex flex-col items-center justify-center min-w-14 p-2 rounded-xl border transition-colors ${bgClass} ${isSelected ? 'ring-2 ring-slate-teal ring-offset-2 ring-offset-eggshell' : 'hover:brightness-95 shadow-sm'}`}>
|
||||||
key={day}
|
<span className="text-[10px] font-black uppercase tracking-wider">{new Date(day + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'short' })}</span>
|
||||||
onClick={() => setCurrentDate(day)}
|
<span className="text-sm font-black">{new Date(day + 'T12:00:00').getDate()}/{new Date(day + 'T12:00:00').getMonth() + 1}</span>
|
||||||
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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date Navigation */}
|
<div className="flex flex-row justify-between items-center bg-white border border-slate-teal/10 p-3 sm:p-4 rounded-3xl shadow-sm gap-4">
|
||||||
<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">
|
<button onClick={() => changeDate(-1)} className="p-3 text-slate-teal bg-eggshell/50 hover:bg-slate-teal/10 rounded-2xl transition-colors"><ChevronLeft size={24} /></button>
|
||||||
<div className="flex items-center gap-4 w-full md:w-auto justify-between md:justify-start">
|
<div className="text-center flex-1">
|
||||||
<button onClick={() => changeDate(-1)} className="p-2 text-slate-teal hover:bg-slate-teal/10 rounded-full transition-colors"><ChevronLeft size={24} /></button>
|
<h2 className="text-lg font-black text-ebony capitalize mb-0.5">{dayNameStr}</h2>
|
||||||
<div className="text-center w-40">
|
<p className="text-[11px] font-black uppercase tracking-widest text-moss">{currentDate}</p>
|
||||||
<h2 className="text-lg font-black text-ebony capitalize">{dayNameStr}</h2>
|
|
||||||
<p className="text-xs font-bold text-moss">{currentDate}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => changeDate(1)} className="p-2 text-slate-teal hover:bg-slate-teal/10 rounded-full transition-colors"><ChevronRight size={24} /></button>
|
<button onClick={() => changeDate(1)} className="p-3 text-slate-teal bg-eggshell/50 hover:bg-slate-teal/10 rounded-2xl transition-colors"><ChevronRight size={24} /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* FIX: Status banner is fully restored and green when done! */}
|
||||||
{currentDayStats.hasWork && (
|
{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'}`}>
|
<div className={`px-5 py-3 rounded-2xl font-black text-xs uppercase tracking-widest flex items-center justify-center shadow-sm border transition-colors ${currentDayStats.isComplete ? 'bg-moss/10 text-moss border-moss/20' : 'bg-goldenrod/10 text-goldenrod border-goldenrod/20'}`}>
|
||||||
{currentDayStats.isComplete ? <><CheckCircle size={18} className="mr-2" /> Dagen är komplett!</> : `Ifyllt: ${currentDayStats.completed} av ${currentDayStats.expected} pers`}
|
{currentDayStats.isComplete ? <><CheckCircle size={16} className="mr-2" /> All närvaro rapporterad</> : `Ifyllt: ${currentDayStats.completed} av ${currentDayStats.expected} pers`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Attendance Lists */}
|
|
||||||
{!daySchedule || !currentDayStats.hasWork ? (
|
{!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>
|
<div className="bg-eggshell border border-slate-teal/10 p-8 rounded-3xl text-center shadow-sm">
|
||||||
|
<p className="font-black text-sm text-ebony">Inga schemalagda pass denna dag.</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
teamsToRender.map(teamStr => {
|
teamsToRender.map(teamStr => {
|
||||||
const team = teamStr as 'PF' | 'TU';
|
const team = teamStr as 'PF' | 'TU';
|
||||||
const { time: standardTime, shiftId } = getTeamShiftInfo(daySchedule, team);
|
const { time: standardTime, shiftId } = getTeamShiftInfo(daySchedule, team);
|
||||||
|
|
||||||
if (standardTime === 'Ledig') return null;
|
if (standardTime === 'Ledig') return null;
|
||||||
|
|
||||||
const rawDuration = calculateShiftDuration(standardTime);
|
const rawDuration = calculateShiftDuration(standardTime);
|
||||||
@@ -211,66 +157,40 @@ export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualA
|
|||||||
|
|
||||||
const scheduledYouth = activePeriod.youthList.filter(y => y.team === team);
|
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 extraYouth = activePeriod.youthList.filter(y => y.team !== team && attendance[getAttendanceKey(currentDate, y.id, shiftId)]);
|
||||||
|
|
||||||
// Display youth sorted by name alphabetically
|
|
||||||
const displayYouth = [...scheduledYouth, ...extraYouth].sort((a, b) => a.name.localeCompare(b.name));
|
const displayYouth = [...scheduledYouth, ...extraYouth].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
const availableExtras = activePeriod.youthList.filter(y => !displayYouth.some(dy => dy.id === y.id)).sort((a, b) => a.name.localeCompare(b.name));
|
const availableExtras = activePeriod.youthList.filter(y => !displayYouth.some(dy => dy.id === y.id)).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
const handleQuickLogAll = () => {
|
const handleQuickLogAll = () => {
|
||||||
const recordsToUpdate: any[] = [];
|
const recordsToUpdate: any[] = [];
|
||||||
|
|
||||||
displayYouth.forEach(y => {
|
displayYouth.forEach(y => {
|
||||||
const entry = attendance[getAttendanceKey(currentDate, y.id, shiftId)];
|
const entry = attendance[getAttendanceKey(currentDate, y.id, shiftId)];
|
||||||
if (!entry || entry.status === 'Pending') {
|
if (!entry || entry.status === 'Pending') {
|
||||||
recordsToUpdate.push({
|
recordsToUpdate.push({ date: currentDate, youthId: y.id, shiftId, status: 'Present', hoursWorked: actualDuration, weightedHours: weightedDuration, note: '' });
|
||||||
date: currentDate,
|
|
||||||
youthId: y.id,
|
|
||||||
shiftId: shiftId,
|
|
||||||
status: 'Present',
|
|
||||||
hoursWorked: actualDuration,
|
|
||||||
weightedHours: weightedDuration,
|
|
||||||
note: ''
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (recordsToUpdate.length > 0) {
|
if (recordsToUpdate.length > 0) bulkSetManualAttendance(recordsToUpdate);
|
||||||
bulkSetManualAttendance(recordsToUpdate);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={team} className="bg-eggshell border-2 border-slate-teal/20 p-4 md:p-6 rounded-2xl shadow-sm">
|
<div key={team} className="bg-eggshell border border-slate-teal/10 p-4 md:p-5 rounded-3xl shadow-sm">
|
||||||
<div className="flex flex-col md:flex-row md:justify-between md:items-center mb-6 gap-3">
|
<div className="flex flex-wrap justify-between items-center mb-4 gap-3">
|
||||||
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center">
|
<div className="flex items-center">
|
||||||
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${team === 'PF' ? 'bg-gold' : 'bg-seafoam'} mr-3 shrink-0 shadow-sm`}>
|
<div className={`flex items-center justify-center w-10 h-10 rounded-xl ${team === 'PF' ? 'bg-gold' : 'bg-seafoam'} mr-3 shadow-inner shrink-0`}>
|
||||||
<Image
|
<Image src={team === 'PF' ? falconIcon : porpoiseIcon} alt={team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare'} width={18} height={18} />
|
||||||
src={team === 'PF' ? falconIcon : porpoiseIcon}
|
|
||||||
alt={team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare'}
|
|
||||||
width={18}
|
|
||||||
height={18}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{team === 'PF' ? 'Pilgrimsfalkarna' : 'Tumlarna'}
|
<h2 className="text-base font-black text-ebony uppercase tracking-widest">{team === 'PF' ? 'Pilgrimsfalkarna' : 'Tumlarna'}</h2>
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<div className="flex 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">{standardTime} ({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)</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<div className="flex items-center gap-2 bg-white px-3 py-1.5 rounded-xl border border-slate-teal/5 text-xs text-ebony font-bold shadow-sm">
|
||||||
onClick={handleQuickLogAll}
|
<FileText size={14} className="text-slate-teal" /> {standardTime} ({formatTimeHHMM(rawDuration)})
|
||||||
className="flex items-center gap-2 bg-moss/10 text-moss hover:bg-moss/20 border border-moss/20 px-4 py-2 rounded-lg font-bold text-sm transition-colors"
|
</div>
|
||||||
title="Sätt alla som inte har en ifylld tid till 'Hela passet'"
|
<button onClick={handleQuickLogAll} className="flex items-center gap-1.5 bg-moss/10 text-moss hover:bg-moss hover:text-white px-3 py-1.5 rounded-xl font-black uppercase tracking-widest text-[10px] transition-colors shadow-sm">
|
||||||
>
|
<Zap size={14} /> Alla närvarande
|
||||||
<Zap size={16} /> Snabblogga alla
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-2">
|
||||||
{displayYouth.map(youth => {
|
{displayYouth.map(youth => {
|
||||||
const entry = attendance[getAttendanceKey(currentDate, youth.id, shiftId)];
|
const entry = attendance[getAttendanceKey(currentDate, youth.id, shiftId)];
|
||||||
const isExtra = youth.team !== team;
|
const isExtra = youth.team !== team;
|
||||||
@@ -278,39 +198,35 @@ export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualA
|
|||||||
const isCompleted = entry && !isPending;
|
const isCompleted = entry && !isPending;
|
||||||
|
|
||||||
return (
|
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 key={youth.id} className={`flex flex-col md:flex-row md:justify-between md:items-center p-3 rounded-2xl border transition-colors ${isCompleted ? 'bg-moss/5 border-moss/20' : (isPending ? 'bg-goldenrod/5 border-goldenrod/30' : 'bg-white border-slate-teal/5 shadow-sm')}`}>
|
||||||
|
<div className="flex items-center gap-3 mb-3 md:mb-0">
|
||||||
<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 bg-white"></div>}
|
||||||
{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>}
|
<div className={`flex items-center justify-center w-6 h-6 rounded-full ${youth.team === 'PF' ? 'bg-gold' : 'bg-seafoam'} shrink-0 shadow-inner`}>
|
||||||
<span className={`font-bold text-lg ${isCompleted ? 'text-moss' : 'text-ebony'}`}>
|
<Image src={youth.team === 'PF' ? falconIcon : porpoiseIcon} alt={youth.team === 'PF' ? 'PF' : 'TU'} width={12} height={12} />
|
||||||
{youth.name}
|
</div>
|
||||||
{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 className={`font-bold text-base ${isCompleted ? 'text-moss' : 'text-ebony'}`}>
|
||||||
|
{youth.name} {isExtra && <span className="ml-2 text-[10px] text-slate-teal bg-slate-teal/10 px-2 py-0.5 rounded-lg uppercase tracking-widest">Extra pass</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 w-full xl:w-auto flex-wrap">
|
<div className="flex items-center justify-end gap-3 w-full md:w-auto flex-wrap">
|
||||||
{isPending && (
|
{isPending && <span className="text-[10px] font-black uppercase tracking-widest bg-goldenrod/10 text-goldenrod px-3 py-1.5 rounded-xl">Väntar...</span>}
|
||||||
<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 && (
|
{isCompleted && (
|
||||||
<span className="text-xs font-bold bg-white text-moss px-3 py-1.5 rounded-lg border border-moss/20 shadow-sm">
|
<span className="text-xs font-black uppercase tracking-widest bg-white text-moss px-3 py-1.5 rounded-xl border border-moss/10 shadow-sm">
|
||||||
{entry.status === 'Absent' ? entry.note : `${formatTimeHHMM(entry.hoursWorked)} arbetat (+${entry.weightedHours.toFixed(1)}t pott)`}
|
{entry.status === 'Absent' ? entry.note : `${formatTimeHHMM(entry.hoursWorked)} (+${entry.weightedHours.toFixed(1)}h)`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{(!entry || isPending) && (
|
{(!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">
|
<button onClick={() => markStandardAttendance(youth.id, team as 'PF' | 'TU', shiftId)} className="bg-slate-teal/10 text-slate-teal hover:bg-slate-teal hover:text-white px-3 py-1.5 rounded-xl font-black uppercase tracking-widest text-[10px] flex items-center gap-1.5 transition-colors shadow-sm">
|
||||||
<ClipboardCheck size={16} /> Hela passet
|
<ClipboardCheck size={14} /> Hela passet
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
const input = prompt(`Timmar arbetade (t.ex. 2:30 eller 2.5):`, formatTimeHHMM(actualDuration));
|
const input = prompt(`Timmar arbetade:`, formatTimeHHMM(actualDuration));
|
||||||
const hrs = input ? parseTimeInput(input) : 0;
|
const hrs = input ? parseTimeInput(input) : 0;
|
||||||
if (hrs > 0) setManualAttendance(currentDate, youth.id, shiftId, 'Present', hrs);
|
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">
|
}} className="bg-goldenrod/10 text-goldenrod hover:bg-goldenrod hover:text-white p-1.5 rounded-xl transition-colors shadow-sm">
|
||||||
<Edit3 size={16} />
|
<Edit3 size={16} />
|
||||||
</button>
|
</button>
|
||||||
<select
|
<select
|
||||||
@@ -321,47 +237,29 @@ export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualA
|
|||||||
if (reason === 'Custom') reason = prompt('Ange anledning:') || 'Frånvarande';
|
if (reason === 'Custom') reason = prompt('Ange anledning:') || 'Frånvarande';
|
||||||
setManualAttendance(currentDate, youth.id, shiftId, 'Absent', 0, reason);
|
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"
|
className="bg-goldenrod/10 text-goldenrod px-2 py-1.5 rounded-xl font-black uppercase tracking-widest text-[10px] outline-none shadow-sm"
|
||||||
>
|
>
|
||||||
<option value="">+ Frånvaro...</option>
|
<option value="">+ Frånvaro</option>
|
||||||
<option value="Sjuk">Sjuk</option>
|
<option value="Sjuk">Sjuk</option><option value="Uteblev">Uteblev</option><option value="Ledig">Ledig</option><option value="Custom">Annan...</option>
|
||||||
<option value="Uteblev">Uteblev</option>
|
|
||||||
<option value="Ledig">Ledig</option>
|
|
||||||
<option value="Custom">Annan...</option>
|
|
||||||
</select>
|
</select>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{entry && (
|
{entry && (
|
||||||
<button
|
<button onClick={() => removeAttendanceEntry(currentDate, youth.id, shiftId)} className="text-emergency/60 hover:text-emergency p-1.5 bg-white rounded-xl border border-emergency/10 transition-colors shadow-sm">
|
||||||
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} />
|
<Undo size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{availableExtras.length > 0 && (
|
{availableExtras.length > 0 && (
|
||||||
<div className="pt-2 border-t border-slate-teal/10 mt-2">
|
<div className="pt-3 mt-3 border-t border-slate-teal/5">
|
||||||
<select
|
<select value="" onChange={(e) => { if (e.target.value) addPendingAttendance(currentDate, e.target.value, shiftId); }} className="bg-white border border-slate-teal/10 p-3 rounded-2xl font-bold text-slate-teal text-sm w-full outline-none shadow-sm">
|
||||||
value=""
|
<option value="">+ Lägg till extra person...</option>
|
||||||
onChange={(e) => {
|
{availableExtras.map(y => <option key={y.id} value={y.id}>{y.name}</option>)}
|
||||||
if (e.target.value) {
|
|
||||||
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}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+67
-115
@@ -12,11 +12,7 @@ import { type AttendanceDataMap, formatTimeHHMM, isWeekend, type Period, type Ro
|
|||||||
import { getTeamShiftInfo } from './AttendanceTab';
|
import { getTeamShiftInfo } from './AttendanceTab';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
periods: Period[];
|
periods: Period[]; attendance: AttendanceDataMap; activePeriodId: string; setActivePeriodId: (id: string) => void; currentUserRole: Role;
|
||||||
attendance: AttendanceDataMap;
|
|
||||||
activePeriodId: string;
|
|
||||||
setActivePeriodId: (id: string) => void;
|
|
||||||
currentUserRole: Role;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const POT_HOUR_LIMIT = 90;
|
const POT_HOUR_LIMIT = 90;
|
||||||
@@ -32,25 +28,18 @@ export const ReportTab: React.FC<Props> = ({ periods, attendance, activePeriodId
|
|||||||
let total = 0;
|
let total = 0;
|
||||||
for (const key in attendance) {
|
for (const key in attendance) {
|
||||||
const entry = attendance[key];
|
const entry = attendance[key];
|
||||||
if (entry.youthId === youthId && entry.date >= activePeriod.startDate && entry.date <= activePeriod.endDate) {
|
if (entry.youthId === youthId && entry.date >= activePeriod.startDate && entry.date <= activePeriod.endDate) total += entry.weightedHours;
|
||||||
total += entry.weightedHours;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTimelineForYouth = (youthId: string) => {
|
const getTimelineForYouth = (youthId: string) => {
|
||||||
return Object.values(attendance)
|
return Object.values(attendance).filter(a => a.youthId === youthId && a.date >= activePeriod.startDate && a.date <= activePeriod.endDate)
|
||||||
.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; });
|
||||||
.sort((a, b) => {
|
|
||||||
if (a.date !== b.date) return a.date.localeCompare(b.date);
|
|
||||||
return a.shiftId === 'MORNING' ? -1 : 1;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportToCSV = () => {
|
const exportToCSV = () => {
|
||||||
if (!activePeriod) return;
|
if (!activePeriod) return;
|
||||||
|
|
||||||
let csvContent = "Datum;Pass;Namn;Lag;Status;Arbetad Tid (HH:MM);Viktad Pott;Anteckning\n";
|
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);
|
const entries = Object.values(attendance).filter(a => a.date >= activePeriod.startDate && a.date <= activePeriod.endDate);
|
||||||
|
|
||||||
@@ -65,22 +54,16 @@ export const ReportTab: React.FC<Props> = ({ periods, attendance, activePeriodId
|
|||||||
entries.forEach(entry => {
|
entries.forEach(entry => {
|
||||||
const youth = activePeriod.youthList.find(y => y.id === entry.youthId);
|
const youth = activePeriod.youthList.find(y => y.id === entry.youthId);
|
||||||
if (!youth) return;
|
if (!youth) return;
|
||||||
|
const shift = isWeekend(entry.date) ? 'Hela dagen' : (entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag');
|
||||||
const isWknd = isWeekend(entry.date);
|
|
||||||
const shift = isWknd ? 'Hela dagen' : (entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag');
|
|
||||||
|
|
||||||
const teamName = youth.team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare';
|
const teamName = youth.team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare';
|
||||||
const worked = formatTimeHHMM(entry.hoursWorked);
|
const worked = formatTimeHHMM(entry.hoursWorked);
|
||||||
const weighted = entry.weightedHours.toFixed(2).replace('.', ',');
|
const weighted = entry.weightedHours.toFixed(2).replace('.', ',');
|
||||||
const note = entry.note || '';
|
const note = entry.note || '';
|
||||||
|
|
||||||
csvContent += `${entry.date};${shift};${youth.name};${teamName};${entry.status};${worked};${weighted};${note}\n`;
|
csvContent += `${entry.date};${shift};${youth.name};${teamName};${entry.status};${worked};${weighted};${note}\n`;
|
||||||
});
|
});
|
||||||
|
|
||||||
csvContent += "\nSummering (Timpott)\nNamn;Lag;Total Viktad Pott\n";
|
csvContent += "\nSummering (Timpott)\nNamn;Lag;Total Viktad Pott\n";
|
||||||
|
|
||||||
const sortedYouth = [...activePeriod.youthList].sort((a, b) => a.name.localeCompare(b.name));
|
const sortedYouth = [...activePeriod.youthList].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
sortedYouth.forEach(youth => {
|
sortedYouth.forEach(youth => {
|
||||||
const total = getPeriodHoursTotal(youth.id).toFixed(2).replace('.', ',');
|
const total = getPeriodHoursTotal(youth.id).toFixed(2).replace('.', ',');
|
||||||
const teamName = youth.team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare';
|
const teamName = youth.team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare';
|
||||||
@@ -90,149 +73,121 @@ export const ReportTab: React.FC<Props> = ({ periods, attendance, activePeriodId
|
|||||||
const blob = new Blob(["\uFEFF" + csvContent], { type: 'text/csv;charset=utf-8;' });
|
const blob = new Blob(["\uFEFF" + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.setAttribute("href", url);
|
link.setAttribute("href", url); link.setAttribute("download", `Narvaro_${activePeriod.name.replace(/ /g, '_')}.csv`);
|
||||||
link.setAttribute("download", `Narvaro_${activePeriod.name.replace(/ /g, '_')}.csv`);
|
document.body.appendChild(link); link.click(); document.body.removeChild(link);
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedActiveYouth = [...activePeriod.youthList].sort((a, b) => a.name.localeCompare(b.name));
|
const sortedActiveYouth = [...activePeriod.youthList].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* Header / Period Selector */}
|
<div className="bg-eggshell border border-slate-teal/10 p-3 sm:p-5 rounded-3xl shadow-sm flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
<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/10 p-3 rounded-xl font-bold text-slate-teal text-sm w-full md:w-auto focus:outline-none shadow-sm">
|
||||||
<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>)}
|
{periods.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<div>
|
<div className="text-center md:text-right">
|
||||||
<h2 className="text-xl font-black text-ebony uppercase tracking-widest">{activePeriod.name}</h2>
|
<h2 className="text-base font-black text-ebony uppercase tracking-widest">{activePeriod.name}</h2>
|
||||||
<p className="font-bold text-moss">{activePeriod.startDate} — {activePeriod.endDate}</p>
|
<p className="text-xs font-bold text-moss">{activePeriod.startDate} — {activePeriod.endDate}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PERIOD HOUR POT REPORT */}
|
<div className="bg-eggshell border border-slate-teal/10 p-3 sm:p-5 rounded-3xl shadow-sm">
|
||||||
<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-6 gap-3">
|
||||||
<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-base font-black text-ebony uppercase tracking-widest flex items-center">
|
||||||
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center">
|
<Users className="mr-2 text-slate-teal" size={20} /> Timrapport
|
||||||
<Users className="mr-3 text-slate-teal" size={24} />
|
|
||||||
Timrapport
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{currentUserRole !== 'Viewer' && (
|
{currentUserRole !== 'Viewer' && (
|
||||||
<button
|
<button onClick={exportToCSV} className="flex items-center justify-center gap-2 bg-seafoam text-eggshell font-black uppercase tracking-widest text-[10px] px-5 py-3 rounded-xl hover:bg-slate-teal transition-colors shadow-sm">
|
||||||
onClick={exportToCSV}
|
<Download size={16} /> Exportera till Excel
|
||||||
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{sortedActiveYouth.map(youth => {
|
{sortedActiveYouth.map(youth => {
|
||||||
const total = getPeriodHoursTotal(youth.id);
|
const total = getPeriodHoursTotal(youth.id);
|
||||||
const warningStatus: 'none' | 'yellow' | 'red' = total > POT_HOUR_LIMIT ? 'red' : (total >= POT_HOUR_LIMIT - 10 ? 'yellow' : 'none');
|
const warningStatus = total > POT_HOUR_LIMIT ? 'red' : (total >= POT_HOUR_LIMIT - 10 ? 'yellow' : 'none');
|
||||||
const isExpanded = expandedYouthId === youth.id;
|
const isExpanded = expandedYouthId === youth.id;
|
||||||
const timeline = getTimelineForYouth(youth.id);
|
const timeline = getTimelineForYouth(youth.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={youth.id} className="bg-white border border-slate-teal/10 rounded-xl shadow-inner overflow-hidden">
|
<div key={youth.id} className={`bg-white border transition-all duration-300 rounded-2xl overflow-hidden ${isExpanded ? 'border-slate-teal/20 shadow-md' : 'border-slate-teal/10 shadow-sm hover:border-slate-teal/20'}`}>
|
||||||
<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
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${youth.team === 'PF' ? 'bg-gold' : 'bg-seafoam'} shrink-0 shadow-sm`}>
|
|
||||||
<Image
|
|
||||||
src={youth.team === 'PF' ? falconIcon : porpoiseIcon}
|
|
||||||
alt={youth.team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare'}
|
|
||||||
width={18}
|
|
||||||
height={18}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="font-bold text-ebony text-xl">{youth.name}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setExpandedYouthId(isExpanded ? null : youth.id)}
|
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"
|
className="flex flex-col sm:flex-row sm:justify-between sm:items-center p-4 gap-4 cursor-pointer hover:bg-slate-teal/5 transition-colors"
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`flex items-center justify-center w-12 h-12 rounded-2xl ${youth.team === 'PF' ? 'bg-gold' : 'bg-seafoam'} shrink-0 shadow-inner`}>
|
||||||
|
<Image src={youth.team === 'PF' ? falconIcon : porpoiseIcon} alt={youth.team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare'} width={24} height={24} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-black text-ebony text-xl leading-tight">{youth.name}</span>
|
||||||
|
<div className="flex items-center text-[10px] font-black uppercase tracking-widest text-slate-teal mt-1">
|
||||||
{isExpanded ? <ChevronUp size={14} className="mr-1" /> : <ChevronDown size={14} className="mr-1" />}
|
{isExpanded ? <ChevronUp size={14} className="mr-1" /> : <ChevronDown size={14} className="mr-1" />}
|
||||||
{isExpanded ? 'Dölj detaljer' : 'Visa detaljer'}
|
{isExpanded ? 'Dölj detaljer' : 'Klicka för detaljer'}
|
||||||
</button>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-5 w-full md:w-auto">
|
<div className="flex items-center justify-end gap-4">
|
||||||
{warningStatus === 'red' && <AlertTriangle className="text-goldenrod shrink-0" size={32} />}
|
{warningStatus === 'red' && <AlertTriangle className="text-goldenrod shrink-0" size={28} />}
|
||||||
<div className="text-right min-w-30 md:min-w-37.5">
|
<div className="text-right">
|
||||||
<div className={`text-3xl md:text-4xl font-black tracking-tight ${warningStatus === 'red' ? 'text-goldenrod' : (warningStatus === 'yellow' ? 'text-goldenrod/80' : 'text-slate-teal')}`}>
|
<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>
|
{formatHours(total)}<span className="text-sm font-bold text-ebony/40"> / {POT_HOUR_LIMIT}h</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] font-bold text-ebony/60 uppercase tracking-widest mt-1 pr-1">Viktade timmar</p>
|
<p className="text-[10px] font-black uppercase tracking-widest mt-0.5 text-ebony/50">Viktade timmar</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="bg-slate-teal/5 border-t border-slate-teal/10 p-5 md:p-6">
|
<div className="bg-slate-teal/5 border-t border-slate-teal/10 p-4 md:p-6">
|
||||||
<h4 className="text-sm font-black text-ebony uppercase tracking-widest mb-4 flex items-center">
|
<h4 className="text-xs font-black text-ebony uppercase tracking-widest mb-4 flex items-center">
|
||||||
<Clock size={16} className="mr-2 text-slate-teal" /> Arbetspass & Frånvaro
|
<Clock size={16} className="mr-2 text-slate-teal" /> Arbetspass
|
||||||
</h4>
|
</h4>
|
||||||
{timeline.length === 0 ? (
|
{timeline.length === 0 ? (
|
||||||
<p className="text-sm font-bold text-slate-teal/60 italic">Ingen närvaro loggad ännu.</p>
|
<p className="text-sm font-bold text-slate-teal/60 italic">Ingen närvaro loggad.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{timeline.map((entry, idx) => {
|
{timeline.map((entry, idx) => {
|
||||||
const isWknd = isWeekend(entry.date);
|
const isWknd = isWeekend(entry.date);
|
||||||
|
|
||||||
// Grab schedule for this exact day to check for 'Extra' shifts accurately!
|
|
||||||
const dayNameStr = new Date(entry.date + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase();
|
const dayNameStr = new Date(entry.date + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase();
|
||||||
const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr);
|
const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr);
|
||||||
|
|
||||||
|
// FIX: Bulletproof isExtra logic that catches weekend-workers too!
|
||||||
let isExtra = false;
|
let isExtra = false;
|
||||||
if (!isWknd && daySchedule) {
|
if (daySchedule) {
|
||||||
const expectedShiftId = getTeamShiftInfo(daySchedule, youth.team).shiftId;
|
const expectedShift = getTeamShiftInfo(daySchedule, youth.team);
|
||||||
isExtra = entry.shiftId !== expectedShiftId;
|
if (expectedShift.time.toLowerCase() === 'ledig') {
|
||||||
|
isExtra = true;
|
||||||
|
} else if (!isWknd) {
|
||||||
|
isExtra = entry.shiftId !== expectedShift.shiftId;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isExtra = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Correct display logic: "Hela dagen" for weekends
|
const shiftLabel = isWknd ? 'Heldag' : (entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag');
|
||||||
const shiftLabel = isWknd ? 'Hela dagen' : (entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag');
|
|
||||||
|
|
||||||
return (
|
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 key={idx} className="flex flex-col sm:flex-row sm:justify-between sm:items-center bg-white p-3 rounded-xl border border-slate-teal/5 text-sm gap-2">
|
||||||
<div className="flex items-center flex-wrap gap-2 mb-2 sm:mb-0">
|
<div className="flex items-center flex-wrap gap-2">
|
||||||
<span className="font-bold text-ebony min-w-25">{entry.date}</span>
|
<span className="font-black text-ebony min-w-24">{entry.date}</span>
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-slate-teal bg-slate-teal/10 px-2 py-1 rounded-lg">{shiftLabel}</span>
|
||||||
|
|
||||||
<span className="text-xs font-bold text-slate-teal bg-slate-teal/10 px-2 py-0.5 rounded">
|
{isExtra && <span className="text-[10px] font-black uppercase tracking-widest text-slate-teal bg-slate-teal/10 px-2 py-1 rounded-lg">Extra</span>}
|
||||||
{shiftLabel}
|
{isWknd && <span className="text-[10px] font-black uppercase tracking-widest text-goldenrod bg-goldenrod/10 px-2 py-1 rounded-lg">Helg</span>}
|
||||||
</span>
|
|
||||||
|
|
||||||
{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>
|
||||||
|
<div className="flex items-center justify-between sm:justify-end gap-4 w-full sm:w-auto">
|
||||||
|
{entry.status === 'Pending' ? <span className="text-[10px] font-black uppercase tracking-widest text-goldenrod bg-goldenrod/10 px-2 py-1 rounded-lg">Väntar...</span> :
|
||||||
|
entry.status === 'Absent' ? <span className="text-[10px] font-black uppercase tracking-widest text-goldenrod bg-goldenrod/10 px-2 py-1 rounded-lg">{entry.note || 'Frånvarande'}</span> :
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-moss bg-moss/10 px-2 py-1 rounded-lg">{entry.status === 'Late' ? 'Manuell' : 'Närvarande'}</span>}
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<span className="font-black text-slate-teal text-base">
|
||||||
{entry.status === 'Pending' ? (
|
{formatTimeHHMM(entry.hoursWorked)} <span className="text-xs opacity-70">(+{entry.weightedHours.toFixed(1)}h)</span>
|
||||||
<span className="font-bold text-goldenrod bg-goldenrod/10 px-2 py-0.5 rounded">Väntar på registrering</span>
|
</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>
|
||||||
);
|
);
|
||||||
@@ -244,9 +199,6 @@ export const ReportTab: React.FC<Props> = ({ periods, attendance, activePeriodId
|
|||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+38
-75
@@ -10,12 +10,9 @@ import porpoiseIcon from '../assets/porpoise.svg';
|
|||||||
import { type Period, type Youth } from './adminTypes';
|
import { type Period, type Youth } from './adminTypes';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
periods: Period[];
|
periods: Period[]; createPeriod: (name: string, start: string) => void;
|
||||||
createPeriod: (name: string, start: string) => void;
|
deletePeriod: (id: string) => void; bulkAddYouth: (periodId: string, text: string, team: 'PF' | 'TU') => void;
|
||||||
deletePeriod: (id: string) => void;
|
removeYouth: (periodId: string, youthId: string) => void; isOffline: boolean;
|
||||||
bulkAddYouth: (periodId: string, text: string, team: 'PF' | 'TU') => void;
|
|
||||||
removeYouth: (periodId: string, youthId: string) => void;
|
|
||||||
isOffline: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SetupTab: React.FC<Props> = ({ periods, createPeriod, deletePeriod, bulkAddYouth, removeYouth, isOffline }) => {
|
export const SetupTab: React.FC<Props> = ({ periods, createPeriod, deletePeriod, bulkAddYouth, removeYouth, isOffline }) => {
|
||||||
@@ -24,40 +21,24 @@ export const SetupTab: React.FC<Props> = ({ periods, createPeriod, deletePeriod,
|
|||||||
const [expandedPeriod, setExpandedPeriod] = useState<string | null>(null);
|
const [expandedPeriod, setExpandedPeriod] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
if (isOffline) {
|
if (isOffline) { alert("Du måste vara ansluten till internet för att skapa en ny period."); return; }
|
||||||
alert("Du måste vara ansluten till internet för att skapa en ny period.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const name = (document.getElementById('periodName') as HTMLInputElement).value;
|
const name = (document.getElementById('periodName') as HTMLInputElement).value;
|
||||||
const start = (document.getElementById('periodStart') as HTMLInputElement).value;
|
const start = (document.getElementById('periodStart') as HTMLInputElement).value;
|
||||||
if (name && start) createPeriod(name, start);
|
if (name && start) createPeriod(name, start);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeletePeriod = (id: string) => {
|
const handleDeletePeriod = (id: string) => {
|
||||||
if (isOffline) {
|
if (isOffline) { alert("Åtgärd nekad: Du måste vara ansluten till internet för att ta bort en period."); return; }
|
||||||
alert("Åtgärd nekad: Du måste vara ansluten till internet för att ta bort en period.");
|
if (window.confirm('Är du säker på att du vill ta bort hela perioden?')) deletePeriod(id);
|
||||||
return;
|
|
||||||
}
|
|
||||||
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) => {
|
const handleRemoveYouth = (periodId: string, youthId: string, youthName: string) => {
|
||||||
if (isOffline) {
|
if (isOffline) { alert("Åtgärd nekad: Du måste vara ansluten till internet för att ta bort en ungdom."); return; }
|
||||||
alert("Åtgärd nekad: Du måste vara ansluten till internet för att ta bort en ungdom.");
|
if (window.confirm(`Är du säker på att du vill ta bort ${youthName} från perioden?`)) removeYouth(periodId, youthId);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (window.confirm(`Är du säker på att du vill ta bort ${youthName} från perioden?`)) {
|
|
||||||
removeYouth(periodId, youthId);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkAdd = () => {
|
const handleBulkAdd = () => {
|
||||||
if (isOffline) {
|
if (isOffline) { alert("Åtgärd nekad!"); return; }
|
||||||
alert("Åtgärd nekad: Du måste vara ansluten till internet för att importera ungdomar.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (expandedPeriod) {
|
if (expandedPeriod) {
|
||||||
const processedText = bulkText.split('\n').map(line => {
|
const processedText = bulkText.split('\n').map(line => {
|
||||||
const parts = line.split(/[,|-]/).map(p => p.trim());
|
const parts = line.split(/[,|-]/).map(p => p.trim());
|
||||||
@@ -69,37 +50,25 @@ export const SetupTab: React.FC<Props> = ({ periods, createPeriod, deletePeriod,
|
|||||||
}
|
}
|
||||||
return line;
|
return line;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
||||||
bulkAddYouth(expandedPeriod, processedText, bulkTeam);
|
bulkAddYouth(expandedPeriod, processedText, bulkTeam);
|
||||||
setBulkText('');
|
setBulkText('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to render a group of youth
|
|
||||||
const renderYouthGroup = (youthList: Youth[], periodId: string) => {
|
const renderYouthGroup = (youthList: Youth[], periodId: string) => {
|
||||||
if (youthList.length === 0) return null;
|
if (youthList.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-6">
|
||||||
{youthList.map(youth => (
|
{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 key={youth.id} className="flex justify-between items-center bg-white border border-slate-teal/10 p-3 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${youth.team === 'PF' ? 'bg-gold' : 'bg-seafoam'} shrink-0 shadow-sm`}>
|
<div className={`flex items-center justify-center w-8 h-8 rounded-xl ${youth.team === 'PF' ? 'bg-gold' : 'bg-seafoam'} shrink-0 shadow-inner`}>
|
||||||
<Image
|
<Image src={youth.team === 'PF' ? falconIcon : porpoiseIcon} alt={youth.team === 'PF' ? 'PF' : 'TU'} width={16} height={16} />
|
||||||
src={youth.team === 'PF' ? falconIcon : porpoiseIcon}
|
|
||||||
alt={youth.team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare'}
|
|
||||||
width={18}
|
|
||||||
height={18}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-ebony text-lg">{youth.name}</span>
|
<span className="font-bold text-ebony text-base">{youth.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={() => handleRemoveYouth(periodId, youth.id, youth.name)} className={`p-2 transition-colors rounded-lg ${isOffline ? 'text-goldenrod/40 cursor-not-allowed' : 'text-goldenrod hover:bg-emergency/10 hover:text-emergency'}`}>
|
||||||
onClick={() => handleRemoveYouth(periodId, youth.id, youth.name)}
|
<Trash2 size={18} />
|
||||||
className={`transition-colors ${isOffline ? 'text-goldenrod/40 cursor-not-allowed' : 'text-goldenrod hover:text-red-500'}`}
|
|
||||||
title={isOffline ? "Kräver internet" : "Ta bort ungdom"}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -109,70 +78,64 @@ export const SetupTab: React.FC<Props> = ({ periods, createPeriod, deletePeriod,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* Create New Period */}
|
<div className="bg-eggshell border border-slate-teal/10 p-5 rounded-3xl shadow-sm">
|
||||||
<div className="bg-eggshell border-2 border-slate-teal/20 p-6 rounded-2xl shadow-sm">
|
<h2 className="text-sm font-black text-ebony uppercase tracking-widest flex items-center mb-4">
|
||||||
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center mb-6">
|
<CalendarRange className="mr-2 text-slate-teal" size={20} /> Skapa Ny Period
|
||||||
<CalendarRange className="mr-3 text-slate-teal" size={24} />
|
|
||||||
Skapa Ny Period
|
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<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" disabled={isOffline} />
|
<input type="text" id="periodName" placeholder="T.ex. Period 1" className="w-full bg-white border border-slate-teal/10 p-3 rounded-xl text-sm font-bold focus:outline-none focus:border-slate-teal shadow-sm" disabled={isOffline} />
|
||||||
<input type="date" id="periodStart" className="w-full bg-white border border-slate-teal/20 p-3 rounded-lg font-bold" disabled={isOffline} />
|
<input type="date" id="periodStart" className="w-full bg-white border border-slate-teal/10 p-3 rounded-xl text-sm font-bold focus:outline-none focus:border-slate-teal shadow-sm" disabled={isOffline} />
|
||||||
<button onClick={handleCreate} disabled={isOffline} className="bg-slate-teal text-eggshell font-black uppercase px-6 py-3 rounded-lg hover:bg-ebony transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
<button onClick={handleCreate} disabled={isOffline} className="bg-slate-teal text-eggshell font-black uppercase tracking-widest text-xs px-4 py-3 rounded-xl hover:bg-ebony transition-colors disabled:opacity-50 shadow-sm">
|
||||||
Starta
|
Starta
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List Existing Periods */}
|
|
||||||
{periods.map(period => {
|
{periods.map(period => {
|
||||||
// Sort by name alphabetically
|
|
||||||
const pfYouth = period.youthList.filter(y => y.team === 'PF').sort((a, b) => a.name.localeCompare(b.name));
|
const pfYouth = period.youthList.filter(y => y.team === 'PF').sort((a, b) => a.name.localeCompare(b.name));
|
||||||
const tuYouth = period.youthList.filter(y => y.team === 'TU').sort((a, b) => a.name.localeCompare(b.name));
|
const tuYouth = period.youthList.filter(y => y.team === 'TU').sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={period.id} className="bg-eggshell border-2 border-slate-teal/20 rounded-2xl shadow-sm overflow-hidden">
|
<div key={period.id} className="bg-eggshell border border-slate-teal/10 rounded-3xl 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 className="p-5 flex justify-between items-center cursor-pointer hover:bg-slate-teal/5 transition-colors" onClick={() => setExpandedPeriod(expandedPeriod === period.id ? null : period.id)}>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-black text-ebony uppercase">{period.name}</h3>
|
<h3 className="text-xl font-black text-ebony uppercase tracking-wide">{period.name}</h3>
|
||||||
<p className="text-sm font-bold text-moss">{period.startDate} till {period.endDate} • {period.youthList.length} ungdomar</p>
|
<p className="text-xs font-bold text-moss mt-1">{period.startDate} till {period.endDate} • {period.youthList.length} ungdomar</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={(e) => { e.stopPropagation(); handleDeletePeriod(period.id); }} className={`p-2.5 rounded-xl transition-colors ${isOffline ? 'text-goldenrod/40 cursor-not-allowed' : 'text-goldenrod bg-white shadow-sm hover:bg-emergency hover:text-white'}`}>
|
||||||
onClick={(e) => { e.stopPropagation(); handleDeletePeriod(period.id); }}
|
|
||||||
className={`p-2 transition-colors ${isOffline ? 'text-goldenrod/40 cursor-not-allowed' : 'text-goldenrod/80 hover:text-goldenrod'}`}
|
|
||||||
title={isOffline ? "Kräver internet" : "Ta bort period"}
|
|
||||||
>
|
|
||||||
<Trash2 size={20} />
|
<Trash2 size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expandable Youth Management */}
|
|
||||||
{expandedPeriod === period.id && (
|
{expandedPeriod === period.id && (
|
||||||
<div className="p-6 border-t border-slate-teal/10 bg-slate-teal/5">
|
<div className="p-5 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>
|
<h4 className="font-black text-slate-teal mb-4 flex items-center uppercase tracking-widest text-xs">
|
||||||
<div className="flex flex-col md:flex-row gap-4 mb-6">
|
<UserPlus size={16} className="mr-2" /> Bulk-lägg till ungdomar
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-col md:flex-row gap-4 mb-8">
|
||||||
|
{/* FIX: Much larger textarea for easier importing */}
|
||||||
<textarea
|
<textarea
|
||||||
value={bulkText}
|
value={bulkText}
|
||||||
onChange={(e) => setBulkText(e.target.value)}
|
onChange={(e) => setBulkText(e.target.value)}
|
||||||
placeholder="Klistra in namn, ett per rad. T.ex. 'Anna' eller 'Anna, P'"
|
placeholder="Klistra in namn, ett per rad. T.ex. 'Anna' eller 'Anna, P'"
|
||||||
className="w-full h-24 bg-white border border-slate-teal/20 p-3 rounded-lg font-bold resize-none"
|
className="w-full h-32 md:h-40 bg-white border border-slate-teal/10 p-4 rounded-2xl text-sm font-bold resize-none focus:outline-none focus:border-slate-teal shadow-sm"
|
||||||
disabled={isOffline}
|
disabled={isOffline}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-2 shrink-0">
|
<div className="flex flex-col gap-3 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" disabled={isOffline}>
|
<select value={bulkTeam} onChange={(e) => setBulkTeam(e.target.value as 'PF' | 'TU')} className="bg-white border border-slate-teal/10 p-3 rounded-xl text-sm font-bold focus:outline-none shadow-sm" disabled={isOffline}>
|
||||||
<option value="PF">Standard: Pilgrimsfalk (P)</option>
|
<option value="PF">Standard: Pilgrimsfalk (P)</option>
|
||||||
<option value="TU">Standard: Tumlare (T)</option>
|
<option value="TU">Standard: Tumlare (T)</option>
|
||||||
</select>
|
</select>
|
||||||
<button onClick={handleBulkAdd} disabled={isOffline} className="bg-slate-teal text-eggshell font-black uppercase px-6 py-3 rounded-lg hover:bg-ebony transition-colors h-full disabled:opacity-50 disabled:cursor-not-allowed">
|
<button onClick={handleBulkAdd} disabled={isOffline} className="bg-seafoam text-eggshell font-black uppercase tracking-widest text-xs px-6 py-3 rounded-xl hover:bg-slate-teal transition-colors h-full disabled:opacity-50 shadow-sm">
|
||||||
Importera
|
Importera
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pfYouth.length > 0 && <h4 className="text-xs font-black text-moss uppercase tracking-widest mb-3">Pilgrimsfalkarna</h4>}
|
{pfYouth.length > 0 && <h4 className="text-xs font-black text-moss uppercase tracking-widest mb-3 flex items-center"><span className="w-2.5 h-2.5 rounded-full bg-gold mr-2 shadow-sm"></span> Pilgrimsfalkarna</h4>}
|
||||||
{renderYouthGroup(pfYouth, period.id)}
|
{renderYouthGroup(pfYouth, period.id)}
|
||||||
|
|
||||||
{tuYouth.length > 0 && <h4 className="text-xs font-black text-moss uppercase tracking-widest mb-3">Tumlarna</h4>}
|
{tuYouth.length > 0 && <h4 className="text-xs font-black text-moss uppercase tracking-widest mb-3 flex items-center"><span className="w-2.5 h-2.5 rounded-full bg-seafoam mr-2 shadow-sm"></span> Tumlarna</h4>}
|
||||||
{renderYouthGroup(tuYouth, period.id)}
|
{renderYouthGroup(tuYouth, period.id)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+34
-67
@@ -14,97 +14,66 @@ import { verifyLogin } from '../actions/admin';
|
|||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const [currentUser, setCurrentUser] = useState<AppUser | null>(null);
|
const [currentUser, setCurrentUser] = useState<AppUser | null>(null);
|
||||||
const [isCheckingSession, setIsCheckingSession] = useState(true); // NEW: Prevents login screen flashing
|
const [isCheckingSession, setIsCheckingSession] = useState(true);
|
||||||
|
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [pin, setPin] = useState('');
|
const [pin, setPin] = useState('');
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [loginError, setLoginError] = useState(false);
|
const [loginError, setLoginError] = useState(false);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'setup' | 'today' | 'report'>('report');
|
const [activeTab, setActiveTab] = useState<'setup' | 'today' | 'report'>('report');
|
||||||
const adminState = useAdminState();
|
const adminState = useAdminState();
|
||||||
|
|
||||||
// NEW: Check for a saved session when the app loads
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedSession = localStorage.getItem('kullaberg_admin_session');
|
const savedSession = localStorage.getItem('kullaberg_admin_session');
|
||||||
if (savedSession) {
|
if (savedSession) {
|
||||||
const user = JSON.parse(savedSession) as AppUser;
|
const user = JSON.parse(savedSession) as AppUser;
|
||||||
setCurrentUser(user);
|
setCurrentUser(user);
|
||||||
|
|
||||||
// Set their correct tab based on role
|
|
||||||
if (user.role === 'Viewer') setActiveTab('report');
|
if (user.role === 'Viewer') setActiveTab('report');
|
||||||
else if (user.role === 'Staff') setActiveTab('today');
|
else if (user.role === 'Staff') setActiveTab('today');
|
||||||
else setActiveTab('today'); // Admins go to today by default if logged in via session
|
else setActiveTab('today');
|
||||||
}
|
}
|
||||||
setIsCheckingSession(false);
|
setIsCheckingSession(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogin = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
const handleLogin = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true); setLoginError(false);
|
||||||
setLoginError(false);
|
|
||||||
|
|
||||||
const result = await verifyLogin(username, pin);
|
const result = await verifyLogin(username, pin);
|
||||||
|
|
||||||
if (result.success && result.user) {
|
if (result.success && result.user) {
|
||||||
const user = result.user as AppUser;
|
const user = result.user as AppUser;
|
||||||
setCurrentUser(user);
|
setCurrentUser(user);
|
||||||
|
|
||||||
// NEW: Save the session to the browser
|
|
||||||
localStorage.setItem('kullaberg_admin_session', JSON.stringify(user));
|
localStorage.setItem('kullaberg_admin_session', JSON.stringify(user));
|
||||||
|
|
||||||
if (user.role === 'Viewer') setActiveTab('report');
|
if (user.role === 'Viewer') setActiveTab('report');
|
||||||
else if (user.role === 'Staff') setActiveTab('today');
|
else if (user.role === 'Staff') setActiveTab('today');
|
||||||
else setActiveTab(adminState.periods.length > 0 ? 'today' : 'setup');
|
else setActiveTab(adminState.periods.length > 0 ? 'today' : 'setup');
|
||||||
} else {
|
} else {
|
||||||
setPin('');
|
setPin(''); setLoginError(true);
|
||||||
setLoginError(true);
|
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
setCurrentUser(null);
|
setCurrentUser(null); setUsername(''); setPin('');
|
||||||
setUsername('');
|
|
||||||
setPin('');
|
|
||||||
localStorage.removeItem('kullaberg_admin_session');
|
localStorage.removeItem('kullaberg_admin_session');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isCheckingSession) {
|
if (isCheckingSession) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-slate-teal" size={40} /></div>;
|
||||||
return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-slate-teal" size={40} /></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
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-6 md:p-8 rounded-2xl shadow-sm 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" />
|
<Lock size={32} className="text-slate-teal" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-black text-ebony uppercase mb-6">Admin Login</h2>
|
<h2 className="text-2xl font-black text-ebony uppercase mb-6 tracking-widest">Admin Login</h2>
|
||||||
|
|
||||||
<form onSubmit={handleLogin} className="space-y-4 text-left">
|
|
||||||
{/* New Text Input for Username */}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => { setUsername(e.target.value); setLoginError(false); }}
|
|
||||||
placeholder="Användarnamn"
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={pin}
|
|
||||||
onChange={(e) => { setPin(e.target.value); setLoginError(false); }}
|
|
||||||
placeholder="•••••"
|
|
||||||
className={`w-full bg-eggshell/50 border-2 text-center text-2xl tracking-[0.5em] text-ebony font-mono p-3 rounded-xl focus:outline-none ${loginError ? 'border-emergency/50 bg-emergency/10' : 'border-slate-teal/30 focus:border-slate-teal'}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
<form onSubmit={handleLogin} className="space-y-3 text-left">
|
||||||
|
<input type="text" value={username} onChange={(e) => { setUsername(e.target.value); setLoginError(false); }} placeholder="Användarnamn" className="w-full bg-white border border-slate-teal/20 text-ebony font-bold p-3 rounded-xl focus:outline-none focus:border-slate-teal" />
|
||||||
|
<input type="password" value={pin} onChange={(e) => { setPin(e.target.value); setLoginError(false); }} placeholder="•••••" className={`w-full bg-white border text-center text-2xl tracking-[0.5em] text-ebony font-mono p-3 rounded-xl focus:outline-none ${loginError ? 'border-emergency/50 bg-emergency/5' : 'border-slate-teal/20 focus:border-slate-teal'}`} />
|
||||||
{loginError && <p className="text-emergency text-xs font-bold text-center mt-1">Fel namn eller lösenord.</p>}
|
{loginError && <p className="text-emergency text-xs font-bold text-center mt-1">Fel namn eller lösenord.</p>}
|
||||||
|
<button type="submit" disabled={isLoading || !pin || !username} className="w-full bg-slate-teal text-eggshell font-black uppercase tracking-widest py-3 mt-2 rounded-xl hover:bg-ebony transition-colors disabled:opacity-50">
|
||||||
<button type="submit" disabled={isLoading || !pin || !username} className="w-full bg-slate-teal text-eggshell font-black uppercase py-3 rounded-xl hover:bg-ebony transition-colors disabled:opacity-50">
|
|
||||||
{isLoading ? <Loader2 className="animate-spin mx-auto" /> : 'Logga in'}
|
{isLoading ? <Loader2 className="animate-spin mx-auto" /> : 'Logga in'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -113,47 +82,47 @@ export default function Admin() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role-based tabs logic that was missing!
|
|
||||||
const availableTabs = [];
|
const availableTabs = [];
|
||||||
if (currentUser.role === 'Admin') {
|
if (currentUser.role === 'Admin') availableTabs.push({ id: 'setup', icon: CalendarRange, label: 'Perioder' });
|
||||||
availableTabs.push({ id: 'setup', icon: CalendarRange, label: 'Perioder' });
|
if (currentUser.role === 'Admin' || currentUser.role === 'Staff') availableTabs.push({ id: 'today', icon: ClipboardCheck, label: 'Närvaro' });
|
||||||
}
|
|
||||||
if (currentUser.role === 'Admin' || currentUser.role === 'Staff') {
|
|
||||||
availableTabs.push({ id: 'today', icon: ClipboardCheck, label: 'Närvaro' });
|
|
||||||
}
|
|
||||||
availableTabs.push({ id: 'report', icon: UsersIcon, label: 'Rapport' });
|
availableTabs.push({ id: 'report', icon: UsersIcon, label: 'Rapport' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-fade-in w-full">
|
<div className="space-y-6 animate-fade-in w-full">
|
||||||
<div className="flex justify-between items-end border-b-2 border-slate-teal/20 pb-4">
|
<div className="flex flex-col md:flex-row justify-between md:items-end border-b border-slate-teal/20 pb-3 gap-2">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<h1 className="text-3xl font-black text-slate-teal uppercase flex items-center">
|
<div className="flex items-center gap-3">
|
||||||
<Unlock className="mr-3 text-seafoam" size={28} /> Admin
|
<Unlock className="text-seafoam" size={24} />
|
||||||
</h1>
|
<h1 className="text-2xl font-black text-slate-teal uppercase tracking-widest">Admin</h1>
|
||||||
{/* Display Offline Badge if no internet! */}
|
</div>
|
||||||
{adminState.isOffline && (
|
{adminState.isOffline && (
|
||||||
<div className="flex items-center mt-2 text-xs font-bold bg-goldenrod/20 text-goldenrod px-3 py-1 rounded-full w-fit">
|
<div className="flex items-center mt-2 text-[10px] font-bold bg-goldenrod/20 text-goldenrod px-2 py-1 rounded w-fit uppercase tracking-widest">
|
||||||
<WifiOff size={14} className="mr-2" /> Offline Läge (Synkar när nätverk finns)
|
<WifiOff size={12} className="mr-1.5" /> Offline Läge
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="flex items-center gap-3 text-sm md:text-right bg-eggshell md:bg-transparent p-2 md:p-0 rounded-lg">
|
||||||
<p className="text-xs font-bold text-moss mb-1">Inloggad: {currentUser.name}</p>
|
<span className="font-bold text-ebony">Inloggad: {currentUser.name}</span>
|
||||||
<button onClick={handleLogout} className="text-sm font-bold text-ebony/60 hover:text-goldenrod">Logga ut</button>
|
<span className="text-slate-teal/30">|</span>
|
||||||
|
<button onClick={handleLogout} className="font-bold text-slate-teal hover:text-goldenrod transition-colors">Logga ut</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{adminState.isLoadingData && adminState.periods.length === 0 ? (
|
{adminState.isLoadingData && adminState.periods.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-slate-teal">
|
<div className="flex flex-col items-center justify-center py-20 text-slate-teal">
|
||||||
<Loader2 size={48} className="animate-spin mb-4" />
|
<Loader2 size={40} className="animate-spin mb-4" />
|
||||||
<p className="font-bold animate-pulse">Hämtar data...</p>
|
<p className="font-bold text-sm animate-pulse">Hämtar data...</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{availableTabs.length > 1 && (
|
{availableTabs.length > 1 && (
|
||||||
<div className="border-b border-slate-teal/20 flex gap-2 overflow-x-auto scrollbar-hide">
|
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
|
||||||
{availableTabs.map(tab => (
|
{availableTabs.map(tab => (
|
||||||
<button key={tab.id} onClick={() => setActiveTab(tab.id as any)} className={`flex items-center whitespace-nowrap 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'}`}>
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id as any)}
|
||||||
|
className={`flex items-center px-4 py-2.5 rounded-lg font-bold text-xs uppercase tracking-widest transition-colors ${activeTab === tab.id ? 'bg-slate-teal text-eggshell' : 'bg-white/60 text-slate-teal hover:bg-slate-teal/10'}`}
|
||||||
|
>
|
||||||
<tab.icon size={16} className="mr-2 hidden md:block shrink-0" /> {tab.label}
|
<tab.icon size={16} className="mr-2 hidden md:block shrink-0" /> {tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -161,7 +130,6 @@ export default function Admin() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'setup' && currentUser.role === 'Admin' && <SetupTab {...adminState} />}
|
{activeTab === 'setup' && currentUser.role === 'Admin' && <SetupTab {...adminState} />}
|
||||||
|
|
||||||
{activeTab === 'today' && (currentUser.role === 'Admin' || currentUser.role === 'Staff') && (
|
{activeTab === 'today' && (currentUser.role === 'Admin' || currentUser.role === 'Staff') && (
|
||||||
<AttendanceTab
|
<AttendanceTab
|
||||||
periods={adminState.periods}
|
periods={adminState.periods}
|
||||||
@@ -174,7 +142,6 @@ export default function Admin() {
|
|||||||
setActivePeriodId={adminState.setActivePeriodId}
|
setActivePeriodId={adminState.setActivePeriodId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'report' && (
|
{activeTab === 'report' && (
|
||||||
<ReportTab
|
<ReportTab
|
||||||
periods={adminState.periods}
|
periods={adminState.periods}
|
||||||
|
|||||||
+22
-37
@@ -12,12 +12,12 @@ export default function Documents() {
|
|||||||
const [cachedFiles, setCachedFiles] = useState<Set<string>>(new Set());
|
const [cachedFiles, setCachedFiles] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const docs = [
|
const docs = [
|
||||||
{ title: 'Turistkarta', icon: <Map size={24} />, file: '/files/Turistkarta - Kullaberg.pdf', size: '2.51 MB' },
|
{ title: 'Turistkarta', icon: <Map size={20} />, file: '/files/Turistkarta - Kullaberg.pdf', size: '2.51 MB' },
|
||||||
{ title: 'Orienteringskarta', icon: <MapPlus size={24} />, file: '/files/Orienteringskarta - Kullaberg.pdf', size: '3.74 MB' },
|
{ title: 'Orienteringskarta', icon: <MapPlus size={20} />, file: '/files/Orienteringskarta - Kullaberg.pdf', size: '3.74 MB' },
|
||||||
{ title: 'Badplatser att besöka', icon: <WavesLadder size={24} />, file: '/files/Badplatser på Kullaberg.pdf', size: '1.88 MB' },
|
{ title: 'Badplatser att besöka', icon: <WavesLadder size={20} />, file: '/files/Badplatser på Kullaberg.pdf', size: '1.88 MB' },
|
||||||
{ title: 'Vandringsrutter', icon: <MapPinned size={24} />, file: '/files/Vandringsrutter.pdf', size: '4.42 MB' },
|
{ title: 'Vandringsrutter', icon: <MapPinned size={20} />, file: '/files/Vandringsrutter.pdf', size: '4.42 MB' },
|
||||||
{ title: 'Underlag för guidediplomering', icon: <BookCopy size={24} />, file: '/files/Underlag för guidediplomering.pdf', size: '3.18 MB' },
|
{ title: 'Underlag för guidediplomering', icon: <BookCopy size={20} />, file: '/files/Underlag för guidediplomering.pdf', size: '3.18 MB' },
|
||||||
{ title: 'Destinationskunskap Kullahalvön', icon: <BookCopy size={24} />, file: '/files/Destinationskunskap.pdf', size: '1.97 MB' }
|
{ title: 'Destinationskunskap Kullahalvön', icon: <BookCopy size={20} />, file: '/files/Destinationskunskap.pdf', size: '1.97 MB' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
@@ -67,23 +67,13 @@ export default function Documents() {
|
|||||||
const handleSyncAll = async () => {
|
const handleSyncAll = async () => {
|
||||||
setSyncStatus('syncing');
|
setSyncStatus('syncing');
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
try {
|
try { await fetch(doc.file); } catch (error) { console.error("Kunde inte ladda ner:", doc.file); }
|
||||||
await fetch(doc.file);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Kunde inte ladda ner:", doc.file);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
await checkCacheStatus();
|
await checkCacheStatus();
|
||||||
setSyncStatus('done');
|
setSyncStatus('done');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isHydrated) {
|
if (!isHydrated) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-slate-teal" size={40} /></div>;
|
||||||
return (
|
|
||||||
<div className="flex justify-center py-20">
|
|
||||||
<Loader2 className="animate-spin text-slate-teal" size={40} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-fade-in w-full">
|
<div className="space-y-8 animate-fade-in w-full">
|
||||||
@@ -98,7 +88,7 @@ export default function Documents() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2 items-start md:items-end">
|
<div className="flex flex-col gap-2 items-start md:items-end">
|
||||||
{isOffline && (
|
{isOffline && (
|
||||||
<div className="bg-goldenrod/10 text-goldenrod border border-goldenrod/20 px-3 py-1.5 rounded-full flex items-center text-xs font-black uppercase tracking-tighter">
|
<div className="bg-goldenrod/10 text-goldenrod border border-goldenrod/20 px-3 py-1.5 rounded-full flex items-center text-xs font-black uppercase tracking-widest">
|
||||||
<WifiOff size={14} className="mr-2" /> Offline-läge
|
<WifiOff size={14} className="mr-2" /> Offline-läge
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -124,40 +114,35 @@ export default function Documents() {
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-black text-ebony/50 uppercase tracking-widest mb-4">Nedladdningar</h2>
|
<h2 className="text-sm font-black text-ebony/50 uppercase tracking-widest mb-4">Nedladdningar</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{docs.map((doc, idx) => {
|
{docs.map((doc, idx) => {
|
||||||
const isCached = cachedFiles.has(doc.file);
|
const isCached = cachedFiles.has(doc.file);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a key={idx} href={doc.file} target="_blank" rel="noopener noreferrer"
|
<a key={idx} href={doc.file} target="_blank" rel="noopener noreferrer"
|
||||||
className={`group flex items-center justify-between p-5 rounded-2xl border transition-all duration-300 ${isOffline && !isCached ? 'opacity-50 grayscale cursor-not-allowed pointer-events-none bg-white/20 border-transparent' : 'bg-white/60 border-white hover:-translate-y-1 hover:shadow-xl hover:shadow-moss/10 backdrop-blur-sm'}`}
|
className={`group flex items-center justify-between p-4 rounded-2xl border transition-colors ${isOffline && !isCached ? 'opacity-50 grayscale cursor-not-allowed pointer-events-none border-transparent bg-eggshell/50' : 'bg-white/50 hover:bg-white border-slate-teal/10'}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center text-slate-teal">
|
<div className="flex items-center text-slate-teal">
|
||||||
{/* FIX: Keep the original icon, but change the background to green when cached */}
|
<div className={`p-2.5 rounded-xl mr-3 text-eggshell shrink-0 transition-colors ${isCached ? 'bg-moss' : 'bg-slate-teal'}`}>
|
||||||
<div className={`p-3 rounded-xl mr-4 text-eggshell shadow-inner shrink-0 transition-colors ${isCached ? 'bg-moss' : 'bg-linear-to-br from-seafoam to-slate-teal'}`}>
|
|
||||||
{doc.icon}
|
{doc.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-bold text-sm leading-tight">{doc.title}</span>
|
<span className="font-bold text-sm leading-tight text-ebony">{doc.title}</span>
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-1.5">
|
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||||
<span className="text-[10px] font-bold text-moss bg-moss/10 px-2 py-0.5 rounded-md uppercase tracking-wider border border-moss/10">
|
<span className="text-[10px] font-bold text-moss bg-moss/10 px-2 py-0.5 rounded uppercase tracking-wider">
|
||||||
{doc.size}
|
{doc.size}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{isCached && (
|
{isCached && (
|
||||||
<span className="flex items-center gap-1 text-[10px] font-bold text-moss bg-moss/10 px-2 py-0.5 rounded-md uppercase tracking-wider">
|
<span className="flex items-center gap-1 text-[10px] font-bold text-moss bg-moss/10 px-2 py-0.5 rounded uppercase tracking-wider">
|
||||||
<Archive size={12} /> Nedladdad
|
<Archive size={10} /> Nedladdad
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FIX: Swap the Download icon for a Check icon when it is already saved on the phone */}
|
|
||||||
{isCached ? (
|
{isCached ? (
|
||||||
<CheckCircle size={20} className="text-moss shrink-0 ml-2" />
|
<CheckCircle size={18} className="text-moss shrink-0 ml-2" />
|
||||||
) : (
|
) : (
|
||||||
<Download size={20} className="text-slate-teal/50 group-hover:text-slate-teal transition-colors shrink-0 ml-2" />
|
<Download size={18} className="text-slate-teal/50 group-hover:text-slate-teal transition-colors shrink-0 ml-2" />
|
||||||
)}
|
)}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
@@ -169,13 +154,13 @@ export default function Documents() {
|
|||||||
<h2 className="text-sm font-black text-ebony/50 uppercase tracking-widest mb-4">
|
<h2 className="text-sm font-black text-ebony/50 uppercase tracking-widest mb-4">
|
||||||
{isOffline ? "Användbara Länkar (Kräver anslutning)" : "Användbara Länkar"}
|
{isOffline ? "Användbara Länkar (Kräver anslutning)" : "Användbara Länkar"}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{links.map((link, idx) => (
|
{links.map((link, idx) => (
|
||||||
<a key={idx} href={isOffline ? '#' : link.url} target={isOffline ? '_self' : '_blank'} rel="noopener noreferrer"
|
<a key={idx} href={isOffline ? '#' : link.url} target={isOffline ? '_self' : '_blank'} rel="noopener noreferrer"
|
||||||
className={`group flex items-center justify-between p-5 rounded-2xl border transition-all duration-300 ${isOffline ? 'opacity-40 grayscale cursor-not-allowed pointer-events-none bg-white/20 border-transparent' : 'bg-white/60 border-white hover:-translate-y-1 hover:shadow-xl hover:shadow-moss/10 backdrop-blur-sm'}`}
|
className={`group flex items-center justify-between p-4 rounded-2xl border transition-colors ${isOffline ? 'opacity-40 grayscale cursor-not-allowed pointer-events-none bg-eggshell/50 border-transparent' : 'bg-white/50 hover:bg-white border-slate-teal/10'}`}
|
||||||
>
|
>
|
||||||
<span className="font-bold text-slate-teal text-sm leading-tight pr-4">{link.title}</span>
|
<span className="font-bold text-slate-teal text-sm leading-tight pr-4">{link.title}</span>
|
||||||
<div className={`rounded-full p-2 transition-colors shrink-0 ${isOffline ? 'bg-ebony/5' : 'bg-eggshell group-hover:bg-seafoam'}`}>
|
<div className={`rounded-full p-2 transition-colors shrink-0 ${isOffline ? 'bg-transparent' : 'bg-white group-hover:bg-seafoam'}`}>
|
||||||
<ExternalLink size={16} className={isOffline ? 'text-ebony/40' : 'text-slate-teal group-hover:text-eggshell'} />
|
<ExternalLink size={16} className={isOffline ? 'text-ebony/40' : 'text-slate-teal group-hover:text-eggshell'} />
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+26
-34
@@ -16,67 +16,59 @@ export default function Emergency() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 112 Card */}
|
{/* 112 Card */}
|
||||||
<div className="bg-emergency/20 border-l-4 border-emergency p-6 rounded-r-xl shadow-sm">
|
<div className="bg-emergency/10 border-l-4 border-emergency p-5 rounded-r-2xl shadow-sm">
|
||||||
<h2 className="text-2xl font-black text-ebony flex items-center mb-2">
|
<h2 className="text-xl font-black text-ebony flex items-center mb-2">
|
||||||
<Phone className="mr-3 text-emergency" size={24} />
|
<Phone className="mr-2 text-emergency" size={20} /> Ring 112
|
||||||
Ring 112
|
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-ebony/80 font-medium mb-4">
|
<p className="text-ebony/80 font-medium mb-4 text-sm">
|
||||||
Vid olycka, brand eller livshotande tillstånd. Berätta vem du är och vad som har hänt.
|
Vid olycka, brand eller livshotande tillstånd. Berätta vem du är och vad som har hänt.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-eggshell/50 p-4 rounded-lg border border-emergency/30">
|
<div className="bg-white/60 p-4 rounded-xl border border-emergency/20">
|
||||||
<h3 className="font-bold text-ebony flex items-center mb-1 text-sm uppercase tracking-wider">
|
<h3 className="font-bold text-emergency flex items-center mb-1 text-xs uppercase tracking-wider">
|
||||||
<MapPin className="mr-2 text-emergency" size={16} />
|
<MapPin className="mr-2" size={14} /> Uppge din position
|
||||||
Uppge din position
|
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-ebony font-medium text-sm">
|
<p className="text-ebony font-medium text-sm">
|
||||||
Använd appen <strong>112</strong> eller GPS i telefonen. Säg att du befinner dig i Kullabergs Naturreservat. Var specifik (t.ex. "Nära fyren" eller "Vid Josefinelust").
|
Använd appen <strong>112</strong> eller GPS. Säg att du befinner dig i Kullabergs Naturreservat. Var specifik (t.ex. "Nära fyren" eller "Vid Josefinelust").
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* First Aid / HLR Card */}
|
{/* First Aid / HLR Card */}
|
||||||
<div className="bg-eggshell border-2 border-slate-teal/20 p-6 rounded-xl shadow-sm">
|
<div className="bg-eggshell border-2 border-slate-teal/10 p-5 rounded-2xl shadow-sm">
|
||||||
<h2 className="text-xl font-black text-slate-teal flex items-center mb-4 uppercase tracking-wide">
|
<h2 className="text-lg font-black text-slate-teal flex items-center mb-4 uppercase tracking-wide">
|
||||||
<HeartPulse className="mr-3 text-seafoam" size={24} />
|
<HeartPulse className="mr-2 text-seafoam" size={20} /> Första Hjälpen & Hjärtstartare
|
||||||
Första Hjälpen & Hjärtstartare
|
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="space-y-3 text-ebony font-medium">
|
<ul className="space-y-3 text-ebony font-medium text-sm">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="bg-seafoam text-eggshell font-bold px-2 py-0.5 rounded mr-3 text-sm">1</span>
|
<span className="bg-seafoam text-eggshell font-bold px-2 py-0.5 rounded mr-3 text-xs shrink-0">1</span>
|
||||||
Säkra platsen – se till att varken du eller personen utsätts för mer fara.
|
<span>Säkra platsen – se till att varken du eller personen utsätts för mer fara.</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="bg-seafoam text-eggshell font-bold px-2 py-0.5 rounded mr-3 text-sm shrink-0">2</span>
|
<span className="bg-seafoam text-eggshell font-bold px-2 py-0.5 rounded mr-3 text-xs shrink-0">2</span>
|
||||||
|
<span>Finns hjärtstartare? Ja, närmaste hjärtstartare finns inne på <strong className="text-ebony font-black">Naturum Kullaberg</strong> (vid fyren) under deras öppettider.</span>
|
||||||
{/* Mobile View: Standard wrapping text (hides on medium screens and up) */}
|
|
||||||
<p className="text-ebony font-medium">
|
|
||||||
Finns hjärtstartare? Ja, närmaste hjärtstartare finns inne på <strong className="text-ebony font-black">Naturum Kullaberg</strong> (vid fyren) under deras öppettider.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Internal Contact */}
|
{/* Internal Contact */}
|
||||||
<div className="bg-eggshell border-2 border-slate-teal/20 p-6 rounded-xl shadow-sm">
|
<div className="bg-eggshell border-2 border-slate-teal/10 p-5 rounded-2xl shadow-sm">
|
||||||
<h2 className="text-xl font-black text-slate-teal mb-2 uppercase tracking-wide">
|
<h2 className="text-lg font-black text-slate-teal mb-2 uppercase tracking-wide">
|
||||||
Intern Rapportering
|
Intern Rapportering
|
||||||
</h2>
|
</h2>
|
||||||
<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 className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-col md:flex-row md:justify-between md:items-center pb-4 border-b border-moss/20 gap-3">
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center pb-3 border-b border-slate-teal/10 gap-2">
|
||||||
<span className="text-base font-bold text-ebony">William Söderberg</span>
|
<span className="text-sm font-bold text-ebony">William Söderberg</span>
|
||||||
<div className='flex gap-2 flex-wrap md:justify-end'>
|
<div className='flex gap-2 flex-wrap md:justify-end text-sm'>
|
||||||
<SafePhoneLink parts={['072', '247', '02', '91']} display="072-247 02 91" />
|
<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" />
|
<SafePhoneLink parts={['078', '389', '355', '81', '0604']} display="078-389 35 58 10 604" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-3">
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-2">
|
||||||
<span className="text-base font-bold text-ebony">Oliver Nilsson</span>
|
<span className="text-sm font-bold text-ebony">Oliver Nilsson</span>
|
||||||
<div className='flex gap-2 flex-wrap md:justify-end'>
|
<div className='flex gap-2 flex-wrap md:justify-end text-sm'>
|
||||||
<SafePhoneLink parts={['072', '717', '74', '40']} display="072-717 74 40" />
|
<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" />
|
<SafePhoneLink parts={['078', '389', '355', '81', '0605']} display="078-389 35 58 10 605" />
|
||||||
</div>
|
</div>
|
||||||
@@ -85,4 +77,4 @@ export default function Emergency() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
+10
-10
@@ -14,8 +14,8 @@ export default function FAQ() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full animate-fade-in">
|
<div className="w-full animate-fade-in space-y-6">
|
||||||
<div className="mb-8">
|
<div>
|
||||||
<h1 className="text-3xl font-black text-slate-teal tracking-tight flex items-center">
|
<h1 className="text-3xl font-black text-slate-teal tracking-tight flex items-center">
|
||||||
<MessageCircleQuestionMark className="mr-3 text-seafoam" size={32} />
|
<MessageCircleQuestionMark className="mr-3 text-seafoam" size={32} />
|
||||||
Vanliga Frågor (FAQ)
|
Vanliga Frågor (FAQ)
|
||||||
@@ -23,29 +23,29 @@ export default function FAQ() {
|
|||||||
<p className="text-moss font-medium mt-2 ml-11">Använd den här guiden för att snabbt svara på turisternas vanligaste frågor.</p>
|
<p className="text-moss font-medium mt-2 ml-11">Använd den här guiden för att snabbt svara på turisternas vanligaste frågor.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="columns-1 lg:columns-2 gap-6 space-y-6">
|
<div className="columns-1 lg:columns-2 gap-4 space-y-4">
|
||||||
{faqData.map((section, sIndex) => (
|
{faqData.map((section, sIndex) => (
|
||||||
<div key={sIndex} className="break-inside-avoid bg-white/40 backdrop-blur-md border border-white p-6 rounded-3xl shadow-lg shadow-ebony/5">
|
<div key={sIndex} className="break-inside-avoid bg-eggshell border-2 border-slate-teal/10 p-5 rounded-2xl shadow-sm">
|
||||||
<h2 className="text-lg font-black text-ebony/70 uppercase tracking-widest mb-4">
|
<h2 className="text-sm font-black text-ebony/60 uppercase tracking-widest mb-4">
|
||||||
{section.category}
|
{section.category}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
{section.questions.map((item, qIndex) => {
|
{section.questions.map((item, qIndex) => {
|
||||||
const id = `${sIndex}-${qIndex}`;
|
const id = `${sIndex}-${qIndex}`;
|
||||||
const isOpen = openIndex === id;
|
const isOpen = openIndex === id;
|
||||||
return (
|
return (
|
||||||
<div key={id} className={`rounded-2xl overflow-hidden transition-all duration-300 ${isOpen ? 'bg-white shadow-md shadow-seafoam/10' : 'bg-eggshell/50 hover:bg-white'}`}>
|
<div key={id} className={`rounded-xl overflow-hidden transition-colors ${isOpen ? 'bg-white shadow-sm' : 'bg-transparent hover:bg-white/50'}`}>
|
||||||
<button
|
<button
|
||||||
className="w-full text-left p-4 flex justify-between items-center focus:outline-none"
|
className="w-full text-left p-3 flex justify-between items-center focus:outline-none"
|
||||||
onClick={() => toggleQuestion(id)}
|
onClick={() => toggleQuestion(id)}
|
||||||
>
|
>
|
||||||
<span className="font-bold text-slate-teal text-sm pr-4 leading-snug">{item.q}</span>
|
<span className="font-bold text-slate-teal text-sm pr-4 leading-snug">{item.q}</span>
|
||||||
<div className={`p-1 rounded-full transition-colors ${isOpen ? 'bg-seafoam text-eggshell' : 'bg-transparent text-seafoam'}`}>
|
<div className={`p-1 rounded-full transition-colors ${isOpen ? 'bg-seafoam text-eggshell' : 'text-seafoam'}`}>
|
||||||
{isOpen ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
{isOpen ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div className={`transition-all duration-300 ease-in-out ${isOpen ? 'max-h-125 opacity-100' : 'max-h-0 opacity-0 overflow-hidden'}`}>
|
<div className={`transition-all duration-300 ease-in-out ${isOpen ? 'max-h-125 opacity-100' : 'max-h-0 opacity-0 overflow-hidden'}`}>
|
||||||
<div className="p-2 pb-4 text-ebony font-medium text-sm leading-relaxed border-t border-ebony/20 mx-4">
|
<div className="p-2 pb-3 text-ebony font-medium text-sm leading-relaxed border-t border-ebony/20 mx-4">
|
||||||
{item.a}
|
{item.a}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<main className="max-w-5xl mx-auto p-4 py-6 md:py-8">
|
<main className="max-w-5xl mx-auto p-3 py-6 md:py-8">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+26
-28
@@ -6,21 +6,19 @@ import { SafePhoneLink } from './components/PhoneLinks';
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in w-full mx-auto">
|
||||||
|
|
||||||
{/* Welcome Banner */}
|
{/* Welcome Banner */}
|
||||||
<div className="bg-slate-teal rounded-2xl p-6 md:p-8 shadow-lg shadow-slate-teal/10 text-eggshell">
|
<div className="bg-slate-teal rounded-2xl p-6 shadow-sm text-eggshell">
|
||||||
<h1 className="text-3xl md:text-4xl font-black tracking-tight mb-2 drop-shadow-sm">Välkommen!</h1>
|
<h1 className="text-3xl font-black tracking-tight mb-1">Välkommen!</h1>
|
||||||
<p className="text-eggshell/90 font-bold text-lg">Naturvärdarna på Kullaberg — Sommar {new Date().getFullYear()}</p>
|
<p className="text-eggshell/90 font-bold">Naturvärdarna på Kullaberg — Sommar {new Date().getFullYear()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Daily Notice Card */}
|
{/* Daily Notice Card */}
|
||||||
<div className="bg-gold rounded-2xl p-5 flex items-start shadow-md shadow-goldenrod/20 text-ebony">
|
<div className="bg-goldenrod/30 border border-goldenrod/20 rounded-2xl p-5 flex items-start shadow-sm text-ebony">
|
||||||
<div className="bg-eggshell/40 p-2 rounded-xl mr-4 shrink-0">
|
<AlertTriangle className="text-goldenrod mr-3 shrink-0 mt-0.5" size={24} />
|
||||||
<AlertTriangle className="text-ebony" size={24} />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-black uppercase tracking-widest text-sm mb-1">Dagens Påminnelse</h3>
|
<h3 className="font-black uppercase tracking-widest text-sm mb-1 text-goldenrod">Dagens Påminnelse</h3>
|
||||||
<p className="text-sm font-medium leading-relaxed">
|
<p className="text-sm font-medium leading-relaxed">
|
||||||
Glöm inte minst 1 liter vatten, solkräm och myggmedel. Det förväntas bli mycket varmt idag!
|
Glöm inte minst 1 liter vatten, solkräm och myggmedel. Det förväntas bli mycket varmt idag!
|
||||||
</p>
|
</p>
|
||||||
@@ -29,40 +27,40 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Quick Action Cards */}
|
{/* Quick Action Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Link href="/schedule" className="group bg-eggshell/50 border-2 border-moss/30 rounded-2xl p-6 hover:-translate-y-1 hover:bg-moss/10 hover:border-moss/60 hover:shadow-lg transition-all duration-300 flex flex-col items-start">
|
<Link href="/schedule" className="group bg-eggshell border-2 border-slate-teal/10 rounded-2xl p-5 hover:bg-slate-teal/5 transition-colors flex flex-col items-start shadow-sm">
|
||||||
<div className="bg-seafoam/20 w-12 h-12 rounded-xl flex items-center justify-center mb-4 text-slate-teal group-hover:bg-seafoam group-hover:text-eggshell transition-colors">
|
<div className="bg-seafoam/20 w-10 h-10 rounded-xl flex items-center justify-center mb-3 text-slate-teal group-hover:bg-seafoam group-hover:text-eggshell transition-colors">
|
||||||
<ArrowRight size={24} className="group-hover:rotate-45 transition-transform" />
|
<ArrowRight size={20} className="group-hover:rotate-45 transition-transform" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-black text-ebony text-xl mb-2 uppercase tracking-wide">Ditt Schema</h3>
|
<h3 className="font-black text-ebony text-lg mb-1 uppercase tracking-wide">Ditt Schema</h3>
|
||||||
<p className="text-sm text-ebony/70 font-medium">Kolla dina arbetspass och dagliga rundor snabbt.</p>
|
<p className="text-sm text-ebony/70 font-medium">Kolla dina arbetspass och dagliga rundor snabbt.</p>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/faq" className="group bg-eggshell/50 border-2 border-moss/30 rounded-2xl p-6 hover:-translate-y-1 hover:bg-moss/10 hover:border-moss/60 hover:shadow-lg transition-all duration-300 flex flex-col items-start">
|
<Link href="/faq" className="group bg-eggshell border-2 border-slate-teal/10 rounded-2xl p-5 hover:bg-slate-teal/5 transition-colors flex flex-col items-start shadow-sm">
|
||||||
<div className="bg-seafoam/20 w-12 h-12 rounded-xl flex items-center justify-center mb-4 text-slate-teal group-hover:bg-seafoam group-hover:text-eggshell transition-colors">
|
<div className="bg-seafoam/20 w-10 h-10 rounded-xl flex items-center justify-center mb-3 text-slate-teal group-hover:bg-seafoam group-hover:text-eggshell transition-colors">
|
||||||
<ArrowRight size={24} className="group-hover:rotate-45 transition-transform" />
|
<ArrowRight size={20} className="group-hover:rotate-45 transition-transform" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-black text-ebony text-xl mb-2 uppercase tracking-wide">Vanliga Frågor</h3>
|
<h3 className="font-black text-ebony text-lg mb-1 uppercase tracking-wide">Vanliga Frågor</h3>
|
||||||
<p className="text-sm text-ebony/70 font-medium">Snabba svar på turisternas vanligaste funderingar.</p>
|
<p className="text-sm text-ebony/70 font-medium">Snabba svar på turisternas vanligaste funderingar.</p>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact Card */}
|
{/* Contact Card */}
|
||||||
<div className="bg-eggshell/50 border-2 border-moss/30 rounded-2xl overflow-hidden shadow-sm">
|
<div className="bg-eggshell border-2 border-slate-teal/10 rounded-2xl overflow-hidden shadow-sm">
|
||||||
<div className="bg-moss/20 px-6 py-4 flex items-center border-b-2 border-moss/30">
|
<div className="bg-slate-teal/5 px-5 py-4 flex items-center border-b border-slate-teal/10">
|
||||||
<PhoneCall size={20} className="text-ebony mr-3" />
|
<PhoneCall size={18} className="text-slate-teal mr-3" />
|
||||||
<h3 className="font-black text-ebony uppercase tracking-widest text-sm">Snabbkontakt</h3>
|
<h3 className="font-black text-slate-teal uppercase tracking-widest text-sm">Snabbkontakt</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-5 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">
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center pb-4 border-b border-slate-teal/10 gap-2">
|
||||||
<span className="text-base font-bold text-ebony">William Söderberg</span>
|
<span className="text-sm font-bold text-ebony">William Söderberg</span>
|
||||||
<div className='flex gap-2 flex-wrap md:justify-end'>
|
<div className='flex gap-2 flex-wrap md:justify-end text-sm'>
|
||||||
<SafePhoneLink parts={['072', '247', '02', '91']} display="072-247 02 91" />
|
<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" />
|
<SafePhoneLink parts={['078', '389', '355', '81', '0604']} display="078-389 35 58 10 604" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-3">
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-2">
|
||||||
<span className="text-base font-bold text-ebony">Oliver Nilsson</span>
|
<span className="text-sm font-bold text-ebony">Oliver Nilsson</span>
|
||||||
<div className='flex gap-2 flex-wrap md:justify-end'>
|
<div className='flex gap-2 flex-wrap md:justify-end text-sm'>
|
||||||
<SafePhoneLink parts={['072', '717', '74', '40']} display="072-717 74 40" />
|
<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" />
|
<SafePhoneLink parts={['078', '389', '355', '81', '0605']} display="078-389 35 58 10 605" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+19
-74
@@ -7,62 +7,29 @@ import scheduleData from '../data/schedule.json';
|
|||||||
import { Clock, CalendarRange } from 'lucide-react';
|
import { Clock, CalendarRange } from 'lucide-react';
|
||||||
|
|
||||||
// --- Helper Functions ---
|
// --- Helper Functions ---
|
||||||
|
|
||||||
// Bulletproof parser: Finds the times regardless of spaces or dashes!
|
|
||||||
const parseTimeBlock = (timeStr: string) => {
|
const parseTimeBlock = (timeStr: string) => {
|
||||||
if (!timeStr || timeStr === 'Ledig') return null;
|
if (!timeStr || timeStr === 'Ledig') return null;
|
||||||
|
|
||||||
// Scans the string and extracts any "HH:MM" patterns
|
|
||||||
const matches = timeStr.match(/(\d{1,2}):(\d{2})/g);
|
const matches = timeStr.match(/(\d{1,2}):(\d{2})/g);
|
||||||
if (!matches || matches.length < 2) return null;
|
if (!matches || matches.length < 2) return null;
|
||||||
|
const parse = (t: string) => { const [h, m] = t.split(':').map(Number); return h + (m / 60); };
|
||||||
const parse = (t: string) => {
|
|
||||||
const [h, m] = t.split(':').map(Number);
|
|
||||||
return h + (m / 60);
|
|
||||||
};
|
|
||||||
|
|
||||||
return { start: parse(matches[0]), end: parse(matches[1]) };
|
return { start: parse(matches[0]), end: parse(matches[1]) };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Checks if the two time blocks actually collide
|
|
||||||
const checkOverlap = (time1?: string, time2?: string) => {
|
const checkOverlap = (time1?: string, time2?: string) => {
|
||||||
const t1 = time1 ? parseTimeBlock(time1) : null;
|
const t1 = time1 ? parseTimeBlock(time1) : null;
|
||||||
const t2 = time2 ? parseTimeBlock(time2) : null;
|
const t2 = time2 ? parseTimeBlock(time2) : null;
|
||||||
if (!t1 || !t2) return false;
|
if (!t1 || !t2) return false;
|
||||||
|
|
||||||
return t1.start < t2.end && t1.end > t2.start;
|
return t1.start < t2.end && t1.end > t2.start;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// --- Types & Components ---
|
// --- Types & Components ---
|
||||||
|
interface ShiftData { time: string; title?: string; notes?: string; }
|
||||||
|
interface ShiftBlockProps { team: 'PF' | 'TU'; data: ShiftData; pos: { top: number; height: number }; isOverlapping: boolean; }
|
||||||
|
|
||||||
interface ShiftData {
|
|
||||||
time: string;
|
|
||||||
title?: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShiftBlockProps {
|
|
||||||
team: 'PF' | 'TU';
|
|
||||||
data: ShiftData;
|
|
||||||
pos: { top: number; height: number };
|
|
||||||
isOverlapping: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reusable Component for the Event Cards
|
|
||||||
const ShiftBlock: React.FC<ShiftBlockProps> = ({ team, data, pos, isOverlapping }) => {
|
const ShiftBlock: React.FC<ShiftBlockProps> = ({ team, data, pos, isOverlapping }) => {
|
||||||
const isPF = team === 'PF';
|
const isPF = team === 'PF';
|
||||||
|
const widthClasses = isOverlapping ? isPF ? "left-1 right-1/2 mr-0.5" : "left-1/2 right-1 ml-0.5" : "left-1 right-1";
|
||||||
// Calculate layout based on overlap
|
const bgClasses = isPF ? "bg-gradient-to-br from-gold to-goldenrod border-goldenrod" : "bg-gradient-to-br from-seafoam to-slate-teal border-slate-teal";
|
||||||
const widthClasses = isOverlapping
|
|
||||||
? isPF ? "left-1 right-1/2 mr-0.5" : "left-1/2 right-1 ml-0.5"
|
|
||||||
: "left-1 right-1";
|
|
||||||
|
|
||||||
// Team-specific styling
|
|
||||||
const bgClasses = isPF
|
|
||||||
? "bg-gradient-to-br from-gold to-goldenrod border-goldenrod"
|
|
||||||
: "bg-gradient-to-br from-seafoam to-slate-teal border-slate-teal";
|
|
||||||
|
|
||||||
const textMain = isPF ? "text-ebony" : "text-eggshell";
|
const textMain = isPF ? "text-ebony" : "text-eggshell";
|
||||||
const textMuted = isPF ? "text-ebony/90" : "text-eggshell/90";
|
const textMuted = isPF ? "text-ebony/90" : "text-eggshell/90";
|
||||||
const notesBg = isPF ? "bg-eggshell/30 border-seafoam/5" : "bg-gold/20 border-eggshell/10";
|
const notesBg = isPF ? "bg-eggshell/30 border-seafoam/5" : "bg-gold/20 border-eggshell/10";
|
||||||
@@ -70,54 +37,40 @@ const ShiftBlock: React.FC<ShiftBlockProps> = ({ team, data, pos, isOverlapping
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
// Removed scale on hover! Added brightness-105 for a safe, non-clipping hover effect.
|
className={`absolute ${widthClasses} ${bgClasses} rounded-lg p-1.5 flex flex-col overflow-hidden shadow-sm border transition-colors hover:brightness-105`}
|
||||||
className={`absolute ${widthClasses} ${bgClasses} rounded-lg p-1.5 flex flex-col overflow-hidden shadow-sm border cursor-pointer transition-all hover:brightness-105 hover:shadow-md`}
|
|
||||||
style={{ top: `${pos.top}px`, height: `${pos.height}px` }}
|
style={{ top: `${pos.top}px`, height: `${pos.height}px` }}
|
||||||
>
|
>
|
||||||
<p className={`text-[10px] font-black ${textMain} uppercase tracking-wide leading-tight truncate`}>
|
<p className={`text-[10px] font-black ${textMain} uppercase tracking-wide leading-tight truncate`}>{data.title || fallbackTitle}</p>
|
||||||
{data.title || fallbackTitle}
|
<p className={`text-[9px] font-bold ${textMuted} flex items-center mt-0.5 whitespace-nowrap`}><Clock size={8} className="mr-0.5 shrink-0" /><span className="truncate">{data.time}</span></p>
|
||||||
</p>
|
|
||||||
<p className={`text-[9px] font-bold ${textMuted} flex items-center mt-0.5 whitespace-nowrap`}>
|
|
||||||
<Clock size={8} className="mr-0.5 shrink-0" />
|
|
||||||
<span className="truncate">{data.time}</span>
|
|
||||||
</p>
|
|
||||||
{data.notes && pos.height > 50 && (
|
{data.notes && pos.height > 50 && (
|
||||||
<div className={`mt-1 ${notesBg} p-1 rounded-md border`}>
|
<div className={`mt-1 ${notesBg} p-1 rounded-md border`}>
|
||||||
<p className={`text-[9px] font-medium ${textMain} leading-tight truncate pb-1`}>
|
<p className={`text-[9px] font-medium ${textMain} leading-tight truncate pb-1`}>{data.notes}</p>
|
||||||
{data.notes}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// --- Main Schedule View ---
|
// --- Main Schedule View ---
|
||||||
|
|
||||||
export default function Schedule() {
|
export default function Schedule() {
|
||||||
const startHour = 7;
|
const startHour = 7;
|
||||||
const endHour = 17;
|
const endHour = 17;
|
||||||
const hours = Array.from({ length: endHour - startHour + 1 }, (_, i) => startHour + i);
|
const hours = Array.from({ length: endHour - startHour + 1 }, (_, i) => startHour + i);
|
||||||
const PIXELS_PER_HOUR = 60;
|
const PIXELS_PER_HOUR = 60;
|
||||||
|
|
||||||
const GRID_PADDING_TOP = 24;
|
const GRID_PADDING_TOP = 24;
|
||||||
const TOTAL_GRID_HEIGHT = (hours.length * PIXELS_PER_HOUR) + GRID_PADDING_TOP;
|
const TOTAL_GRID_HEIGHT = (hours.length * PIXELS_PER_HOUR) + GRID_PADDING_TOP;
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const calculatePosition = (timeStr: string) => {
|
const calculatePosition = (timeStr: string) => {
|
||||||
const t = parseTimeBlock(timeStr);
|
const t = parseTimeBlock(timeStr);
|
||||||
if (!t) return null;
|
if (!t) return null;
|
||||||
|
|
||||||
const topOffset = (t.start - startHour) * PIXELS_PER_HOUR;
|
const topOffset = (t.start - startHour) * PIXELS_PER_HOUR;
|
||||||
const duration = (t.end - t.start) * PIXELS_PER_HOUR;
|
const duration = (t.end - t.start) * PIXELS_PER_HOUR;
|
||||||
|
|
||||||
return { top: topOffset + GRID_PADDING_TOP + 1, height: duration - 2 };
|
return { top: topOffset + GRID_PADDING_TOP + 1, height: duration - 2 };
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 animate-fade-in w-full">
|
<div className="space-y-6 animate-fade-in w-full">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-black text-slate-teal tracking-tight flex items-center">
|
<h1 className="text-3xl font-black text-slate-teal tracking-tight flex items-center">
|
||||||
<CalendarRange className="mr-3 text-seafoam" size={32} />
|
<CalendarRange className="mr-3 text-seafoam" size={32} />
|
||||||
@@ -126,17 +79,16 @@ export default function Schedule() {
|
|||||||
<p className="text-moss font-medium mt-2 ml-11">Här hittar du när vi bemannar Kullaberg.</p>
|
<p className="text-moss font-medium mt-2 ml-11">Här hittar du när vi bemannar Kullaberg.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-eggshell border-2 border-slate-teal/20 rounded-2xl shadow-sm overflow-hidden flex flex-col max-h-[65vh] h-max min-h-125">
|
<div className="bg-eggshell border-2 border-slate-teal/10 rounded-2xl shadow-sm overflow-hidden flex flex-col max-h-[60vh] h-max min-h-125">
|
||||||
<div className="flex-1 overflow-auto scrollbar-hide" ref={containerRef}>
|
<div className="flex-1 overflow-auto scrollbar-hide" ref={containerRef}>
|
||||||
<div className="flex min-w-200 w-full">
|
<div className="flex min-w-200 w-full">
|
||||||
|
|
||||||
{/* Sticky Time Column */}
|
{/* Sticky Time Column */}
|
||||||
<div className="sticky left-0 z-20 w-18 flex-none bg-eggshell border-r-2 border-slate-teal/10 shadow-[2px_0_5px_rgba(0,0,0,0.02)]">
|
<div className="sticky left-0 z-20 w-18 flex-none bg-eggshell border-r border-slate-teal/10 shadow-[2px_0_5px_rgba(0,0,0,0.02)]">
|
||||||
<div className="sticky top-0 z-30 h-12 border-b-2 border-slate-teal/10 bg-eggshell"></div>
|
<div className="sticky top-0 z-30 h-10 border-b border-slate-teal/10 bg-eggshell"></div>
|
||||||
<div className="relative w-full" style={{ height: `${TOTAL_GRID_HEIGHT}px` }}>
|
<div className="relative w-full" style={{ height: `${TOTAL_GRID_HEIGHT}px` }}>
|
||||||
{hours.map((hour, i) => (
|
{hours.map((hour, i) => (
|
||||||
<div key={hour} className="absolute w-full flex justify-end pr-3" style={{ top: `${i * PIXELS_PER_HOUR + GRID_PADDING_TOP}px` }}>
|
<div key={hour} className="absolute w-full flex justify-end pr-2" style={{ top: `${i * PIXELS_PER_HOUR + GRID_PADDING_TOP}px` }}>
|
||||||
<span className="text-[11px] font-bold text-slate-teal -mt-2 bg-eggshell px-1">
|
<span className="text-[10px] font-bold text-slate-teal -mt-2 bg-eggshell px-1">
|
||||||
{hour.toString().padStart(2, '0')}:00
|
{hour.toString().padStart(2, '0')}:00
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,9 +99,9 @@ export default function Schedule() {
|
|||||||
{/* Grid Area */}
|
{/* Grid Area */}
|
||||||
<div className="flex-auto relative">
|
<div className="flex-auto relative">
|
||||||
{/* Sticky Day Headers */}
|
{/* Sticky Day Headers */}
|
||||||
<div className="sticky top-0 z-10 flex h-12 border-b-2 border-slate-teal/10 bg-eggshell">
|
<div className="sticky top-0 z-10 flex h-10 border-b border-slate-teal/10 bg-eggshell">
|
||||||
{scheduleData.map((d) => (
|
{scheduleData.map((d) => (
|
||||||
<div key={d.day} className="flex-1 flex items-center justify-center border-l-2 border-slate-teal/5 first:border-l-0 min-w-25">
|
<div key={d.day} className="flex-1 flex items-center justify-center border-l border-slate-teal/5 first:border-l-0 min-w-25">
|
||||||
<span className="text-xs font-black text-ebony uppercase tracking-widest">{d.day.substring(0, 3)}</span>
|
<span className="text-xs font-black text-ebony uppercase tracking-widest">{d.day.substring(0, 3)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -159,30 +111,23 @@ export default function Schedule() {
|
|||||||
<div className="relative w-full" style={{ height: `${TOTAL_GRID_HEIGHT}px` }}>
|
<div className="relative w-full" style={{ height: `${TOTAL_GRID_HEIGHT}px` }}>
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
{hours.map((hour, i) => (
|
{hours.map((hour, i) => (
|
||||||
<div key={hour} className="absolute w-full border-t border-slate-teal/10" style={{ top: `${i * PIXELS_PER_HOUR + GRID_PADDING_TOP}px`, height: `${PIXELS_PER_HOUR}px` }} />
|
<div key={hour} className="absolute w-full border-t border-slate-teal/5" style={{ top: `${i * PIXELS_PER_HOUR + GRID_PADDING_TOP}px`, height: `${PIXELS_PER_HOUR}px` }} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vertical Columns & Blocks */}
|
|
||||||
<div className="absolute inset-0 flex z-0">
|
<div className="absolute inset-0 flex z-0">
|
||||||
{scheduleData.map((d, colIndex) => {
|
{scheduleData.map((d, colIndex) => {
|
||||||
// Type casting here assumes scheduleData is typed loosely.
|
|
||||||
const pfData = d.pilgrimsfalkarna as ShiftData | undefined;
|
const pfData = d.pilgrimsfalkarna as ShiftData | undefined;
|
||||||
const tuData = d.tumlarna as ShiftData | undefined;
|
const tuData = d.tumlarna as ShiftData | undefined;
|
||||||
|
|
||||||
const hasPF = !!(pfData && pfData.time && pfData.time !== 'Ledig');
|
const hasPF = !!(pfData && pfData.time && pfData.time !== 'Ledig');
|
||||||
const hasTU = !!(tuData && tuData.time && tuData.time !== 'Ledig');
|
const hasTU = !!(tuData && tuData.time && tuData.time !== 'Ledig');
|
||||||
const isOverlapping = hasPF && hasTU && checkOverlap(pfData.time, tuData.time);
|
const isOverlapping = hasPF && hasTU && checkOverlap(pfData.time, tuData.time);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={colIndex} className="flex-1 border-l border-slate-teal/10 relative min-w-25">
|
<div key={colIndex} className="flex-1 border-l border-slate-teal/5 relative min-w-25">
|
||||||
{/* Pilgrimsfalkarna Component */}
|
|
||||||
{hasPF && (() => {
|
{hasPF && (() => {
|
||||||
const pos = calculatePosition(pfData.time);
|
const pos = calculatePosition(pfData.time);
|
||||||
return pos ? <ShiftBlock team="PF" data={pfData} pos={pos} isOverlapping={isOverlapping} /> : null;
|
return pos ? <ShiftBlock team="PF" data={pfData} pos={pos} isOverlapping={isOverlapping} /> : null;
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Tumlarna Component */}
|
|
||||||
{hasTU && (() => {
|
{hasTU && (() => {
|
||||||
const pos = calculatePosition(tuData.time);
|
const pos = calculatePosition(tuData.time);
|
||||||
return pos ? <ShiftBlock team="TU" data={tuData} pos={pos} isOverlapping={isOverlapping} /> : null;
|
return pos ? <ShiftBlock team="TU" data={tuData} pos={pos} isOverlapping={isOverlapping} /> : null;
|
||||||
|
|||||||
Reference in New Issue
Block a user