UI improvments
This commit is contained in:
+23
-133
@@ -2,13 +2,11 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CheckCircle, ChevronLeft, ChevronRight, ClipboardCheck, Edit3, FileText, Undo, Zap } from 'lucide-react';
|
import { CalendarRange, CheckCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import falconIcon from '../assets/falcon.svg';
|
|
||||||
import porpoiseIcon from '../assets/porpoise.svg';
|
|
||||||
import scheduleData from '../data/schedule.json';
|
import scheduleData from '../data/schedule.json';
|
||||||
import { type AttendanceDataMap, calculateShiftDuration, formatTimeHHMM, getAttendanceKey, isWeekend, parseTimeInput, type Period, toIsoDate } from './adminTypes';
|
import { type AttendanceDataMap, getAttendanceKey, type Period, toIsoDate } from './adminTypes';
|
||||||
|
import { TeamAttendanceCard } from './TeamAttendanceCard';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
periods: Period[]; attendance: AttendanceDataMap;
|
periods: Period[]; attendance: AttendanceDataMap;
|
||||||
@@ -80,13 +78,6 @@ export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualA
|
|||||||
const todayIso = toIsoDate(new Date());
|
const todayIso = toIsoDate(new Date());
|
||||||
const currentDayStats = getDailyCompletionStats(currentDate, activePeriod, attendance);
|
const currentDayStats = getDailyCompletionStats(currentDate, activePeriod, attendance);
|
||||||
|
|
||||||
const markStandardAttendance = (youthId: string, team: 'PF' | 'TU', shiftId: 'MORNING' | 'AFTERNOON') => {
|
|
||||||
const { time: shiftTime } = getTeamShiftInfo(daySchedule, team);
|
|
||||||
const rawHours = calculateShiftDuration(shiftTime);
|
|
||||||
const actualHours = isWeekend(currentDate) ? Math.max(0, rawHours - 0.5) : rawHours;
|
|
||||||
if (actualHours > 0) setManualAttendance(currentDate, youthId, shiftId, 'Present', actualHours);
|
|
||||||
};
|
|
||||||
|
|
||||||
const teamsToRender = ['PF', 'TU'].sort((a, b) => {
|
const 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;
|
||||||
@@ -97,9 +88,11 @@ 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">
|
||||||
<div className="bg-eggshell border border-slate-teal/10 p-3 sm:p-5 rounded-3xl shadow-sm">
|
<div className="rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md">
|
||||||
<div className="flex justify-between items-center mb-3">
|
<div className="flex justify-between items-center mb-3">
|
||||||
<h3 className="text-xs font-black text-slate-teal uppercase tracking-widest">Periodöversikt</h3>
|
<h2 className="text-sm font-black text-ebony uppercase tracking-widest flex items-center mb-2">
|
||||||
|
<CalendarRange className="mr-2 text-slate-teal" size={20} /> Periodöversikt
|
||||||
|
</h2>
|
||||||
<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/10 py-1.5 px-3 rounded-xl font-bold text-slate-teal text-xs cursor-pointer focus:outline-none shadow-sm">
|
||||||
{periods.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
{periods.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -123,16 +116,15 @@ export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualA
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row justify-between items-center bg-white border border-slate-teal/10 p-3 sm:p-4 rounded-3xl shadow-sm gap-4">
|
<div className="flex flex-row justify-between items-center rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md 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>
|
<button onClick={() => changeDate(-1)} className="p-3 text-slate-teal bg-slate-teal/10 hover:bg-slate-teal/30 rounded-2xl transition-colors"><ChevronLeft size={24} /></button>
|
||||||
<div className="text-center flex-1">
|
<div className="text-center flex-1">
|
||||||
<h2 className="text-lg font-black text-ebony capitalize mb-0.5">{dayNameStr}</h2>
|
<h2 className="text-lg font-black text-ebony capitalize mb-0.5">{dayNameStr}</h2>
|
||||||
<p className="text-[11px] font-black uppercase tracking-widest text-moss">{currentDate}</p>
|
<p className="text-[11px] font-black uppercase tracking-widest text-moss">{currentDate}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => changeDate(1)} className="p-3 text-slate-teal bg-eggshell/50 hover:bg-slate-teal/10 rounded-2xl transition-colors"><ChevronRight size={24} /></button>
|
<button onClick={() => changeDate(1)} className="p-3 text-slate-teal bg-slate-teal/10 hover:bg-slate-teal/30 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={`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'}`}>
|
<div className={`px-5 py-3 rounded-2xl font-black text-xs uppercase tracking-widest flex items-center justify-center shadow-sm border transition-colors ${currentDayStats.isComplete ? 'bg-moss/10 text-moss border-moss/20' : 'bg-goldenrod/10 text-goldenrod border-goldenrod/20'}`}>
|
||||||
{currentDayStats.isComplete ? <><CheckCircle size={16} className="mr-2" /> All närvaro rapporterad</> : `Ifyllt: ${currentDayStats.completed} av ${currentDayStats.expected} pers`}
|
{currentDayStats.isComplete ? <><CheckCircle size={16} className="mr-2" /> All närvaro rapporterad</> : `Ifyllt: ${currentDayStats.completed} av ${currentDayStats.expected} pers`}
|
||||||
@@ -149,122 +141,20 @@ export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualA
|
|||||||
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 isWknd = isWeekend(currentDate);
|
|
||||||
const actualDuration = isWknd ? Math.max(0, rawDuration - 0.5) : rawDuration;
|
|
||||||
const weight = isWknd ? 1.5 : 1.0;
|
|
||||||
const weightedDuration = actualDuration * weight;
|
|
||||||
|
|
||||||
const scheduledYouth = activePeriod.youthList.filter(y => y.team === team);
|
|
||||||
const extraYouth = activePeriod.youthList.filter(y => y.team !== team && attendance[getAttendanceKey(currentDate, y.id, shiftId)]);
|
|
||||||
const displayYouth = [...scheduledYouth, ...extraYouth].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
const availableExtras = activePeriod.youthList.filter(y => !displayYouth.some(dy => dy.id === y.id)).sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
|
|
||||||
const handleQuickLogAll = () => {
|
|
||||||
const recordsToUpdate: any[] = [];
|
|
||||||
displayYouth.forEach(y => {
|
|
||||||
const entry = attendance[getAttendanceKey(currentDate, y.id, shiftId)];
|
|
||||||
if (!entry || entry.status === 'Pending') {
|
|
||||||
recordsToUpdate.push({ date: currentDate, youthId: y.id, shiftId, status: 'Present', hoursWorked: actualDuration, weightedHours: weightedDuration, note: '' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (recordsToUpdate.length > 0) bulkSetManualAttendance(recordsToUpdate);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={team} className="bg-eggshell border border-slate-teal/10 p-4 md:p-5 rounded-3xl shadow-sm">
|
<TeamAttendanceCard
|
||||||
<div className="flex flex-wrap justify-between items-center mb-4 gap-3">
|
key={team}
|
||||||
<div className="flex items-center">
|
team={team}
|
||||||
<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`}>
|
currentDate={currentDate}
|
||||||
<Image src={team === 'PF' ? falconIcon : porpoiseIcon} alt={team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare'} width={18} height={18} />
|
shiftId={shiftId}
|
||||||
</div>
|
standardTime={standardTime}
|
||||||
<h2 className="text-base font-black text-ebony uppercase tracking-widest">{team === 'PF' ? 'Pilgrimsfalkarna' : 'Tumlarna'}</h2>
|
periodYouthList={activePeriod.youthList}
|
||||||
</div>
|
attendance={attendance}
|
||||||
<div className="flex items-center gap-3">
|
setManualAttendance={setManualAttendance}
|
||||||
<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">
|
bulkSetManualAttendance={bulkSetManualAttendance}
|
||||||
<FileText size={14} className="text-slate-teal" /> {standardTime} ({formatTimeHHMM(rawDuration)})
|
addPendingAttendance={addPendingAttendance}
|
||||||
</div>
|
removeAttendanceEntry={removeAttendanceEntry}
|
||||||
<button onClick={handleQuickLogAll} className="flex items-center gap-1.5 bg-moss/10 text-moss hover:bg-moss hover:text-white px-3 py-1.5 rounded-xl font-black uppercase tracking-widest text-[10px] transition-colors shadow-sm">
|
/>
|
||||||
<Zap size={14} /> Alla närvarande
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{displayYouth.map(youth => {
|
|
||||||
const entry = attendance[getAttendanceKey(currentDate, youth.id, shiftId)];
|
|
||||||
const isExtra = youth.team !== team;
|
|
||||||
const isPending = entry?.status === 'Pending';
|
|
||||||
const isCompleted = entry && !isPending;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={youth.id} className={`flex flex-col md:flex-row md:justify-between md:items-center p-3 rounded-2xl border transition-colors ${isCompleted ? 'bg-moss/5 border-moss/20' : (isPending ? 'bg-goldenrod/5 border-goldenrod/30' : 'bg-white border-slate-teal/5 shadow-sm')}`}>
|
|
||||||
<div className="flex items-center gap-3 mb-3 md:mb-0">
|
|
||||||
{isCompleted ? <CheckCircle size={20} className="text-moss shrink-0" /> : <div className="w-5 h-5 rounded-full border-2 border-slate-teal/20 shrink-0 bg-white"></div>}
|
|
||||||
<div className={`flex items-center justify-center w-6 h-6 rounded-full ${youth.team === 'PF' ? 'bg-gold' : 'bg-seafoam'} shrink-0 shadow-inner`}>
|
|
||||||
<Image src={youth.team === 'PF' ? falconIcon : porpoiseIcon} alt={youth.team === 'PF' ? 'PF' : 'TU'} width={12} height={12} />
|
|
||||||
</div>
|
|
||||||
<span className={`font-bold text-base ${isCompleted ? 'text-moss' : 'text-ebony'}`}>
|
|
||||||
{youth.name} {isExtra && <span className="ml-2 text-[10px] text-slate-teal bg-slate-teal/10 px-2 py-0.5 rounded-lg uppercase tracking-widest">Extra pass</span>}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 w-full md:w-auto flex-wrap">
|
|
||||||
{isPending && <span className="text-[10px] font-black uppercase tracking-widest bg-goldenrod/10 text-goldenrod px-3 py-1.5 rounded-xl">Väntar...</span>}
|
|
||||||
{isCompleted && (
|
|
||||||
<span className="text-xs font-black uppercase tracking-widest bg-white text-moss px-3 py-1.5 rounded-xl border border-moss/10 shadow-sm">
|
|
||||||
{entry.status === 'Absent' ? entry.note : `${formatTimeHHMM(entry.hoursWorked)} (+${entry.weightedHours.toFixed(1)}h)`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(!entry || isPending) && (
|
|
||||||
<>
|
|
||||||
<button onClick={() => markStandardAttendance(youth.id, team as 'PF' | 'TU', shiftId)} className="bg-slate-teal/10 text-slate-teal hover:bg-slate-teal hover:text-white px-3 py-1.5 rounded-xl font-black uppercase tracking-widest text-[10px] flex items-center gap-1.5 transition-colors shadow-sm">
|
|
||||||
<ClipboardCheck size={14} /> Hela passet
|
|
||||||
</button>
|
|
||||||
<button onClick={() => {
|
|
||||||
const input = prompt(`Timmar arbetade:`, formatTimeHHMM(actualDuration));
|
|
||||||
const hrs = input ? parseTimeInput(input) : 0;
|
|
||||||
if (hrs > 0) setManualAttendance(currentDate, youth.id, shiftId, 'Present', hrs);
|
|
||||||
}} className="bg-goldenrod/10 text-goldenrod hover:bg-goldenrod hover:text-white p-1.5 rounded-xl transition-colors shadow-sm">
|
|
||||||
<Edit3 size={16} />
|
|
||||||
</button>
|
|
||||||
<select
|
|
||||||
value=""
|
|
||||||
onChange={(e) => {
|
|
||||||
if (!e.target.value) return;
|
|
||||||
let reason = e.target.value;
|
|
||||||
if (reason === 'Custom') reason = prompt('Ange anledning:') || 'Frånvarande';
|
|
||||||
setManualAttendance(currentDate, youth.id, shiftId, 'Absent', 0, reason);
|
|
||||||
}}
|
|
||||||
className="bg-goldenrod/10 text-goldenrod px-2 py-1.5 rounded-xl font-black uppercase tracking-widest text-[10px] outline-none shadow-sm"
|
|
||||||
>
|
|
||||||
<option value="">+ Frånvaro</option>
|
|
||||||
<option value="Sjuk">Sjuk</option><option value="Uteblev">Uteblev</option><option value="Ledig">Ledig</option><option value="Custom">Annan...</option>
|
|
||||||
</select>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{entry && (
|
|
||||||
<button onClick={() => removeAttendanceEntry(currentDate, youth.id, shiftId)} className="text-emergency/60 hover:text-emergency p-1.5 bg-white rounded-xl border border-emergency/10 transition-colors shadow-sm">
|
|
||||||
<Undo size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{availableExtras.length > 0 && (
|
|
||||||
<div className="pt-3 mt-3 border-t border-slate-teal/5">
|
|
||||||
<select value="" onChange={(e) => { if (e.target.value) addPendingAttendance(currentDate, e.target.value, shiftId); }} className="bg-white border border-slate-teal/10 p-3 rounded-2xl font-bold text-slate-teal text-sm w-full outline-none shadow-sm">
|
|
||||||
<option value="">+ Lägg till extra person...</option>
|
|
||||||
{availableExtras.map(y => <option key={y.id} value={y.id}>{y.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
+17
-105
@@ -2,21 +2,16 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertTriangle, ChevronDown, ChevronUp, Clock, Download, Users } from 'lucide-react';
|
import { Download, Users } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import scheduleData from '../data/schedule.json';
|
|
||||||
import falconIcon from '../assets/falcon.svg';
|
|
||||||
import porpoiseIcon from '../assets/porpoise.svg';
|
|
||||||
import { type AttendanceDataMap, formatTimeHHMM, isWeekend, type Period, type Role } from './adminTypes';
|
import { type AttendanceDataMap, formatTimeHHMM, isWeekend, type Period, type Role } from './adminTypes';
|
||||||
import { getTeamShiftInfo } from './AttendanceTab';
|
import { YouthReportCard } from './YouthReportCard';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
periods: Period[]; attendance: AttendanceDataMap; activePeriodId: string; setActivePeriodId: (id: string) => void; currentUserRole: Role;
|
periods: Period[]; attendance: AttendanceDataMap; activePeriodId: string; setActivePeriodId: (id: string) => void; currentUserRole: Role;
|
||||||
}
|
}
|
||||||
|
|
||||||
const POT_HOUR_LIMIT = 90;
|
const POT_HOUR_LIMIT = 90;
|
||||||
const formatHours = (h: number) => Number(h.toFixed(1)).toString();
|
|
||||||
|
|
||||||
export const ReportTab: React.FC<Props> = ({ periods, attendance, activePeriodId, setActivePeriodId, currentUserRole }) => {
|
export const ReportTab: React.FC<Props> = ({ periods, attendance, activePeriodId, setActivePeriodId, currentUserRole }) => {
|
||||||
const activePeriod = periods.find(p => p.id === activePeriodId);
|
const activePeriod = periods.find(p => p.id === activePeriodId);
|
||||||
@@ -81,7 +76,7 @@ export const ReportTab: React.FC<Props> = ({ periods, attendance, activePeriodId
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
<div className="bg-eggshell border border-slate-teal/10 p-3 sm:p-5 rounded-3xl shadow-sm flex flex-col md:flex-row justify-between items-center gap-4">
|
<div className="flex flex-col md:flex-row justify-between items-center gap-4 rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md">
|
||||||
<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/10 p-3 rounded-xl font-bold text-slate-teal text-sm w-full md:w-auto focus:outline-none shadow-sm">
|
||||||
{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>
|
||||||
@@ -91,10 +86,10 @@ export const ReportTab: React.FC<Props> = ({ periods, attendance, activePeriodId
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-eggshell border border-slate-teal/10 p-3 sm:p-5 rounded-3xl shadow-sm">
|
<div>
|
||||||
<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-6 gap-3">
|
||||||
<h2 className="text-base font-black text-ebony uppercase tracking-widest flex items-center">
|
<h2 className="font-black text-ebony uppercase tracking-widest flex items-center text-2xl justify-center sm:justify-normal">
|
||||||
<Users className="mr-2 text-slate-teal" size={20} /> Timrapport
|
<Users className="mr-2 text-slate-teal" size={24} /> Timrapport
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{currentUserRole !== 'Viewer' && (
|
{currentUserRole !== 'Viewer' && (
|
||||||
@@ -105,100 +100,17 @@ export const ReportTab: React.FC<Props> = ({ periods, attendance, activePeriodId
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{sortedActiveYouth.map(youth => {
|
{sortedActiveYouth.map(youth => (
|
||||||
const total = getPeriodHoursTotal(youth.id);
|
<YouthReportCard
|
||||||
const warningStatus = total > POT_HOUR_LIMIT ? 'red' : (total >= POT_HOUR_LIMIT - 10 ? 'yellow' : 'none');
|
key={youth.id}
|
||||||
const isExpanded = expandedYouthId === youth.id;
|
youth={youth}
|
||||||
const timeline = getTimelineForYouth(youth.id);
|
totalHours={getPeriodHoursTotal(youth.id)}
|
||||||
|
potHourLimit={POT_HOUR_LIMIT}
|
||||||
return (
|
isExpanded={expandedYouthId === youth.id}
|
||||||
<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'}`}>
|
onToggle={() => setExpandedYouthId(expandedYouthId === youth.id ? null : youth.id)}
|
||||||
|
timeline={getTimelineForYouth(youth.id)}
|
||||||
<div
|
/>
|
||||||
onClick={() => setExpandedYouthId(isExpanded ? null : youth.id)}
|
))}
|
||||||
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 ? 'Dölj detaljer' : 'Klicka för detaljer'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-4">
|
|
||||||
{warningStatus === 'red' && <AlertTriangle className="text-goldenrod shrink-0" size={28} />}
|
|
||||||
<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')}`}>
|
|
||||||
{formatHours(total)}<span className="text-sm font-bold text-ebony/40"> / {POT_HOUR_LIMIT}h</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] font-black uppercase tracking-widest mt-0.5 text-ebony/50">Viktade timmar</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="bg-slate-teal/5 border-t border-slate-teal/10 p-4 md:p-6">
|
|
||||||
<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
|
|
||||||
</h4>
|
|
||||||
{timeline.length === 0 ? (
|
|
||||||
<p className="text-sm font-bold text-slate-teal/60 italic">Ingen närvaro loggad.</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{timeline.map((entry, idx) => {
|
|
||||||
const isWknd = isWeekend(entry.date);
|
|
||||||
const dayNameStr = new Date(entry.date + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase();
|
|
||||||
const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr);
|
|
||||||
|
|
||||||
// FIX: Bulletproof isExtra logic that catches weekend-workers too!
|
|
||||||
let isExtra = false;
|
|
||||||
if (daySchedule) {
|
|
||||||
const expectedShift = getTeamShiftInfo(daySchedule, youth.team);
|
|
||||||
if (expectedShift.time.toLowerCase() === 'ledig') {
|
|
||||||
isExtra = true;
|
|
||||||
} else if (!isWknd) {
|
|
||||||
isExtra = entry.shiftId !== expectedShift.shiftId;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
isExtra = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shiftLabel = isWknd ? 'Heldag' : (entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{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>}
|
|
||||||
{isWknd && <span className="text-[10px] font-black uppercase tracking-widest text-goldenrod bg-goldenrod/10 px-2 py-1 rounded-lg">Helg</span>}
|
|
||||||
</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>}
|
|
||||||
|
|
||||||
<span className="font-black text-slate-teal text-base">
|
|
||||||
{formatTimeHHMM(entry.hoursWorked)} <span className="text-xs opacity-70">(+{entry.weightedHours.toFixed(1)}h)</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+35
-11
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CalendarRange, Trash2, UserPlus } from 'lucide-react';
|
import { CalendarPlus, Trash2, UserPlus } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import falconIcon from '../assets/falcon.svg';
|
import falconIcon from '../assets/falcon.svg';
|
||||||
@@ -78,14 +78,39 @@ 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">
|
||||||
<div className="bg-eggshell border border-slate-teal/10 p-5 rounded-3xl shadow-sm">
|
<div className="rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md">
|
||||||
<h2 className="text-sm font-black text-ebony uppercase tracking-widest flex items-center mb-4">
|
<h2 className="text-sm font-black text-ebony uppercase tracking-widest flex items-center mb-4">
|
||||||
<CalendarRange className="mr-2 text-slate-teal" size={20} /> Skapa Ny Period
|
<CalendarPlus className="mr-2 text-slate-teal" size={20} /> Skapa Ny Period
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<input type="text" id="periodName" placeholder="T.ex. Period 1" className="w-full bg-white border border-slate-teal/10 p-3 rounded-xl text-sm font-bold focus:outline-none focus:border-slate-teal shadow-sm" disabled={isOffline} />
|
{/* Changed from Grid to Flex */}
|
||||||
<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} />
|
<div className="flex flex-col md:flex-row gap-4 w-full">
|
||||||
<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">
|
|
||||||
|
{/* Wrapping inputs in flex-1 min-w-0 forces Safari to respect the bounds */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="periodName"
|
||||||
|
placeholder="T.ex. Period 1"
|
||||||
|
className="w-full appearance-none 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="periodStart"
|
||||||
|
className="w-full appearance-none 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={isOffline}
|
||||||
|
className="w-full md:w-auto md:px-8 shrink-0 bg-slate-teal text-eggshell font-black uppercase tracking-widest text-xs py-3 rounded-xl hover:bg-ebony transition-colors disabled:opacity-50 shadow-sm"
|
||||||
|
>
|
||||||
Starta
|
Starta
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,8 +121,8 @@ export const SetupTab: React.FC<Props> = ({ periods, createPeriod, deletePeriod,
|
|||||||
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 border-slate-teal/10 rounded-3xl shadow-sm overflow-hidden">
|
<div key={period.id} className="rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 hover:shadow-md overflow-hidden">
|
||||||
<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 className="p-5 flex justify-between items-center cursor-pointer hover:bg-moss/10 transition-colors" onClick={() => setExpandedPeriod(expandedPeriod === period.id ? null : period.id)}>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-black text-ebony uppercase tracking-wide">{period.name}</h3>
|
<h3 className="text-xl font-black text-ebony uppercase tracking-wide">{period.name}</h3>
|
||||||
<p className="text-xs font-bold text-moss mt-1">{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>
|
||||||
@@ -108,12 +133,11 @@ export const SetupTab: React.FC<Props> = ({ periods, createPeriod, deletePeriod,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expandedPeriod === period.id && (
|
{expandedPeriod === period.id && (
|
||||||
<div className="p-5 border-t border-slate-teal/10 bg-slate-teal/5">
|
<div className="p-5 border-t border-slate-teal/10 bg-moss/10">
|
||||||
<h4 className="font-black text-slate-teal mb-4 flex items-center uppercase tracking-widest text-xs">
|
<h4 className="font-black text-slate-teal mb-4 flex items-center uppercase tracking-widest text-xs">
|
||||||
<UserPlus size={16} className="mr-2" /> Bulk-lägg till ungdomar
|
<UserPlus size={16} className="mr-2" /> Bulk-lägg till ungdomar
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex flex-col md:flex-row gap-4 mb-8">
|
<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)}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// app/admin/TeamAttendanceCard.tsx
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ClockFading, Zap } from 'lucide-react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import React from 'react';
|
||||||
|
import falconIcon from '../assets/falcon.svg';
|
||||||
|
import porpoiseIcon from '../assets/porpoise.svg';
|
||||||
|
import { formatTimeHHMM, getAttendanceKey, calculateShiftDuration, isWeekend, type AttendanceDataMap, type Youth } from './adminTypes';
|
||||||
|
import { YouthAttendanceRow } from './YouthAttendanceRow';
|
||||||
|
|
||||||
|
interface TeamAttendanceCardProps {
|
||||||
|
team: 'PF' | 'TU';
|
||||||
|
currentDate: string;
|
||||||
|
shiftId: 'MORNING' | 'AFTERNOON';
|
||||||
|
standardTime: string;
|
||||||
|
periodYouthList: Youth[];
|
||||||
|
attendance: AttendanceDataMap;
|
||||||
|
setManualAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON', status: 'Absent' | 'Late' | 'Present', hours: number, note?: string) => void;
|
||||||
|
bulkSetManualAttendance: (records: any[]) => void;
|
||||||
|
addPendingAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
|
||||||
|
removeAttendanceEntry: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TeamAttendanceCard: React.FC<TeamAttendanceCardProps> = ({
|
||||||
|
team,
|
||||||
|
currentDate,
|
||||||
|
shiftId,
|
||||||
|
standardTime,
|
||||||
|
periodYouthList,
|
||||||
|
attendance,
|
||||||
|
setManualAttendance,
|
||||||
|
bulkSetManualAttendance,
|
||||||
|
addPendingAttendance,
|
||||||
|
removeAttendanceEntry
|
||||||
|
}) => {
|
||||||
|
// Logic moved inside the component
|
||||||
|
const rawDuration = calculateShiftDuration(standardTime);
|
||||||
|
const isWknd = isWeekend(currentDate);
|
||||||
|
const actualDuration = isWknd ? Math.max(0, rawDuration - 0.5) : rawDuration;
|
||||||
|
const weightedDuration = actualDuration * (isWknd ? 1.5 : 1.0);
|
||||||
|
|
||||||
|
const scheduledYouth = periodYouthList.filter(y => y.team === team);
|
||||||
|
const extraYouth = periodYouthList.filter(y => y.team !== team && attendance[getAttendanceKey(currentDate, y.id, shiftId)]);
|
||||||
|
const displayYouth = [...scheduledYouth, ...extraYouth].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const availableExtras = periodYouthList.filter(y => !displayYouth.some(dy => dy.id === y.id)).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const handleQuickLogAll = () => {
|
||||||
|
const recordsToUpdate: any[] = [];
|
||||||
|
displayYouth.forEach(y => {
|
||||||
|
const entry = attendance[getAttendanceKey(currentDate, y.id, shiftId)];
|
||||||
|
if (!entry || entry.status === 'Pending') {
|
||||||
|
recordsToUpdate.push({ date: currentDate, youthId: y.id, shiftId, status: 'Present', hoursWorked: actualDuration, weightedHours: weightedDuration, note: '' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (recordsToUpdate.length > 0) bulkSetManualAttendance(recordsToUpdate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkStandard = (youthId: string) => {
|
||||||
|
if (actualDuration > 0) setManualAttendance(currentDate, youthId, shiftId, 'Present', actualDuration);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md">
|
||||||
|
<div className="flex flex-wrap justify-between items-center mb-4 gap-3">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className={`flex items-center justify-center w-10 h-10 rounded-xl ${team === 'PF' ? 'bg-gold' : 'bg-seafoam'} mr-3 shadow-inner shrink-0`}>
|
||||||
|
<Image src={team === 'PF' ? falconIcon : porpoiseIcon} alt={team === 'PF' ? 'Pilgrimsfalk' : 'Tumlare'} width={18} height={18} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-base font-black text-ebony uppercase tracking-widest">{team === 'PF' ? 'Pilgrimsfalkarna' : 'Tumlarna'}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 sm:w-fit w-full justify-between">
|
||||||
|
<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">
|
||||||
|
<ClockFading size={14} className="text-slate-teal" /> {standardTime} ({formatTimeHHMM(rawDuration)})
|
||||||
|
</div>
|
||||||
|
<button onClick={handleQuickLogAll} className="flex items-center gap-1.5 bg-moss/70 text-white 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} /> Snabblog
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{displayYouth.map(youth => (
|
||||||
|
<YouthAttendanceRow
|
||||||
|
key={youth.id}
|
||||||
|
youth={youth}
|
||||||
|
entry={attendance[getAttendanceKey(currentDate, youth.id, shiftId)]}
|
||||||
|
currentDate={currentDate}
|
||||||
|
shiftId={shiftId}
|
||||||
|
team={team}
|
||||||
|
actualDuration={actualDuration}
|
||||||
|
onMarkStandard={() => handleMarkStandard(youth.id)}
|
||||||
|
setManualAttendance={setManualAttendance}
|
||||||
|
removeAttendanceEntry={removeAttendanceEntry}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{availableExtras.length > 0 && (
|
||||||
|
<div className="pt-3 mt-3 border-t border-slate-teal/5">
|
||||||
|
<select value="" onChange={(e) => { if (e.target.value) addPendingAttendance(currentDate, e.target.value, shiftId); }} className="bg-white border border-slate-teal/10 p-3 rounded-2xl font-bold text-slate-teal text-sm w-full outline-none shadow-sm">
|
||||||
|
<option value="">+ Lägg till extra person...</option>
|
||||||
|
{availableExtras.map(y => <option key={y.id} value={y.id}>{y.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// app/admin/YouthAttendanceRow.tsx
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CheckCircle, ClipboardCheck, Edit3, Undo } from 'lucide-react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import React from 'react';
|
||||||
|
import falconIcon from '../assets/falcon.svg';
|
||||||
|
import porpoiseIcon from '../assets/porpoise.svg';
|
||||||
|
import { formatTimeHHMM, parseTimeInput, type Youth } from './adminTypes';
|
||||||
|
|
||||||
|
interface YouthAttendanceRowProps {
|
||||||
|
youth: Youth;
|
||||||
|
entry: any;
|
||||||
|
currentDate: string;
|
||||||
|
shiftId: 'MORNING' | 'AFTERNOON';
|
||||||
|
team: 'PF' | 'TU';
|
||||||
|
actualDuration: number;
|
||||||
|
onMarkStandard: () => void;
|
||||||
|
setManualAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON', status: 'Absent' | 'Late' | 'Present', hours: number, note?: string) => void;
|
||||||
|
removeAttendanceEntry: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const YouthAttendanceRow: React.FC<YouthAttendanceRowProps> = ({
|
||||||
|
youth,
|
||||||
|
entry,
|
||||||
|
currentDate,
|
||||||
|
shiftId,
|
||||||
|
team,
|
||||||
|
actualDuration,
|
||||||
|
onMarkStandard,
|
||||||
|
setManualAttendance,
|
||||||
|
removeAttendanceEntry
|
||||||
|
}) => {
|
||||||
|
// Logic moved inside the component
|
||||||
|
const isExtra = youth.team !== team;
|
||||||
|
const isPending = entry?.status === 'Pending';
|
||||||
|
const isCompleted = !!entry && !isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col md:flex-row md:justify-between md:items-center p-3 rounded-2xl border transition-colors ${isCompleted ? 'bg-moss/5 border-moss/20' : (isPending ? 'bg-goldenrod/5 border-goldenrod/30' : 'bg-white border-slate-teal/5 shadow-sm')}`}>
|
||||||
|
<div className="flex items-center gap-3 mb-3 md:mb-0">
|
||||||
|
{isCompleted ? <CheckCircle size={20} className="text-moss shrink-0" /> : <div className="w-5 h-5 rounded-full border-2 border-slate-teal/20 shrink-0 bg-white"></div>}
|
||||||
|
<div className={`flex items-center justify-center w-6 h-6 rounded-full ${youth.team === 'PF' ? 'bg-gold' : 'bg-seafoam'} shrink-0 shadow-inner`}>
|
||||||
|
<Image src={youth.team === 'PF' ? falconIcon : porpoiseIcon} alt={youth.team === 'PF' ? 'PF' : 'TU'} width={12} height={12} />
|
||||||
|
</div>
|
||||||
|
<span className={`font-bold text-base ${isCompleted ? 'text-moss' : 'text-ebony'}`}>
|
||||||
|
{youth.name} {isExtra && <span className="ml-2 text-[10px] text-slate-teal bg-slate-teal/10 px-2 py-0.5 rounded-lg uppercase tracking-widest">Extra pass</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 w-full md:w-auto flex-wrap">
|
||||||
|
{isPending && <span className="text-[10px] font-black uppercase tracking-widest bg-goldenrod/10 text-goldenrod px-3 py-1.5 rounded-xl">Väntar...</span>}
|
||||||
|
{isCompleted && (
|
||||||
|
<span className="text-xs font-black uppercase tracking-widest bg-white text-moss px-3 py-1.5 rounded-xl border border-moss/10 shadow-sm">
|
||||||
|
{entry.status === 'Absent' ? entry.note : `${formatTimeHHMM(entry.hoursWorked)} (+${entry.weightedHours.toFixed(1)}h)`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!entry || isPending) && (
|
||||||
|
<>
|
||||||
|
<button onClick={onMarkStandard} className="bg-slate-teal/10 text-slate-teal hover:bg-slate-teal hover:text-white px-3 py-1.5 rounded-xl font-black uppercase tracking-widest text-[10px] flex items-center gap-1.5 transition-colors shadow-sm">
|
||||||
|
<ClipboardCheck size={14} /> Hela passet
|
||||||
|
</button>
|
||||||
|
<button onClick={() => {
|
||||||
|
const input = prompt(`Timmar arbetade:`, formatTimeHHMM(actualDuration));
|
||||||
|
const hrs = input ? parseTimeInput(input) : 0;
|
||||||
|
if (hrs > 0) setManualAttendance(currentDate, youth.id, shiftId, 'Present', hrs);
|
||||||
|
}} className="bg-goldenrod/10 text-goldenrod hover:bg-goldenrod hover:text-white p-1.5 rounded-xl transition-colors shadow-sm">
|
||||||
|
<Edit3 size={16} />
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!e.target.value) return;
|
||||||
|
let reason = e.target.value;
|
||||||
|
if (reason === 'Custom') reason = prompt('Ange anledning:') || 'Frånvarande';
|
||||||
|
setManualAttendance(currentDate, youth.id, shiftId, 'Absent', 0, reason);
|
||||||
|
}}
|
||||||
|
className="bg-goldenrod/10 text-goldenrod px-2 py-1.5 rounded-xl font-black uppercase tracking-widest text-[10px] outline-none shadow-sm"
|
||||||
|
>
|
||||||
|
<option value="">+ Frånvaro</option>
|
||||||
|
<option value="Sjuk">Sjuk</option><option value="Uteblev">Uteblev</option><option value="Ledig">Ledig</option><option value="Custom">Annan...</option>
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entry && (
|
||||||
|
<button onClick={() => removeAttendanceEntry(currentDate, youth.id, shiftId)} className="text-emergency/60 hover:text-emergency p-1.5 bg-white rounded-xl border border-emergency/10 transition-colors shadow-sm">
|
||||||
|
<Undo size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// app/admin/YouthReportCard.tsx
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AlertTriangle, ChevronDown, ChevronUp, Clock } from 'lucide-react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import React from 'react';
|
||||||
|
import falconIcon from '../assets/falcon.svg';
|
||||||
|
import porpoiseIcon from '../assets/porpoise.svg';
|
||||||
|
import scheduleData from '../data/schedule.json';
|
||||||
|
import { formatTimeHHMM, isWeekend, type Youth } from './adminTypes';
|
||||||
|
import { getTeamShiftInfo } from './AttendanceTab';
|
||||||
|
|
||||||
|
interface YouthReportCardProps {
|
||||||
|
youth: Youth;
|
||||||
|
totalHours: number;
|
||||||
|
potHourLimit: number;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
timeline: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatHours = (h: number) => Number(h.toFixed(1)).toString();
|
||||||
|
|
||||||
|
export const YouthReportCard: React.FC<YouthReportCardProps> = ({
|
||||||
|
youth,
|
||||||
|
totalHours,
|
||||||
|
potHourLimit,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
timeline
|
||||||
|
}) => {
|
||||||
|
const warningStatus = totalHours > potHourLimit ? 'red' : (totalHours >= potHourLimit - 10 ? 'yellow' : 'none');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div 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
|
||||||
|
onClick={onToggle}
|
||||||
|
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 ? 'Dölj detaljer' : 'Klicka för detaljer'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-4">
|
||||||
|
{warningStatus === 'red' && <AlertTriangle className="text-goldenrod shrink-0" size={28} />}
|
||||||
|
<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')}`}>
|
||||||
|
{formatHours(totalHours)}<span className="text-sm font-bold text-ebony/40"> / {potHourLimit}h</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-widest mt-0.5 text-ebony/50">Viktade timmar</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="bg-slate-teal/5 border-t border-slate-teal/10 p-4 md:p-6">
|
||||||
|
<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
|
||||||
|
</h4>
|
||||||
|
{timeline.length === 0 ? (
|
||||||
|
<p className="text-sm font-bold text-slate-teal/60 italic">Ingen närvaro loggad.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{timeline.map((entry, idx) => {
|
||||||
|
const isWknd = isWeekend(entry.date);
|
||||||
|
const dateObj = new Date(entry.date + 'T12:00:00');
|
||||||
|
const dayNameStrFull = dateObj.toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase();
|
||||||
|
|
||||||
|
const dayNameShort = dateObj.toLocaleDateString('sv-SE', { weekday: 'short' });
|
||||||
|
const capitalizedShortDay = dayNameShort.charAt(0).toUpperCase() + dayNameShort.slice(1);
|
||||||
|
|
||||||
|
const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStrFull);
|
||||||
|
|
||||||
|
let isExtra = false;
|
||||||
|
if (daySchedule) {
|
||||||
|
const expectedShift = getTeamShiftInfo(daySchedule, youth.team);
|
||||||
|
if (expectedShift.time.toLowerCase() === 'ledig') {
|
||||||
|
isExtra = true;
|
||||||
|
} else if (!isWknd) {
|
||||||
|
isExtra = entry.shiftId !== expectedShift.shiftId;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isExtra = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shiftLabel = entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<span className="font-black text-ebony min-w-24">
|
||||||
|
{entry.date} | {capitalizedShortDay}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 1. Shift Label */}
|
||||||
|
{!isWknd && (
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-slate-teal bg-slate-teal/10 px-2 py-1 rounded-lg">
|
||||||
|
{shiftLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 2. Weekend Tag */}
|
||||||
|
{isWknd && <span className="text-[10px] font-black uppercase tracking-widest text-goldenrod bg-goldenrod/10 px-2 py-1 rounded-lg">Helg</span>}
|
||||||
|
|
||||||
|
{/* 3. Extra Tag (Moved to end, made bolder) */}
|
||||||
|
{isExtra && (
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest bg-slate-teal text-eggshell px-2 py-1 rounded-lg shadow-sm">
|
||||||
|
Extra
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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>}
|
||||||
|
|
||||||
|
<span className="font-black text-slate-teal text-base">
|
||||||
|
{formatTimeHHMM(entry.hoursWorked)} <span className="text-xs opacity-70">(+{entry.weightedHours.toFixed(1)}h)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+59
-20
@@ -2,22 +2,24 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import { CalendarRange, ClipboardCheck, Loader2, Lock, Unlock, Users as UsersIcon } from 'lucide-react';
|
||||||
import { Lock, Unlock, CalendarRange, ClipboardCheck, Users as UsersIcon, Loader2, WifiOff } from 'lucide-react';
|
import React, { useEffect, useState, useRef } from 'react'; // <-- Added useRef
|
||||||
|
import { verifyLogin } from '../actions/admin';
|
||||||
|
import { OfflineBadge } from '../components/ui/OfflineBadge';
|
||||||
import { AppUser } from './adminTypes';
|
import { AppUser } from './adminTypes';
|
||||||
import { useAdminState } from './useAdminState';
|
|
||||||
import { SetupTab } from './SetupTab';
|
|
||||||
import { AttendanceTab } from './AttendanceTab';
|
import { AttendanceTab } from './AttendanceTab';
|
||||||
import { ReportTab } from './ReportTab';
|
import { ReportTab } from './ReportTab';
|
||||||
|
import { SetupTab } from './SetupTab';
|
||||||
import { verifyLogin } from '../actions/admin';
|
import { useAdminState } from './useAdminState';
|
||||||
|
|
||||||
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);
|
const [isCheckingSession, setIsCheckingSession] = useState(true);
|
||||||
|
|
||||||
const [username, setUsername] = useState('');
|
// Replaced username/pin state with refs to stop React from fighting the autofill
|
||||||
const [pin, setPin] = useState('');
|
const usernameRef = useRef<HTMLInputElement>(null);
|
||||||
|
const pinRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [loginError, setLoginError] = useState(false);
|
const [loginError, setLoginError] = useState(false);
|
||||||
|
|
||||||
@@ -38,8 +40,21 @@ export default function Admin() {
|
|||||||
|
|
||||||
const handleLogin = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
const handleLogin = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true); setLoginError(false);
|
setLoginError(false);
|
||||||
const result = await verifyLogin(username, pin);
|
|
||||||
|
// Read values directly from the DOM nodes on submit
|
||||||
|
const usernameVal = usernameRef.current?.value || '';
|
||||||
|
const pinVal = pinRef.current?.value || '';
|
||||||
|
|
||||||
|
// Basic validation before hitting the server
|
||||||
|
if (!usernameVal || !pinVal) {
|
||||||
|
setLoginError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const result = await verifyLogin(usernameVal, pinVal);
|
||||||
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);
|
||||||
@@ -48,13 +63,15 @@ export default function Admin() {
|
|||||||
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(''); setLoginError(true);
|
// Clear the pin field on failure
|
||||||
|
if (pinRef.current) pinRef.current.value = '';
|
||||||
|
setLoginError(true);
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
setCurrentUser(null); setUsername(''); setPin('');
|
setCurrentUser(null);
|
||||||
localStorage.removeItem('kullaberg_admin_session');
|
localStorage.removeItem('kullaberg_admin_session');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,10 +87,36 @@ export default function Admin() {
|
|||||||
<h2 className="text-2xl font-black text-ebony uppercase mb-6 tracking-widest">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-3 text-left">
|
<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" />
|
<div className="relative">
|
||||||
<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'}`} />
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
autoComplete="username"
|
||||||
|
type="text"
|
||||||
|
ref={usernameRef} // <-- Uncontrolled Ref
|
||||||
|
placeholder="Användarnamn"
|
||||||
|
className="w-full bg-white border border-slate-teal/20 text-center text-ebony font-bold p-3 rounded-xl focus:outline-none focus:border-slate-teal"
|
||||||
|
onChange={() => setLoginError(false)} // Just clear error on type, don't trigger full re-render
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="pin"
|
||||||
|
name="pin"
|
||||||
|
autoComplete="current-password"
|
||||||
|
type="password"
|
||||||
|
ref={pinRef} // <-- Uncontrolled Ref
|
||||||
|
placeholder="•••••"
|
||||||
|
className={`w-full bg-white border text-center text-2xl 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'}`}
|
||||||
|
onChange={() => setLoginError(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{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">
|
|
||||||
|
{/* Removed the disabled state logic since we don't track live values anymore */}
|
||||||
|
<button type="submit" disabled={isLoading} 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">
|
||||||
{isLoading ? <Loader2 className="animate-spin mx-auto" /> : 'Logga in'}
|
{isLoading ? <Loader2 className="animate-spin mx-auto" /> : 'Logga in'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -95,11 +138,7 @@ export default function Admin() {
|
|||||||
<Unlock className="text-seafoam" size={24} />
|
<Unlock className="text-seafoam" size={24} />
|
||||||
<h1 className="text-2xl font-black text-slate-teal uppercase tracking-widest">Admin</h1>
|
<h1 className="text-2xl font-black text-slate-teal uppercase tracking-widest">Admin</h1>
|
||||||
</div>
|
</div>
|
||||||
{adminState.isOffline && (
|
{adminState.isOffline && <OfflineBadge className="mt-2" />}
|
||||||
<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={12} className="mr-1.5" /> Offline Läge
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-sm md:text-right bg-eggshell md:bg-transparent p-2 md:p-0 rounded-lg">
|
<div className="flex items-center gap-3 text-sm md:text-right bg-eggshell md:bg-transparent p-2 md:p-0 rounded-lg">
|
||||||
<span className="font-bold text-ebony">Inloggad: {currentUser.name}</span>
|
<span className="font-bold text-ebony">Inloggad: {currentUser.name}</span>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { Briefcase, Calendar, FileText, Home as HomeIcon, LifeBuoy, Lock, Map as MapIcon } from 'lucide-react';
|
import { Briefcase, Calendar, FileText, Home as HomeIcon, LifeBuoy, Lock, Map as MapIcon } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
const NavItem = ({ to, icon: Icon, label, className = "" }: { to: string, icon: any, label: string, className?: string }) => {
|
const NavItem = ({ to, icon: Icon, label, className = "" }: { to: string, icon: any, label: string, className?: string }) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -25,8 +26,15 @@ const NavItem = ({ to, icon: Icon, label, className = "" }: { to: string, icon:
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Navigation = () => {
|
export const Navigation = () => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const navRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex overflow-x-auto scrollbar-hide border-t border-eggshell/10">
|
<nav ref={navRef} className="flex overflow-x-auto scrollbar-hide border-t border-eggshell/10">
|
||||||
<NavItem to="/" icon={HomeIcon} label="Hem" />
|
<NavItem to="/" icon={HomeIcon} label="Hem" />
|
||||||
<NavItem to="/schedule" icon={Calendar} label="Schema" />
|
<NavItem to="/schedule" icon={Calendar} label="Schema" />
|
||||||
<NavItem to="/faq" icon={MapIcon} label="FAQ" />
|
<NavItem to="/faq" icon={MapIcon} label="FAQ" />
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// app/components/ui/ActionLinkCard.tsx
|
||||||
|
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface ActionLinkCardProps {
|
||||||
|
href: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionLinkCard({ href, title, description }: ActionLinkCardProps) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="group bg-white/40 backdrop-blur-md border border-white/40 p-5 rounded-3xl shadow-sm hover:shadow-md hover:bg-white/50 transition-all duration-300 flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-white w-10 h-10 rounded-xl flex items-center justify-center shrink-0 text-slate-teal group-hover:bg-seafoam group-hover:text-eggshell transition-colors shadow-sm">
|
||||||
|
<ArrowRight size={20} className="group-hover:rotate-45 transition-transform" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-black text-ebony text-lg uppercase tracking-wide leading-tight">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-ebony/70 font-medium leading-relaxed w-full m-0 pl-1">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// app/components/ui/Cards.tsx
|
||||||
|
|
||||||
|
import { Archive, CheckCircle, Download, ExternalLink } from 'lucide-react';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
export interface DocumentItem {
|
||||||
|
title: string;
|
||||||
|
file: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
size: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinkItem {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Document Card Component ---
|
||||||
|
interface DocumentCardProps {
|
||||||
|
doc: DocumentItem;
|
||||||
|
isOffline: boolean;
|
||||||
|
isCached: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentCard({ doc, isOffline, isCached }: DocumentCardProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={doc.file}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
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={`p-2.5 rounded-xl mr-3 text-eggshell shrink-0 transition-colors ${isCached ? 'bg-moss' : 'bg-slate-teal'}`}>
|
||||||
|
{doc.icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-sm leading-tight text-ebony">{doc.title}</span>
|
||||||
|
<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 uppercase tracking-wider">
|
||||||
|
{doc.size}
|
||||||
|
</span>
|
||||||
|
{isCached && (
|
||||||
|
<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={10} /> Nedladdad
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isCached ? (
|
||||||
|
<CheckCircle size={18} className="text-moss shrink-0 ml-2" />
|
||||||
|
) : (
|
||||||
|
<Download size={18} className="text-slate-teal/50 group-hover:text-slate-teal transition-colors shrink-0 ml-2" />
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- External Link Card Component ---
|
||||||
|
interface ExternalLinkCardProps {
|
||||||
|
link: LinkItem;
|
||||||
|
isOffline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExternalLinkCard({ link, isOffline }: ExternalLinkCardProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={isOffline ? '#' : link.url}
|
||||||
|
target={isOffline ? '_self' : '_blank'}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
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>
|
||||||
|
<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'} />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// app/components/ui/FAQItem.tsx
|
||||||
|
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
|
||||||
|
interface FAQItemProps {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onToggle: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FAQItem({ id, question, answer, isOpen, onToggle }: FAQItemProps) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl overflow-hidden transition-colors ${isOpen ? 'bg-white/80 shadow-sm' : 'bg-white/40 hover:bg-white/60'}`}>
|
||||||
|
<button
|
||||||
|
className="w-full text-left p-3 flex justify-between items-center focus:outline-none"
|
||||||
|
onClick={() => onToggle(id)}
|
||||||
|
>
|
||||||
|
<span className="font-bold text-slate-teal text-sm pr-4 leading-snug">{question}</span>
|
||||||
|
<div className={`p-1 rounded-full transition-colors ${isOpen ? 'bg-seafoam text-eggshell' : 'text-seafoam'}`}>
|
||||||
|
{isOpen ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* The CSS Grid accordion trick */}
|
||||||
|
<div className={`grid transition-all duration-300 ease-in-out ${isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}`}>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="p-2 pb-3 text-ebony font-medium text-sm leading-relaxed border-t border-ebony/20 mx-4">
|
||||||
|
{answer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// app/components/ui/OfflineBadge.tsx
|
||||||
|
|
||||||
|
import { WifiOff } from 'lucide-react';
|
||||||
|
|
||||||
|
interface OfflineBadgeProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfflineBadge({ className = "" }: OfflineBadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={`bg-goldenrod/10 text-goldenrod border border-goldenrod/20 px-3 py-1.5 rounded-full flex items-center w-fit text-xs font-black uppercase tracking-widest ${className}`}>
|
||||||
|
<WifiOff size={14} className="mr-2" /> Offline-läge
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// app/components/ui/PageHeader.tsx
|
||||||
|
|
||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
description: string;
|
||||||
|
variant?: 'default' | 'emergency';
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
description,
|
||||||
|
variant = 'default',
|
||||||
|
actions
|
||||||
|
}: PageHeaderProps) {
|
||||||
|
|
||||||
|
// Set colors based on the variant
|
||||||
|
const isEmergency = variant === 'emergency';
|
||||||
|
const titleColor = isEmergency ? 'text-emergency uppercase' : 'text-slate-teal';
|
||||||
|
const iconColor = isEmergency ? 'text-emergency' : 'text-seafoam';
|
||||||
|
const descColor = isEmergency ? 'text-ebony' : 'text-moss';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 className={`text-3xl font-black tracking-tight flex items-center ${titleColor}`}>
|
||||||
|
<Icon className={`mr-3 shrink-0 ${iconColor}`} size={32} />
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className={`font-medium mt-2 ml-11 ${descColor} hidden sm:visible`}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actions && (
|
||||||
|
<div className="flex flex-col gap-2 items-start md:items-end ml-11 md:ml-0 mt-2 md:mt-0">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// app/components/ui/SectionCard.tsx
|
||||||
|
|
||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface SectionCardProps {
|
||||||
|
title: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
children: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
variant?: 'default' | 'alert' | 'highlight';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionCard({
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
children,
|
||||||
|
description,
|
||||||
|
variant = 'default',
|
||||||
|
className = ""
|
||||||
|
}: SectionCardProps) {
|
||||||
|
|
||||||
|
// Define base styles for the card container
|
||||||
|
let cardStyle = "rounded-3xl shadow-sm transition-all duration-300 ";
|
||||||
|
let headerStyle = "text-xl font-black uppercase tracking-widest mb-4 flex items-center ";
|
||||||
|
let iconStyle = "mr-3 shrink-0 ";
|
||||||
|
let descStyle = "text-sm mb-6 leading-relaxed ";
|
||||||
|
|
||||||
|
// Apply specific styles based on the chosen variant
|
||||||
|
switch (variant) {
|
||||||
|
case 'alert':
|
||||||
|
cardStyle += "bg-emergency/10 border-2 border-emergency/30 p-6 md:p-8 hover:shadow-md";
|
||||||
|
headerStyle += "text-emergency";
|
||||||
|
iconStyle += "text-emergency";
|
||||||
|
descStyle += "text-emergency/90 font-bold";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'highlight':
|
||||||
|
cardStyle += "bg-goldenrod/30 border border-goldenrod/20 p-5";
|
||||||
|
headerStyle += "text-sm text-goldenrod mb-1";
|
||||||
|
iconStyle += "text-goldenrod mt-0.5";
|
||||||
|
descStyle += "text-ebony font-medium m-0";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'default':
|
||||||
|
default:
|
||||||
|
cardStyle += "bg-white/40 backdrop-blur-md border border-white/40 p-6 md:p-8 hover:shadow-md";
|
||||||
|
headerStyle += "text-ebony";
|
||||||
|
iconStyle += "text-slate-teal";
|
||||||
|
descStyle += "text-ebony/80 font-medium";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`${cardStyle} ${className}`}>
|
||||||
|
<div className={variant === 'highlight' ? 'flex items-start' : ''}>
|
||||||
|
{variant === 'highlight' && Icon && (
|
||||||
|
<Icon className={iconStyle} size={24} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={variant === 'highlight' ? 'flex-1' : ''}>
|
||||||
|
<h2 className={headerStyle}>
|
||||||
|
{variant !== 'highlight' && Icon && <Icon className={iconStyle} size={24} />}
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{description && (
|
||||||
|
<div className={descStyle}>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === 'highlight' ? (
|
||||||
|
<div className="text-sm font-medium leading-relaxed text-ebony">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+46
-70
@@ -2,16 +2,20 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BookCopy, CheckCircle, CloudDownload, Download, ExternalLink, Folder, Loader2, Map, MapPinned, MapPlus, WavesLadder, WifiOff, Archive } from 'lucide-react';
|
import { BookCopy, CheckCircle, CloudDownload, Folder, Loader2, Map, MapPinned, MapPlus, WavesLadder, WifiOff } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { DocumentCard, ExternalLinkCard, DocumentItem, LinkItem } from '../components/ui/Cards';
|
||||||
|
import { OfflineBadge } from '../components/ui/OfflineBadge';
|
||||||
|
import { PageHeader } from '../components/ui/PageHeader';
|
||||||
|
|
||||||
export default function Documents() {
|
export default function Documents() {
|
||||||
const [isHydrated, setIsHydrated] = useState(false);
|
const [isHydrated, setIsHydrated] = useState(false);
|
||||||
const [isOffline, setIsOffline] = useState(false);
|
const [isOffline, setIsOffline] = useState(false);
|
||||||
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'done'>('idle');
|
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'done'>('idle');
|
||||||
const [cachedFiles, setCachedFiles] = useState<Set<string>>(new Set());
|
const [cachedFiles, setCachedFiles] = useState<Set<string>>(new Set());
|
||||||
|
const [showNotification, setShowNotification] = useState(false);
|
||||||
|
|
||||||
const docs = [
|
const docs: DocumentItem[] = [
|
||||||
{ title: 'Turistkarta', icon: <Map size={20} />, 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={20} />, 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={20} />, 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' },
|
||||||
@@ -20,7 +24,7 @@ export default function Documents() {
|
|||||||
{ title: 'Destinationskunskap Kullahalvön', icon: <BookCopy size={20} />, 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: LinkItem[] = [
|
||||||
{ title: 'Kullabergs Naturreservat', url: 'https://www.kullabergsnatur.se/' },
|
{ title: 'Kullabergs Naturreservat', url: 'https://www.kullabergsnatur.se/' },
|
||||||
{ title: 'Vandra på Kullahalvön', url: 'https://www.kullahalvon.com/upptacka--uppleva/friluftsliv--natur/vandra-pa-kullahalvon.html' },
|
{ title: 'Vandra på Kullahalvön', url: 'https://www.kullahalvon.com/upptacka--uppleva/friluftsliv--natur/vandra-pa-kullahalvon.html' },
|
||||||
{ title: 'Naturkartan', url: 'https://www.naturkartan.se/en/explore' },
|
{ title: 'Naturkartan', url: 'https://www.naturkartan.se/en/explore' },
|
||||||
@@ -71,28 +75,23 @@ export default function Documents() {
|
|||||||
}
|
}
|
||||||
await checkCacheStatus();
|
await checkCacheStatus();
|
||||||
setSyncStatus('done');
|
setSyncStatus('done');
|
||||||
|
setShowNotification(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowNotification(false);
|
||||||
|
}, 4000);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isHydrated) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-slate-teal" size={40} /></div>;
|
if (!isHydrated) 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 relative">
|
||||||
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-4">
|
<PageHeader
|
||||||
<div>
|
title="Info & Dokument"
|
||||||
<h1 className="text-3xl font-black text-slate-teal tracking-tight flex items-center">
|
icon={Folder}
|
||||||
<Folder className="mr-3 text-seafoam" size={32} />
|
description="Allt du behöver för att guida besökarna."
|
||||||
Info & Dokument
|
actions={
|
||||||
</h1>
|
<>
|
||||||
<p className="text-moss font-medium mt-2 ml-11">Allt du behöver för att guida besökarna.</p>
|
{isOffline && <OfflineBadge />}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 items-start md:items-end">
|
|
||||||
{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-widest">
|
|
||||||
<WifiOff size={14} className="mr-2" /> Offline-läge
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isOffline && syncStatus !== 'done' && (
|
{!isOffline && syncStatus !== 'done' && (
|
||||||
<button
|
<button
|
||||||
onClick={handleSyncAll}
|
onClick={handleSyncAll}
|
||||||
@@ -100,53 +99,24 @@ export default function Documents() {
|
|||||||
className="flex items-center gap-2 bg-seafoam/10 text-slate-teal border border-seafoam/20 hover:bg-seafoam/20 px-4 py-2 rounded-lg font-bold text-sm transition-colors"
|
className="flex items-center gap-2 bg-seafoam/10 text-slate-teal border border-seafoam/20 hover:bg-seafoam/20 px-4 py-2 rounded-lg font-bold text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{syncStatus === 'syncing' ? <Loader2 size={18} className="animate-spin" /> : <CloudDownload size={18} />}
|
{syncStatus === 'syncing' ? <Loader2 size={18} className="animate-spin" /> : <CloudDownload size={18} />}
|
||||||
{syncStatus === 'syncing' ? 'Laddar ner filer...' : 'Gör tillgänglig offline'}
|
{syncStatus === 'syncing' ? 'Laddar ner filer...' : 'Gör tillgängliga offline'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
{syncStatus === 'done' && (
|
}
|
||||||
<div className="flex items-center gap-2 text-moss font-bold text-sm bg-moss/10 border border-moss/20 px-4 py-2 rounded-lg">
|
/>
|
||||||
<CheckCircle size={18} /> Redo för offline
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">Filer</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
<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);
|
<DocumentCard
|
||||||
return (
|
key={idx}
|
||||||
<a key={idx} href={doc.file} target="_blank" rel="noopener noreferrer"
|
doc={doc}
|
||||||
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'}`}
|
isOffline={isOffline}
|
||||||
>
|
isCached={cachedFiles.has(doc.file)}
|
||||||
<div className="flex items-center text-slate-teal">
|
/>
|
||||||
<div className={`p-2.5 rounded-xl mr-3 text-eggshell shrink-0 transition-colors ${isCached ? 'bg-moss' : 'bg-slate-teal'}`}>
|
))}
|
||||||
{doc.icon}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-bold text-sm leading-tight text-ebony">{doc.title}</span>
|
|
||||||
<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 uppercase tracking-wider">
|
|
||||||
{doc.size}
|
|
||||||
</span>
|
|
||||||
{isCached && (
|
|
||||||
<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={10} /> Nedladdad
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isCached ? (
|
|
||||||
<CheckCircle size={18} className="text-moss shrink-0 ml-2" />
|
|
||||||
) : (
|
|
||||||
<Download size={18} className="text-slate-teal/50 group-hover:text-slate-teal transition-colors shrink-0 ml-2" />
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -156,17 +126,23 @@ export default function Documents() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
<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"
|
<ExternalLinkCard
|
||||||
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'}`}
|
key={idx}
|
||||||
>
|
link={link}
|
||||||
<span className="font-bold text-slate-teal text-sm leading-tight pr-4">{link.title}</span>
|
isOffline={isOffline}
|
||||||
<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'} />
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{showNotification && (
|
||||||
|
<div className="fixed bottom-6 right-6 z-50 animate-fade-in flex items-center gap-3 bg-white text-moss font-bold text-sm border border-moss/20 shadow-xl px-4 py-3 rounded-xl">
|
||||||
|
<div className="bg-moss/10 p-1 rounded-full">
|
||||||
|
<CheckCircle size={20} className="text-moss" />
|
||||||
|
</div>
|
||||||
|
Alla filer är redo för offline
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+29
-30
@@ -1,28 +1,27 @@
|
|||||||
// app/emergency/page.tsx
|
// app/emergency/page.tsx
|
||||||
|
|
||||||
import React from 'react';
|
import { AlertTriangle, FileText, HeartPulse, MapPin, Phone } from 'lucide-react';
|
||||||
import { Phone, AlertTriangle, MapPin, HeartPulse } from 'lucide-react';
|
|
||||||
import { SafePhoneLink } from '../components/PhoneLinks';
|
import { SafePhoneLink } from '../components/PhoneLinks';
|
||||||
|
import { PageHeader } from '../components/ui/PageHeader';
|
||||||
|
import { SectionCard } from '../components/ui/SectionCard';
|
||||||
|
|
||||||
export default function Emergency() {
|
export default function Emergency() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in w-full mx-auto">
|
<div className="space-y-6 animate-fade-in w-full mx-auto">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-3xl font-black text-emergency tracking-tight uppercase flex items-center">
|
title="Vid Nödsituation"
|
||||||
<AlertTriangle className="mr-3 text-emergency" size={32} />
|
icon={AlertTriangle}
|
||||||
Vid Nödsituation
|
description="Agera lugnt, stanna kvar på platsen och tillkalla hjälp."
|
||||||
</h1>
|
variant="emergency" // <-- Sets the red colors and uppercase
|
||||||
<p className="text-ebony font-medium mt-2 ml-11">Agera lugnt, stanna kvar på platsen och tillkalla hjälp.</p>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 112 Card */}
|
{/* 112 Card */}
|
||||||
<div className="bg-emergency/10 border-l-4 border-emergency p-5 rounded-r-2xl shadow-sm">
|
<SectionCard
|
||||||
<h2 className="text-xl font-black text-ebony flex items-center mb-2">
|
variant="alert"
|
||||||
<Phone className="mr-2 text-emergency" size={20} /> Ring 112
|
title="Ring 112"
|
||||||
</h2>
|
icon={Phone}
|
||||||
<p className="text-ebony/80 font-medium mb-4 text-sm">
|
description="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>
|
|
||||||
<div className="bg-white/60 p-4 rounded-xl border border-emergency/20">
|
<div className="bg-white/60 p-4 rounded-xl border border-emergency/20">
|
||||||
<h3 className="font-bold text-emergency flex items-center mb-1 text-xs uppercase tracking-wider">
|
<h3 className="font-bold text-emergency flex items-center mb-1 text-xs uppercase tracking-wider">
|
||||||
<MapPin className="mr-2" size={14} /> Uppge din position
|
<MapPin className="mr-2" size={14} /> Uppge din position
|
||||||
@@ -31,13 +30,14 @@ export default function Emergency() {
|
|||||||
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").
|
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>
|
</SectionCard>
|
||||||
|
|
||||||
{/* First Aid / HLR Card */}
|
{/* First Aid / HLR Card */}
|
||||||
<div className="bg-eggshell border-2 border-slate-teal/10 p-5 rounded-2xl shadow-sm">
|
<SectionCard
|
||||||
<h2 className="text-lg font-black text-slate-teal flex items-center mb-4 uppercase tracking-wide">
|
variant="default"
|
||||||
<HeartPulse className="mr-2 text-seafoam" size={20} /> Första Hjälpen & Hjärtstartare
|
title="Första Hjälpen & Hjärtstartare"
|
||||||
</h2>
|
icon={HeartPulse}
|
||||||
|
>
|
||||||
<ul className="space-y-3 text-ebony font-medium text-sm">
|
<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-xs shrink-0">1</span>
|
<span className="bg-seafoam text-eggshell font-bold px-2 py-0.5 rounded mr-3 text-xs shrink-0">1</span>
|
||||||
@@ -48,16 +48,15 @@ export default function Emergency() {
|
|||||||
<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>
|
<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>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</SectionCard>
|
||||||
|
|
||||||
{/* Internal Contact */}
|
{/* Internal Contact */}
|
||||||
<div className="bg-eggshell border-2 border-slate-teal/10 p-5 rounded-2xl shadow-sm">
|
<SectionCard
|
||||||
<h2 className="text-lg font-black text-slate-teal mb-2 uppercase tracking-wide">
|
variant="default"
|
||||||
Intern Rapportering
|
title="Intern Rapportering"
|
||||||
</h2>
|
icon={FileText}
|
||||||
<p className="text-ebony/80 font-medium mb-4 text-sm">
|
description="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>
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-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">
|
<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-sm font-bold text-ebony">William Söderberg</span>
|
<span className="text-sm font-bold text-ebony">William Söderberg</span>
|
||||||
@@ -74,7 +73,7 @@ export default function Emergency() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+26
-33
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import { MessageCircleQuestionMark } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { FAQItem } from '../components/ui/FAQItem';
|
||||||
|
import { PageHeader } from '../components/ui/PageHeader';
|
||||||
|
import { SectionCard } from '../components/ui/SectionCard';
|
||||||
import faqData from '../data/faq.json';
|
import faqData from '../data/faq.json';
|
||||||
import { ChevronDown, ChevronUp, MessageCircleQuestionMark } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function FAQ() {
|
export default function FAQ() {
|
||||||
const [openIndex, setOpenIndex] = useState<string | null>(null);
|
const [openIndex, setOpenIndex] = useState<string | null>(null);
|
||||||
@@ -15,47 +18,37 @@ export default function FAQ() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full animate-fade-in space-y-6">
|
<div className="w-full animate-fade-in space-y-6">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-3xl font-black text-slate-teal tracking-tight flex items-center">
|
title="Vanliga Frågor (FAQ)"
|
||||||
<MessageCircleQuestionMark className="mr-3 text-seafoam" size={32} />
|
icon={MessageCircleQuestionMark}
|
||||||
Vanliga Frågor (FAQ)
|
description="Använd den här guiden för att snabbt svara på turisternas vanligaste frågor."
|
||||||
</h1>
|
/>
|
||||||
<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 className="columns-1 lg:columns-2 gap-4 space-y-4">
|
<div className="columns-1 lg:columns-2 gap-4">
|
||||||
{faqData.map((section, sIndex) => (
|
{faqData.map((section, sIndex) => (
|
||||||
<div key={sIndex} className="break-inside-avoid bg-eggshell border-2 border-slate-teal/10 p-5 rounded-2xl shadow-sm">
|
<SectionCard
|
||||||
<h2 className="text-sm font-black text-ebony/60 uppercase tracking-widest mb-4">
|
key={sIndex}
|
||||||
{section.category}
|
title={section.category}
|
||||||
</h2>
|
className="break-inside-avoid mb-4 inline-block w-full"
|
||||||
|
>
|
||||||
<div className="space-y-2">
|
<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;
|
|
||||||
return (
|
return (
|
||||||
<div key={id} className={`rounded-xl overflow-hidden transition-colors ${isOpen ? 'bg-white shadow-sm' : 'bg-transparent hover:bg-white/50'}`}>
|
<FAQItem
|
||||||
<button
|
key={id}
|
||||||
className="w-full text-left p-3 flex justify-between items-center focus:outline-none"
|
id={id}
|
||||||
onClick={() => toggleQuestion(id)}
|
question={item.q}
|
||||||
>
|
answer={item.a}
|
||||||
<span className="font-bold text-slate-teal text-sm pr-4 leading-snug">{item.q}</span>
|
isOpen={openIndex === id}
|
||||||
<div className={`p-1 rounded-full transition-colors ${isOpen ? 'bg-seafoam text-eggshell' : 'text-seafoam'}`}>
|
onToggle={toggleQuestion}
|
||||||
{isOpen ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
/>
|
||||||
</div>
|
|
||||||
</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="p-2 pb-3 text-ebony font-medium text-sm leading-relaxed border-t border-ebony/20 mx-4">
|
|
||||||
{item.a}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SectionCard>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
+82
-103
@@ -1,71 +1,78 @@
|
|||||||
// app/info/page.tsx
|
// app/info/page.tsx
|
||||||
|
|
||||||
import { AlertTriangle, Briefcase, CloudRain, HeartHandshake, ListChecks, Navigation, ShieldAlert, Sun } from 'lucide-react';
|
import { AlertTriangle, Briefcase, CloudRain, HeartHandshake, ListChecks, Navigation, ShieldAlert, Sun } from 'lucide-react';
|
||||||
|
import React from 'react';
|
||||||
|
import { PageHeader } from '../components/ui/PageHeader';
|
||||||
|
import { SectionCard } from '../components/ui/SectionCard';
|
||||||
|
|
||||||
|
// --- Local Components ---
|
||||||
|
const TaskItem = ({ title, desc }: { title: string; desc: string }) => (
|
||||||
|
<li className="flex items-start bg-white/40 p-3 rounded-xl">
|
||||||
|
<span className="text-seafoam mr-3 mt-0.5 font-black">✦</span>
|
||||||
|
<span><strong className="text-slate-teal">{title}:</strong> {desc}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PackItem = ({ label, isWeekend = false }: { label: string; isWeekend?: boolean }) => (
|
||||||
|
<li className={`flex items-center text-xs font-bold text-ebony/80 bg-white p-3 rounded-xl border border-slate-teal/5 shadow-sm ${isWeekend ? 'sm:col-span-2' : ''}`}>
|
||||||
|
<span className={`w-2.5 h-2.5 rounded-full mr-3 shrink-0 ${isWeekend ? 'bg-goldenrod' : 'bg-seafoam'}`}></span>
|
||||||
|
{label}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RuleItem = ({ number, title, content }: { number: number; title: string; content: React.ReactNode }) => (
|
||||||
|
<div className="flex items-start bg-white/60 p-4 rounded-2xl">
|
||||||
|
<div className="bg-goldenrod text-white font-black w-6 h-6 rounded-full flex items-center justify-center shrink-0 mr-3 mt-0.5 shadow-sm text-xs">
|
||||||
|
{number}
|
||||||
|
</div>
|
||||||
|
<span><strong className="text-ebony">{title}:</strong> {content}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RiskItem = ({ title, content, colSpanClass = "" }: { title: string; content: React.ReactNode; colSpanClass?: string }) => (
|
||||||
|
<div className={`bg-white p-4 rounded-2xl shadow-sm flex items-start border border-emergency/10 ${colSpanClass}`}>
|
||||||
|
<AlertTriangle className="text-emergency shrink-0 mr-3 mt-0.5" size={20} />
|
||||||
|
<p><strong className="text-emergency">{title}:</strong> {content}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Main Page Component ---
|
||||||
export default function JobInfo() {
|
export default function JobInfo() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-fade-in w-full mx-auto">
|
<div className="space-y-8 animate-fade-in w-full mx-auto">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-3xl font-black text-slate-teal tracking-tight flex items-center">
|
title="Jobbinfo & Regler"
|
||||||
<Briefcase className="mr-3 text-seafoam" size={32} />
|
icon={Briefcase}
|
||||||
Jobbinfo & Regler
|
description="Din överlevnadsguide till världens bästa sommarjobb på Kullaberg!"
|
||||||
</h1>
|
/>
|
||||||
<p className="text-moss font-medium mt-2 ml-11">Din överlevnadsguide till världens bästa sommarjobb på Kullaberg!</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
{/* VAD GÖR VI? */}
|
{/* VAD GÖR VI? */}
|
||||||
<section className="group bg-white/40 backdrop-blur-md border border-white p-6 md:p-8 rounded-3xl shadow-sm hover:shadow-md transition-all duration-300">
|
<SectionCard
|
||||||
<h2 className="text-xl font-black text-ebony uppercase tracking-widest mb-6 flex items-center">
|
title="Snabbguiden: Uppdrag"
|
||||||
<ListChecks className="mr-3 text-slate-teal" size={24} /> Snabbguiden: Uppdrag
|
icon={ListChecks}
|
||||||
</h2>
|
description="Arbetsuppgifterna är varierande och vi löser ofta specialuppdrag som ett team. Exempel på vad du kommer göra:"
|
||||||
<p className="text-sm text-ebony/80 font-bold mb-6 leading-relaxed bg-white/60 p-4 rounded-xl border border-white">
|
>
|
||||||
Arbetsuppgifterna är varierande och vi löser ofta specialuppdrag som ett team. Exempel på vad du kommer göra:
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-4 text-sm font-medium text-ebony/90">
|
<ul className="space-y-4 text-sm font-medium text-ebony/90">
|
||||||
<li className="flex items-start bg-white/40 p-3 rounded-xl">
|
<TaskItem title="Parkeringsvärd" desc="Hjälpa folk att parkera rätt." />
|
||||||
<span className="text-seafoam mr-3 mt-0.5 font-black">✦</span>
|
<TaskItem title="Naturvärd" desc="Berätta för besökare om Kullaberg och vad man kan göra." />
|
||||||
<span><strong className="text-slate-teal">Parkeringsvärd:</strong> Hjälpa folk att parkera rätt.</span>
|
<TaskItem title="Naturvård" desc="Plocka skräp och bekämpa invasiva växter." />
|
||||||
</li>
|
<TaskItem title="Underhåll & Städ" desc="Städa faciliteter, olja skyltar/möbler, laga staket och röja stigar." />
|
||||||
<li className="flex items-start bg-white/40 p-3 rounded-xl">
|
|
||||||
<span className="text-seafoam mr-3 mt-0.5 font-black">✦</span>
|
|
||||||
<span><strong className="text-slate-teal">Naturvärd:</strong> Berätta för besökare om Kullaberg och vad man kan göra.</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start bg-white/40 p-3 rounded-xl">
|
|
||||||
<span className="text-seafoam mr-3 mt-0.5 font-black">✦</span>
|
|
||||||
<span><strong className="text-slate-teal">Naturvård:</strong> Plocka skräp och bekämpa invasiva växter.</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start bg-white/40 p-3 rounded-xl">
|
|
||||||
<span className="text-seafoam mr-3 mt-0.5 font-black">✦</span>
|
|
||||||
<span><strong className="text-slate-teal">Underhåll & Städ:</strong> Städa faciliteter, olja skyltar/möbler, laga staket och röja stigar.</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</SectionCard>
|
||||||
|
|
||||||
{/* PACKLISTA */}
|
{/* PACKLISTA */}
|
||||||
<section className="group bg-white/40 backdrop-blur-md border border-white p-6 md:p-8 rounded-3xl shadow-sm hover:shadow-md transition-all duration-300">
|
<SectionCard
|
||||||
<h2 className="text-xl font-black text-ebony uppercase tracking-widest mb-6 flex items-center">
|
title="Daglig packlista"
|
||||||
<Navigation className="mr-3 text-slate-teal" size={24} /> Daglig packlista
|
icon={Navigation}
|
||||||
</h2>
|
description="Vi är ute i princip hela arbetspasset. Kommunen löser t-shirt och keps, resten står du för!"
|
||||||
<p className="text-sm text-ebony/80 font-bold mb-6 leading-relaxed bg-white/60 p-4 rounded-xl border border-white">
|
>
|
||||||
Vi är ute i princip hela arbetspasset. Kommunen löser t-shirt och keps, resten står du för!
|
|
||||||
</p>
|
|
||||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-6">
|
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-6">
|
||||||
<li className="flex items-center text-xs font-bold text-ebony/80 bg-white p-3 rounded-xl border border-slate-teal/5 shadow-sm">
|
<PackItem label="Sköna promenadskor" />
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-seafoam mr-3 shrink-0"></span> Sköna promenadskor
|
<PackItem label="Oömma kläder efter väder" />
|
||||||
</li>
|
<PackItem label="Bekväm ryggsäck" />
|
||||||
<li className="flex items-center text-xs font-bold text-ebony/80 bg-white p-3 rounded-xl border border-slate-teal/5 shadow-sm">
|
<PackItem label="Minst 1L vatten" />
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-seafoam mr-3 shrink-0"></span> Oömma kläder efter väder
|
<PackItem label="Matsäck (Endast helger)" isWeekend={true} />
|
||||||
</li>
|
|
||||||
<li className="flex items-center text-xs font-bold text-ebony/80 bg-white p-3 rounded-xl border border-slate-teal/5 shadow-sm">
|
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-seafoam mr-3 shrink-0"></span> Bekväm ryggsäck
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center text-xs font-bold text-ebony/80 bg-white p-3 rounded-xl border border-slate-teal/5 shadow-sm">
|
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-seafoam mr-3 shrink-0"></span> Minst 1L vatten
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center text-xs font-bold text-ebony/80 bg-white p-3 rounded-xl border border-slate-teal/5 shadow-sm sm:col-span-2">
|
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-goldenrod mr-3 shrink-0"></span> Matsäck (Endast helger)
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* Fixed Weather Blocks */}
|
{/* Fixed Weather Blocks */}
|
||||||
@@ -85,65 +92,37 @@ export default function JobInfo() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* VETT OCH ETIKETT */}
|
{/* VETT OCH ETIKETT */}
|
||||||
<section className="group bg-white/40 backdrop-blur-md border border-white p-6 md:p-8 rounded-3xl shadow-sm hover:shadow-md transition-all duration-300">
|
<SectionCard
|
||||||
<h2 className="text-xl font-black text-ebony uppercase tracking-widest mb-6 flex items-center">
|
title="Policy: Vett & Etikett"
|
||||||
<HeartHandshake className="mr-3 text-slate-teal" size={24} /> Policy: Vett & Etikett
|
icon={HeartHandshake}
|
||||||
</h2>
|
>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm font-medium text-ebony/80 leading-relaxed">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm font-medium text-ebony/80 leading-relaxed">
|
||||||
<div className="flex items-start bg-white/60 p-4 rounded-2xl">
|
<RuleItem number={1} title="Var ett proffs" content="Uppträd professionellt, var schysst mot alla och tänk på att vi har absolut nolltolerans mot diskriminering." />
|
||||||
<div className="bg-goldenrod text-white font-black w-6 h-6 rounded-full flex items-center justify-center shrink-0 mr-3 mt-0.5 shadow-sm text-xs">1</div>
|
<RuleItem number={2} title="Ingen dötid" content="Stillastående väntan accepteras inte. Har du inget att göra? Fråga ledaren! Du ska inte stå still när andra jobbar." />
|
||||||
<span><strong className="text-ebony">Var ett proffs:</strong> Uppträd professionellt, var schysst mot alla och tänk på att vi har absolut nolltolerans mot diskriminering.</span>
|
<RuleItem number={3} title="Se folk i ögonen" content={<>Inga hörlurar under arbetstid, mobilen är bara ett arbetsverktyg, och <em className="text-slate-teal font-bold">ta av solglasögonen</em> när du pratar med besökare.</>} />
|
||||||
|
<RuleItem number={4} title="Djur & Natur" content="Respektera djur och natur. Vi rör inte djuren och vi tar hand om våra verktyg." />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start bg-white/60 p-4 rounded-2xl">
|
</SectionCard>
|
||||||
<div className="bg-goldenrod text-white font-black w-6 h-6 rounded-full flex items-center justify-center shrink-0 mr-3 mt-0.5 shadow-sm text-xs">2</div>
|
|
||||||
<span><strong className="text-ebony">Ingen dötid:</strong> Stillastående väntan accepteras inte. Har du inget att göra? Fråga ledaren! Du ska inte stå still när andra jobbar.</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start bg-white/60 p-4 rounded-2xl">
|
|
||||||
<div className="bg-goldenrod text-white font-black w-6 h-6 rounded-full flex items-center justify-center shrink-0 mr-3 mt-0.5 shadow-sm text-xs">3</div>
|
|
||||||
<span><strong className="text-ebony">Se folk i ögonen:</strong> Inga hörlurar under arbetstid, mobilen är bara ett arbetsverktyg, och <em className="text-slate-teal font-bold">ta av solglasögonen</em> när du pratar med besökare.</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start bg-white/60 p-4 rounded-2xl">
|
|
||||||
<div className="bg-goldenrod text-white font-black w-6 h-6 rounded-full flex items-center justify-center shrink-0 mr-3 mt-0.5 shadow-sm text-xs">4</div>
|
|
||||||
<span><strong className="text-ebony">Djur & Natur:</strong> Respektera djur och natur. Vi rör inte djuren och vi tar hand om våra verktyg.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* SÄKERHET */}
|
{/* SÄKERHET */}
|
||||||
<section className="group bg-emergency/10 border-2 border-emergency/30 p-6 md:p-8 rounded-3xl shadow-sm hover:shadow-md transition-all duration-300">
|
<SectionCard
|
||||||
<h2 className="text-xl font-black text-emergency uppercase tracking-widest mb-6 flex items-center">
|
variant="alert"
|
||||||
<ShieldAlert className="mr-3 text-emergency" size={24} /> Risker & Säkerhet
|
title="Risker & Säkerhet"
|
||||||
</h2>
|
icon={ShieldAlert}
|
||||||
<p className="text-sm text-emergency/80 font-black mb-6 leading-relaxed bg-white/50 p-4 rounded-xl border border-emergency/20">
|
description="Säkerhet går alltid först! Därför utförs ALLT arbete minst 2 och 2."
|
||||||
Säkerhet går alltid först! Därför utförs ALLT arbete minst 2 och 2.
|
>
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm text-ebony/90 font-medium">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm text-ebony/90 font-medium">
|
||||||
<div className="bg-white p-4 rounded-2xl shadow-sm flex items-start border border-emergency/10">
|
<RiskItem title="Trafik" content="Vid parkeringar bär vi alltid gul reflexväst." />
|
||||||
<AlertTriangle className="text-emergency shrink-0 mr-3 mt-0.5" size={20} />
|
<RiskItem title="Klippor" content="Vi håller oss alltid minst 2 meter ifrån klippkanter." />
|
||||||
<p><strong className="text-emergency">Trafik:</strong> Vid parkeringar bär vi alltid gul reflexväst.</p>
|
<RiskItem title="Tunga lyft" content="Tunga föremål lyfter vi alltid med minst två personer." />
|
||||||
|
<RiskItem title="Ormbett" content="Blir du biten, ring Giftinformationscentralen (010-456 67 00 eller 112) och kontakta projektledaren direkt." colSpanClass="sm:col-span-2 lg:col-span-1" />
|
||||||
|
<RiskItem title="Djurhagar" content={<>Håll 50 meters avstånd, klappa dem inte. Ropa <em className="font-black text-ebony">"HEJ KOSSORNA/LAMMEN"</em> eller <em className="font-black text-ebony">"MOOOOOOO!"</em> tills de uppmärksammat dig.</>} colSpanClass="sm:col-span-2" />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-4 rounded-2xl shadow-sm flex items-start border border-emergency/10">
|
</SectionCard>
|
||||||
<AlertTriangle className="text-emergency shrink-0 mr-3 mt-0.5" size={20} />
|
|
||||||
<p><strong className="text-emergency">Klippor:</strong> Vi håller oss alltid minst 2 meter ifrån klippkanter.</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-2xl shadow-sm flex items-start border border-emergency/10">
|
|
||||||
<AlertTriangle className="text-emergency shrink-0 mr-3 mt-0.5" size={20} />
|
|
||||||
<p><strong className="text-emergency">Tunga lyft:</strong> Tunga föremål lyfter vi alltid med minst två personer.</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-2xl shadow-sm flex items-start border border-emergency/10 sm:col-span-2 lg:col-span-1">
|
|
||||||
<AlertTriangle className="text-emergency shrink-0 mr-3 mt-0.5" size={20} />
|
|
||||||
<p><strong className="text-emergency">Ormbett:</strong> Blir du biten, ring Giftinformationscentralen (010-456 67 00 eller 112) och kontakta projektledaren direkt.</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-2xl shadow-sm flex items-start border border-emergency/10 sm:col-span-2">
|
|
||||||
<AlertTriangle className="text-emergency shrink-0 mr-3 mt-0.5" size={20} />
|
|
||||||
<p><strong className="text-emergency">Djurhagar:</strong> Håll 50 meters avstånd, klappa dem inte. Ropa <em className="font-black text-ebony">"HEJ KOSSORNA/LAMMEN"</em> eller <em className="font-black text-ebony">"MOOOOOOO!"</em> tills de uppmärksammat dig.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Vendored
-15
@@ -1,15 +0,0 @@
|
|||||||
// app/network.d.ts
|
|
||||||
|
|
||||||
interface NetworkInformation extends EventTarget {
|
|
||||||
readonly effectiveType: 'slow-2g' | '2g' | '3g' | '4g';
|
|
||||||
readonly type: 'bluetooth' | 'cellular' | 'ethernet' | 'none' | 'wifi' | 'wimax' | 'other' | 'unknown';
|
|
||||||
readonly saveData: boolean;
|
|
||||||
onchange: EventListener;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Navigator extends NavigatorNetworkInformation { }
|
|
||||||
interface WorkerNavigator extends NavigatorNetworkInformation { }
|
|
||||||
|
|
||||||
interface NavigatorNetworkInformation {
|
|
||||||
readonly connection?: NetworkInformation;
|
|
||||||
}
|
|
||||||
+30
-37
@@ -1,57 +1,51 @@
|
|||||||
// app/page.tsx
|
// app/page.tsx
|
||||||
|
|
||||||
import { AlertTriangle, ArrowRight, PhoneCall } from 'lucide-react';
|
import { AlertTriangle, PhoneCall } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
|
||||||
import { SafePhoneLink } from './components/PhoneLinks';
|
import { SafePhoneLink } from './components/PhoneLinks';
|
||||||
|
import { SectionCard } from './components/ui/SectionCard';
|
||||||
|
import { ActionLinkCard } from './components/ui/ActionLinkCard'; // <-- Import the new card
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in w-full mx-auto">
|
<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 shadow-sm text-eggshell">
|
<div className="bg-slate-teal rounded-3xl p-6 shadow-sm text-eggshell">
|
||||||
<h1 className="text-3xl font-black tracking-tight mb-1">Välkommen!</h1>
|
<h1 className="text-3xl font-black tracking-tight mb-1">Välkommen!</h1>
|
||||||
<p className="text-eggshell/90 font-bold">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-goldenrod/30 border border-goldenrod/20 rounded-2xl p-5 flex items-start shadow-sm text-ebony">
|
<SectionCard
|
||||||
<AlertTriangle className="text-goldenrod mr-3 shrink-0 mt-0.5" size={24} />
|
title="Dagens Påminnelse"
|
||||||
<div>
|
icon={AlertTriangle}
|
||||||
<h3 className="font-black uppercase tracking-widest text-sm mb-1 text-goldenrod">Dagens Påminnelse</h3>
|
variant="highlight"
|
||||||
<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>
|
</SectionCard>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Action Cards */}
|
{/* Quick Action Cards now use the glass style */}
|
||||||
<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 border-2 border-slate-teal/10 rounded-2xl p-5 hover:bg-slate-teal/5 transition-colors flex flex-col items-start shadow-sm">
|
<ActionLinkCard
|
||||||
<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">
|
href="/schedule"
|
||||||
<ArrowRight size={20} className="group-hover:rotate-45 transition-transform" />
|
title="Ditt Schema"
|
||||||
</div>
|
description="Kolla dina arbetspass och dagliga rundor snabbt."
|
||||||
<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>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<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">
|
<ActionLinkCard
|
||||||
<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">
|
href="/faq"
|
||||||
<ArrowRight size={20} className="group-hover:rotate-45 transition-transform" />
|
title="Vanliga Frågor"
|
||||||
</div>
|
description="Snabba svar på turisternas vanligaste funderingar."
|
||||||
<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>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact Card */}
|
{/* Contact Card updated to 'glass' variant */}
|
||||||
<div className="bg-eggshell border-2 border-slate-teal/10 rounded-2xl overflow-hidden shadow-sm">
|
<SectionCard
|
||||||
<div className="bg-slate-teal/5 px-5 py-4 flex items-center border-b border-slate-teal/10">
|
title="Snabbkontakt"
|
||||||
<PhoneCall size={18} className="text-slate-teal mr-3" />
|
icon={PhoneCall}
|
||||||
<h3 className="font-black text-slate-teal uppercase tracking-widest text-sm">Snabbkontakt</h3>
|
variant="glass" // <-- Changed this to glass
|
||||||
</div>
|
>
|
||||||
<div className="p-5 space-y-4">
|
<div className="space-y-4 pt-2">
|
||||||
<div className="flex flex-col md:flex-row md:justify-between md:items-center pb-4 border-b border-slate-teal/10 gap-2">
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center pb-4 border-b border-white gap-2">
|
||||||
<span className="text-sm 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 text-sm'>
|
<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" />
|
||||||
@@ -66,8 +60,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SectionCard>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+55
-26
@@ -2,9 +2,15 @@
|
|||||||
|
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { CalendarRange, Clock } from 'lucide-react';
|
||||||
|
import Image from 'next/image';
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
|
import { PageHeader } from '../components/ui/PageHeader';
|
||||||
import scheduleData from '../data/schedule.json';
|
import scheduleData from '../data/schedule.json';
|
||||||
import { Clock, CalendarRange } from 'lucide-react';
|
|
||||||
|
// Icons
|
||||||
|
import falconIcon from '../assets/falcon.svg';
|
||||||
|
import porpoiseIcon from '../assets/porpoise.svg';
|
||||||
|
|
||||||
// --- Helper Functions ---
|
// --- Helper Functions ---
|
||||||
const parseTimeBlock = (timeStr: string) => {
|
const parseTimeBlock = (timeStr: string) => {
|
||||||
@@ -35,13 +41,27 @@ const ShiftBlock: React.FC<ShiftBlockProps> = ({ team, data, pos, isOverlapping
|
|||||||
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";
|
||||||
const fallbackTitle = isPF ? "PF" : "TU";
|
const fallbackTitle = isPF ? "PF" : "TU";
|
||||||
|
|
||||||
|
const iconSrc = isPF ? falconIcon : porpoiseIcon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
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 transition-colors hover:brightness-105`}
|
||||||
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`}>{data.title || fallbackTitle}</p>
|
<p className={`text-[10px] font-black ${textMain} uppercase tracking-wide leading-tight truncate flex items-center gap-1`}>
|
||||||
<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>
|
<Image
|
||||||
|
src={iconSrc}
|
||||||
|
alt={team}
|
||||||
|
width={10}
|
||||||
|
height={10}
|
||||||
|
className={`shrink-0 opacity-90 ${!isPF ? 'brightness-0 invert' : ''}`}
|
||||||
|
/>
|
||||||
|
{data.title || fallbackTitle}
|
||||||
|
</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`}>{data.notes}</p>
|
<p className={`text-[9px] font-medium ${textMain} leading-tight truncate pb-1`}>{data.notes}</p>
|
||||||
@@ -53,7 +73,7 @@ const ShiftBlock: React.FC<ShiftBlockProps> = ({ team, data, pos, isOverlapping
|
|||||||
|
|
||||||
// --- Main Schedule View ---
|
// --- Main Schedule View ---
|
||||||
export default function Schedule() {
|
export default function Schedule() {
|
||||||
const startHour = 7;
|
const startHour = 8;
|
||||||
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;
|
||||||
@@ -70,25 +90,24 @@ export default function Schedule() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in w-full">
|
<div className="space-y-2 animate-fade-in w-full">
|
||||||
<div>
|
<PageHeader
|
||||||
<h1 className="text-3xl font-black text-slate-teal tracking-tight flex items-center">
|
title="Veckoschema"
|
||||||
<CalendarRange className="mr-3 text-seafoam" size={32} />
|
icon={CalendarRange}
|
||||||
Veckoschema
|
description="Här hittar du när vi bemannar Kullaberg."
|
||||||
</h1>
|
/>
|
||||||
<p className="text-moss font-medium mt-2 ml-11">Här hittar du när vi bemannar Kullaberg.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
{/* Direct glass container (No extra padding from SectionCard) to maximize width */}
|
||||||
|
<div className="bg-white/40 backdrop-blur-md border border-white/40 rounded-3xl shadow-sm overflow-hidden flex flex-col max-h-[60vh] h-max min-h-125 w-full">
|
||||||
<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-13 flex-none bg-eggshell border-r border-slate-teal/10 shadow-[2px_0_5px_rgba(0,0,0,0.02)]">
|
<div className="sticky left-0 z-20 w-12 flex-none bg-white/60 backdrop-blur-md border-r border-white/40 shadow-[2px_0_5px_rgba(0,0,0,0.02)]">
|
||||||
<div className="sticky top-0 z-30 h-10 border-b border-slate-teal/10 bg-eggshell"></div>
|
<div className="sticky top-0 z-30 h-10 border-b border-white/40 bg-white/40 backdrop-blur-md"></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-2" style={{ top: `${i * PIXELS_PER_HOUR + GRID_PADDING_TOP}px` }}>
|
<div key={hour} className="absolute w-full flex justify-center" style={{ top: `${i * PIXELS_PER_HOUR + GRID_PADDING_TOP}px` }}>
|
||||||
<span className="text-[10px] font-bold text-slate-teal -mt-2 bg-eggshell px-1">
|
<span className="text-[10px] font-bold text-slate-teal -mt-2 px-1 rounded ">
|
||||||
{hour.toString().padStart(2, '0')}:00
|
{hour.toString().padStart(2, '0')}:00
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,9 +118,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-10 border-b border-slate-teal/10 bg-eggshell">
|
<div className="sticky top-0 z-10 flex h-10 border-b border-white/40 bg-white/60 backdrop-blur-md">
|
||||||
{scheduleData.map((d) => (
|
{scheduleData.map((d) => (
|
||||||
<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">
|
<div key={d.day} className="flex-1 flex items-center justify-center border-l border-white/20 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>
|
||||||
))}
|
))}
|
||||||
@@ -111,7 +130,7 @@ 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/5" 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-white/30" style={{ top: `${i * PIXELS_PER_HOUR + GRID_PADDING_TOP}px`, height: `${PIXELS_PER_HOUR}px` }} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 flex z-0">
|
<div className="absolute inset-0 flex z-0">
|
||||||
@@ -123,7 +142,7 @@ export default function Schedule() {
|
|||||||
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/5 relative min-w-25">
|
<div key={colIndex} className="flex-1 border-l border-white/30 relative min-w-25">
|
||||||
{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;
|
||||||
@@ -142,11 +161,21 @@ export default function Schedule() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend with Icons */}
|
||||||
<div className="flex items-center justify-center space-x-6 py-2 text-xs font-bold text-slate-teal">
|
<div className="flex items-center justify-center space-x-6 py-2 text-xs font-bold text-ebony">
|
||||||
<span className="flex items-center"><div className="w-3 h-3 bg-linear-to-br from-gold to-goldenrod rounded-full mr-2 shadow-sm"></div> Pilgrimsfalkarna</span>
|
<span className="flex items-center">
|
||||||
<span className="flex items-center"><div className="w-3 h-3 bg-linear-to-br from-seafoam to-slate-teal rounded-full mr-2 shadow-sm"></div> Tumlarna</span>
|
<div className="w-6 h-6 bg-linear-to-br from-gold to-goldenrod rounded-lg mr-2 shadow-sm flex items-center justify-center">
|
||||||
|
<Image src={falconIcon} alt="PF" width={14} height={14} />
|
||||||
|
</div>
|
||||||
|
Pilgrimsfalkarna
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<div className="w-6 h-6 bg-linear-to-br from-seafoam to-slate-teal rounded-lg mr-2 shadow-sm flex items-center justify-center">
|
||||||
|
<Image src={porpoiseIcon} alt="TU" width={14} height={14} className="brightness-0 invert" />
|
||||||
|
</div>
|
||||||
|
Tumlarna
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
Reference in New Issue
Block a user