Dynamic data files

This commit is contained in:
2026-03-18 15:29:05 +01:00 Verified
parent d61f2af6d4
commit 2814a287fb
20 changed files with 272 additions and 186 deletions
+7
View File
@@ -1,3 +1,10 @@
# Ignore the heavy PDFs during build
public/files/*
# (Optional) Keep a placeholder file so the directory structure isn't completely lost locally
!public/files/.gitkeep
# Dependency directories # Dependency directories
node_modules node_modules
npm-debug.log npm-debug.log
+1 -1
View File
@@ -6,7 +6,7 @@ generated/
*.db-journal *.db-journal
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
data/
# PWA / Serwist generated files # PWA / Serwist generated files
public/sw.js public/sw.js
+1
View File
@@ -24,6 +24,7 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN mkdir -p data RUN mkdir -p data
RUN mkdir -p public/files
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
+33
View File
@@ -0,0 +1,33 @@
// app/actions/jsonEditor.ts
"use server";
import fs from 'fs/promises';
import path from 'path';
const isProd = process.env.NODE_ENV === "production";
const DATA_DIR = isProd ? "/app/data" : path.join(process.cwd(), "app/data");
export async function readJsonFile(filename: string) {
try {
const filePath = path.join(DATA_DIR, filename);
const fileContent = await fs.readFile(filePath, 'utf-8');
return { success: true, data: JSON.parse(fileContent) };
} catch (error) {
console.error(`Kunde inte läsa ${filename}:`, error);
return { success: false, data: null };
}
}
export async function writeJsonFile(filename: string, data: any) {
try {
const filePath = path.join(DATA_DIR, filename);
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(filePath, JSON.stringify(data, null, 4), 'utf-8');
return { success: true };
} catch (error) {
console.error(`Kunde inte spara ${filename}:`, error);
return { success: false, error: "Kunde inte spara filen." };
}
}
+5 -6
View File
@@ -1,10 +1,8 @@
// app/admin/AttendanceTab.tsx // app/admin/AttendanceTab.tsx
"use client"; "use client";
import { CalendarRange, CheckCircle, ChevronLeft, ChevronRight } from 'lucide-react'; import { CalendarRange, CheckCircle, ChevronLeft, ChevronRight } from 'lucide-react';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import scheduleData from '../data/schedule.json';
import { type AttendanceDataMap, getAttendanceKey, type Period, toIsoDate } from './adminTypes'; import { type AttendanceDataMap, getAttendanceKey, type Period, toIsoDate } from './adminTypes';
import { TeamAttendanceCard } from './TeamAttendanceCard'; import { TeamAttendanceCard } from './TeamAttendanceCard';
@@ -15,6 +13,7 @@ interface Props {
addPendingAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void; addPendingAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
removeAttendanceEntry: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void; removeAttendanceEntry: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
activePeriodId: string; setActivePeriodId: (id: string) => void; activePeriodId: string; setActivePeriodId: (id: string) => void;
scheduleData: any[];
} }
export const getTeamShiftInfo = (daySchedule: any, team: 'PF' | 'TU') => { export const getTeamShiftInfo = (daySchedule: any, team: 'PF' | 'TU') => {
@@ -33,7 +32,7 @@ const getDaysInPeriod = (start: string, end: string) => {
return days; return days;
}; };
const getDailyCompletionStats = (date: string, period: Period, attendance: AttendanceDataMap) => { const getDailyCompletionStats = (date: string, period: Period, attendance: AttendanceDataMap, scheduleData: any[]) => {
const dayNameStr = new Date(date + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase(); const dayNameStr = new Date(date + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase();
const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr); const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr);
if (!daySchedule) return { expected: 0, completed: 0, isComplete: true, hasWork: false }; if (!daySchedule) return { expected: 0, completed: 0, isComplete: true, hasWork: false };
@@ -52,7 +51,7 @@ const getDailyCompletionStats = (date: string, period: Period, attendance: Atten
return { expected, completed, isComplete: expected > 0 && completed >= expected, hasWork }; return { expected, completed, isComplete: expected > 0 && completed >= expected, hasWork };
}; };
export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry, activePeriodId, setActivePeriodId }) => { export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry, activePeriodId, setActivePeriodId, scheduleData }) => {
const activePeriod = periods.find(p => p.id === activePeriodId); const activePeriod = periods.find(p => p.id === activePeriodId);
const [currentDate, setCurrentDate] = useState<string>(activePeriod ? activePeriod.startDate : toIsoDate(new Date())); const [currentDate, setCurrentDate] = useState<string>(activePeriod ? activePeriod.startDate : toIsoDate(new Date()));
@@ -76,7 +75,7 @@ export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualA
const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr.toLowerCase()); const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr.toLowerCase());
const timelineDays = getDaysInPeriod(activePeriod.startDate, activePeriod.endDate); const timelineDays = getDaysInPeriod(activePeriod.startDate, activePeriod.endDate);
const todayIso = toIsoDate(new Date()); const todayIso = toIsoDate(new Date());
const currentDayStats = getDailyCompletionStats(currentDate, activePeriod, attendance); const currentDayStats = getDailyCompletionStats(currentDate, activePeriod, attendance, scheduleData);
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;
@@ -99,7 +98,7 @@ export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualA
</div> </div>
<div className="flex overflow-x-auto gap-2.5 pb-3 pt-1 px-2 scrollbar-hide"> <div className="flex overflow-x-auto gap-2.5 pb-3 pt-1 px-2 scrollbar-hide">
{timelineDays.map(day => { {timelineDays.map(day => {
const stats = getDailyCompletionStats(day, activePeriod, attendance); const stats = getDailyCompletionStats(day, activePeriod, attendance, scheduleData);
const isSelected = day === currentDate; const isSelected = day === currentDate;
let bgClass = "bg-white text-ebony border-slate-teal/10"; let bgClass = "bg-white text-ebony border-slate-teal/10";
if (!stats.hasWork) bgClass = "bg-slate-teal/5 text-ebony/40 border-transparent"; if (!stats.hasWork) bgClass = "bg-slate-teal/5 text-ebony/40 border-transparent";
+3 -1
View File
@@ -9,11 +9,12 @@ 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;
scheduleData: any[];
} }
const POT_HOUR_LIMIT = 90; const POT_HOUR_LIMIT = 90;
export const ReportTab: React.FC<Props> = ({ periods, attendance, activePeriodId, setActivePeriodId, currentUserRole }) => { export const ReportTab: React.FC<Props> = ({ periods, attendance, activePeriodId, setActivePeriodId, currentUserRole, scheduleData }) => {
const activePeriod = periods.find(p => p.id === activePeriodId); const activePeriod = periods.find(p => p.id === activePeriodId);
const [expandedYouthId, setExpandedYouthId] = useState<string | null>(null); const [expandedYouthId, setExpandedYouthId] = useState<string | null>(null);
@@ -109,6 +110,7 @@ export const ReportTab: React.FC<Props> = ({ periods, attendance, activePeriodId
isExpanded={expandedYouthId === youth.id} isExpanded={expandedYouthId === youth.id}
onToggle={() => setExpandedYouthId(expandedYouthId === youth.id ? null : youth.id)} onToggle={() => setExpandedYouthId(expandedYouthId === youth.id ? null : youth.id)}
timeline={getTimelineForYouth(youth.id)} timeline={getTimelineForYouth(youth.id)}
scheduleData={scheduleData}
/> />
))} ))}
</div> </div>
+4 -9
View File
@@ -7,7 +7,6 @@ import Image from 'next/image';
import React from 'react'; import React from 'react';
import falconIcon from '../assets/falcon.svg'; import falconIcon from '../assets/falcon.svg';
import porpoiseIcon from '../assets/porpoise.svg'; import porpoiseIcon from '../assets/porpoise.svg';
import scheduleData from '../data/schedule.json';
import { formatTimeHHMM, isWeekend, type Youth } from './adminTypes'; import { formatTimeHHMM, isWeekend, type Youth } from './adminTypes';
import { getTeamShiftInfo } from './AttendanceTab'; import { getTeamShiftInfo } from './AttendanceTab';
@@ -18,6 +17,7 @@ interface YouthReportCardProps {
isExpanded: boolean; isExpanded: boolean;
onToggle: () => void; onToggle: () => void;
timeline: any[]; timeline: any[];
scheduleData: any[];
} }
const formatHours = (h: number) => Number(h.toFixed(1)).toString(); const formatHours = (h: number) => Number(h.toFixed(1)).toString();
@@ -28,7 +28,8 @@ export const YouthReportCard: React.FC<YouthReportCardProps> = ({
potHourLimit, potHourLimit,
isExpanded, isExpanded,
onToggle, onToggle,
timeline timeline,
scheduleData
}) => { }) => {
const warningStatus = totalHours > potHourLimit ? 'red' : (totalHours >= potHourLimit - 10 ? 'yellow' : 'none'); const warningStatus = totalHours > potHourLimit ? 'red' : (totalHours >= potHourLimit - 10 ? 'yellow' : 'none');
@@ -96,23 +97,17 @@ export const YouthReportCard: React.FC<YouthReportCardProps> = ({
const shiftLabel = entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag'; const shiftLabel = entry.shiftId === 'MORNING' ? 'Morgon' : 'Eftermiddag';
return ( return (
<div key={idx} className="flex flex-col sm:flex-row sm:justify-between sm:items-center bg-white p-3 rounded-xl border border-slate-teal/5 text-sm gap-2"> <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 hover:shadow-sm transition-shadow">
<div className="flex items-center flex-wrap gap-2"> <div className="flex items-center flex-wrap gap-2">
<span className="font-black text-ebony min-w-24"> <span className="font-black text-ebony min-w-24">
{entry.date} | {capitalizedShortDay} {entry.date} | {capitalizedShortDay}
</span> </span>
{/* 1. Shift Label */}
{!isWknd && ( {!isWknd && (
<span className="text-[10px] font-black uppercase tracking-widest text-slate-teal bg-slate-teal/10 px-2 py-1 rounded-lg"> <span className="text-[10px] font-black uppercase tracking-widest text-slate-teal bg-slate-teal/10 px-2 py-1 rounded-lg">
{shiftLabel} {shiftLabel}
</span> </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>} {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 && ( {isExtra && (
<span className="text-[10px] font-black uppercase tracking-widest bg-slate-teal text-eggshell px-2 py-1 rounded-lg shadow-sm"> <span className="text-[10px] font-black uppercase tracking-widest bg-slate-teal text-eggshell px-2 py-1 rounded-lg shadow-sm">
Extra Extra
+3 -1
View File
@@ -142,7 +142,7 @@ export default function Admin() {
</div> </div>
</div> </div>
{adminState.isLoadingData && adminState.periods.length === 0 ? ( {adminState.isLoadingData && (adminState.periods.length === 0 || adminState.scheduleData.length === 0) ? (
<div className="flex flex-col items-center justify-center py-20 text-slate-teal"> <div className="flex flex-col items-center justify-center py-20 text-slate-teal">
<Loader2 size={40} className="animate-spin mb-4" /> <Loader2 size={40} className="animate-spin mb-4" />
<p className="font-bold text-sm animate-pulse">Hämtar data...</p> <p className="font-bold text-sm animate-pulse">Hämtar data...</p>
@@ -174,6 +174,7 @@ export default function Admin() {
removeAttendanceEntry={adminState.removeAttendanceEntry} removeAttendanceEntry={adminState.removeAttendanceEntry}
activePeriodId={adminState.activePeriodId} activePeriodId={adminState.activePeriodId}
setActivePeriodId={adminState.setActivePeriodId} setActivePeriodId={adminState.setActivePeriodId}
scheduleData={adminState.scheduleData}
/> />
)} )}
{activeTab === 'report' && ( {activeTab === 'report' && (
@@ -183,6 +184,7 @@ export default function Admin() {
activePeriodId={adminState.activePeriodId} activePeriodId={adminState.activePeriodId}
setActivePeriodId={adminState.setActivePeriodId} setActivePeriodId={adminState.setActivePeriodId}
currentUserRole={currentUser.role} currentUserRole={currentUser.role}
scheduleData={adminState.scheduleData}
/> />
)} )}
</> </>
+41 -113
View File
@@ -2,20 +2,21 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from 'react';
import localforage from 'localforage'; import localforage from 'localforage';
import { Period, AttendanceDataMap, Youth, toIsoDate, isWeekend, getAttendanceKey } from './adminTypes'; import { useCallback, useEffect, useState } from 'react';
import { getAdminData, createPeriodDb, deletePeriodDb, bulkAddYouthDb, removeYouthDb, setAttendanceDb, removeAttendanceDb, syncOfflineQueueDb, bulkSetAttendanceDb } from '../actions/admin'; import { bulkAddYouthDb, bulkSetAttendanceDb, createPeriodDb, deletePeriodDb, getAdminData, removeAttendanceDb, removeYouthDb, setAttendanceDb, syncOfflineQueueDb } from '../actions/admin';
import { readJsonFile } from '../actions/jsonEditor';
import { AttendanceDataMap, Period, Youth, getAttendanceKey, isWeekend, toIsoDate } from './adminTypes';
export const useAdminState = () => { export const useAdminState = () => {
const [periods, setPeriods] = useState<Period[]>([]); const [periods, setPeriods] = useState<Period[]>([]);
const [attendance, setAttendance] = useState<AttendanceDataMap>({}); const [attendance, setAttendance] = useState<AttendanceDataMap>({});
const [scheduleData, setScheduleData] = useState<any[]>([]);
const [activePeriodId, setActivePeriodId] = useState<string>(''); const [activePeriodId, setActivePeriodId] = useState<string>('');
const [isLoadingData, setIsLoadingData] = useState<boolean>(true); const [isLoadingData, setIsLoadingData] = useState<boolean>(true);
const [isOffline, setIsOffline] = useState<boolean>(false); const [isOffline, setIsOffline] = useState<boolean>(false);
const [isSyncing, setIsSyncing] = useState<boolean>(false); const [isSyncing, setIsSyncing] = useState<boolean>(false);
// --- 1. DATA MAPPING ---
const mapDbDataToUI = (dbPeriods: any[]) => { const mapDbDataToUI = (dbPeriods: any[]) => {
const loadedPeriods: Period[] = []; const loadedPeriods: Period[] = [];
const loadedAttendance: AttendanceDataMap = {}; const loadedAttendance: AttendanceDataMap = {};
@@ -44,9 +45,7 @@ export const useAdminState = () => {
for (const action of queue) { for (const action of queue) {
if (action.type === 'SET_ATTENDANCE') { if (action.type === 'SET_ATTENDANCE') {
const { date, youthId, shiftId, hoursWorked, weightedHours, status, note } = action.payload; const { date, youthId, shiftId, hoursWorked, weightedHours, status, note } = action.payload;
nextAttendance[getAttendanceKey(date, youthId, shiftId)] = { nextAttendance[getAttendanceKey(date, youthId, shiftId)] = { date, youthId, shiftId, hoursWorked, weightedHours, status, note };
date, youthId, shiftId, hoursWorked, weightedHours, status, note
};
} else if (action.type === 'REMOVE_ATTENDANCE') { } else if (action.type === 'REMOVE_ATTENDANCE') {
const { date, youthId, shiftId } = action.payload; const { date, youthId, shiftId } = action.payload;
delete nextAttendance[getAttendanceKey(date, youthId, shiftId)]; delete nextAttendance[getAttendanceKey(date, youthId, shiftId)];
@@ -55,7 +54,6 @@ export const useAdminState = () => {
return nextAttendance; return nextAttendance;
}; };
// --- 2. OFFLINE QUEUE FLUSHER ---
const flushOfflineQueue = useCallback(async () => { const flushOfflineQueue = useCallback(async () => {
try { try {
const queue = await localforage.getItem<any[]>('sync-queue') || []; const queue = await localforage.getItem<any[]>('sync-queue') || [];
@@ -70,13 +68,17 @@ export const useAdminState = () => {
return false; return false;
}, []); }, []);
// --- 3. MAIN DATA FETCHING ---
const refreshData = useCallback(async (isInitialLoad = false) => { const refreshData = useCallback(async (isInitialLoad = false) => {
if (!isInitialLoad) setIsSyncing(true); if (!isInitialLoad) setIsSyncing(true);
try { try {
if (navigator.onLine) { if (navigator.onLine) await flushOfflineQueue();
await flushOfflineQueue();
// NYTT: Hämta schemat via filsystemet
const schedRes = await readJsonFile('schedule.json');
if (schedRes.success && schedRes.data) {
setScheduleData(schedRes.data);
await localforage.setItem('cachedScheduleData', schedRes.data);
} }
const dbPeriods = await getAdminData(); const dbPeriods = await getAdminData();
@@ -92,24 +94,20 @@ export const useAdminState = () => {
if (isInitialLoad && loadedPeriods.length > 0) { if (isInitialLoad && loadedPeriods.length > 0) {
const today = toIsoDate(new Date()); const today = toIsoDate(new Date());
const activePeriods = loadedPeriods.filter(p => today >= p.startDate && today <= p.endDate); const activePeriods = loadedPeriods.filter(p => today >= p.startDate && today <= p.endDate);
if (activePeriods.length > 0) setActivePeriodId(activePeriods[0].id);
if (activePeriods.length > 0) { else setActivePeriodId(loadedPeriods[0].id);
setActivePeriodId(activePeriods[0].id);
} else {
setActivePeriodId(loadedPeriods[0].id);
}
} }
} catch (error) { } catch (error) {
setIsOffline(true); setIsOffline(true);
const cachedData = await localforage.getItem<any[]>('cachedAdminData'); const cachedSched = await localforage.getItem<any[]>('cachedScheduleData');
if (cachedSched) setScheduleData(cachedSched);
const cachedData = await localforage.getItem<any[]>('cachedAdminData');
if (cachedData) { if (cachedData) {
const { loadedPeriods, loadedAttendance } = mapDbDataToUI(cachedData); const { loadedPeriods, loadedAttendance } = mapDbDataToUI(cachedData);
const finalAttendance = await applyQueueToAttendance(loadedAttendance); const finalAttendance = await applyQueueToAttendance(loadedAttendance);
setPeriods(loadedPeriods); setPeriods(loadedPeriods);
setAttendance(finalAttendance); setAttendance(finalAttendance);
if (isInitialLoad && loadedPeriods.length > 0) setActivePeriodId(loadedPeriods[0].id); if (isInitialLoad && loadedPeriods.length > 0) setActivePeriodId(loadedPeriods[0].id);
} }
} finally { } finally {
@@ -118,62 +116,30 @@ export const useAdminState = () => {
} }
}, [flushOfflineQueue]); }, [flushOfflineQueue]);
useEffect(() => { useEffect(() => { refreshData(true); }, [refreshData]);
refreshData(true);
}, [refreshData]);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => { if (navigator.onLine) refreshData(); }, 10000);
if (navigator.onLine) refreshData(); const handleVisibilityChange = () => { if (document.visibilityState === 'visible' && navigator.onLine) refreshData(); };
}, 10000);
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && navigator.onLine) {
refreshData();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('focus', handleVisibilityChange); window.addEventListener('focus', handleVisibilityChange);
return () => { clearInterval(interval); document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('focus', handleVisibilityChange); };
return () => {
clearInterval(interval);
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('focus', handleVisibilityChange);
};
}, [refreshData]); }, [refreshData]);
// --- 4. OFFLINE/ONLINE EVENT LISTENERS ---
useEffect(() => { useEffect(() => {
const handleOnline = async () => { const handleOnline = async () => { setIsOffline(false); const didSync = await flushOfflineQueue(); if (didSync) refreshData(); };
setIsOffline(false); window.addEventListener('online', handleOnline); window.addEventListener('offline', () => setIsOffline(true));
const didSync = await flushOfflineQueue(); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', () => setIsOffline(true)); };
if (didSync) refreshData();
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', () => setIsOffline(true));
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', () => setIsOffline(true));
};
}, [flushOfflineQueue, refreshData]); }, [flushOfflineQueue, refreshData]);
const addToOfflineQueue = async (action: any) => { const addToOfflineQueue = async (action: any) => {
const queue = await localforage.getItem<any[]>('sync-queue') || []; const queue = await localforage.getItem<any[]>('sync-queue') || [];
const filteredQueue = queue.filter(q => const filteredQueue = queue.filter(q => !(q.payload.date === action.payload.date && q.payload.youthId === action.payload.youthId && q.payload.shiftId === action.payload.shiftId));
!(q.payload.date === action.payload.date &&
q.payload.youthId === action.payload.youthId &&
q.payload.shiftId === action.payload.shiftId)
);
filteredQueue.push(action); filteredQueue.push(action);
await localforage.setItem('sync-queue', filteredQueue); await localforage.setItem('sync-queue', filteredQueue);
setIsOffline(true); setIsOffline(true);
}; };
// --- 5. DATABASE WRITE ACTIONS ---
const createPeriod = async (name: string, startDateStr: string) => { const createPeriod = async (name: string, startDateStr: string) => {
setIsLoadingData(true); setIsLoadingData(true);
const start = new Date(startDateStr + 'T12:00:00'); const start = new Date(startDateStr + 'T12:00:00');
@@ -183,11 +149,7 @@ export const useAdminState = () => {
setActivePeriodId(newPeriod.id); setActivePeriodId(newPeriod.id);
}; };
const deletePeriod = async (periodId: string) => { const deletePeriod = async (periodId: string) => { setPeriods(periods.filter(p => p.id !== periodId)); await deletePeriodDb(periodId); await refreshData(); };
setPeriods(periods.filter(p => p.id !== periodId));
await deletePeriodDb(periodId);
await refreshData();
};
const bulkAddYouth = async (periodId: string, text: string, defaultTeam: 'PF' | 'TU') => { const bulkAddYouth = async (periodId: string, text: string, defaultTeam: 'PF' | 'TU') => {
setIsLoadingData(true); setIsLoadingData(true);
@@ -199,89 +161,55 @@ export const useAdminState = () => {
await refreshData(); await refreshData();
}; };
const removeYouth = async (periodId: string, youthId: string) => { const removeYouth = async (periodId: string, youthId: string) => { setPeriods(periods.map(p => p.id === periodId ? { ...p, youthList: p.youthList.filter(y => y.id !== youthId) } : p)); await removeYouthDb(youthId); await refreshData(); };
setPeriods(periods.map(p => p.id === periodId ? { ...p, youthList: p.youthList.filter(y => y.id !== youthId) } : p));
await removeYouthDb(youthId);
await refreshData();
};
// --- 6. ATTENDANCE ACTIONS ---
const setManualAttendance = async (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON', status: 'Absent' | 'Late' | 'Present', hoursManual: number = 0, note?: string) => { const setManualAttendance = async (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON', status: 'Absent' | 'Late' | 'Present', hoursManual: number = 0, note?: string) => {
const weight = isWeekend(date) ? 1.5 : 1.0; const weight = isWeekend(date) ? 1.5 : 1.0;
let weightedHours = status === 'Late' || status === 'Present' ? hoursManual * weight : 0; let weightedHours = status === 'Late' || status === 'Present' ? hoursManual * weight : 0;
let hoursWorked = status === 'Late' || status === 'Present' ? hoursManual : 0; let hoursWorked = status === 'Late' || status === 'Present' ? hoursManual : 0;
const noteStr = note || ''; const noteStr = note || '';
setAttendance(prev => ({ setAttendance(prev => ({ ...prev, [getAttendanceKey(date, youthId, shiftId)]: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr } }));
...prev, [getAttendanceKey(date, youthId, shiftId)]: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr }
}));
if (!navigator.onLine) { if (!navigator.onLine) await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr } });
await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr } }); else await setAttendanceDb(date, youthId, shiftId, hoursWorked, weightedHours, status, noteStr).catch(async () => { await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr } }); });
} else {
await setAttendanceDb(date, youthId, shiftId, hoursWorked, weightedHours, status, noteStr).catch(async () => {
await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr } });
});
}
}; };
const bulkSetManualAttendance = async (records: { date: string; youthId: string; shiftId: 'MORNING' | 'AFTERNOON'; hoursWorked: number; weightedHours: number; status: 'Absent' | 'Late' | 'Present'; note: string }[]) => { const bulkSetManualAttendance = async (records: { date: string; youthId: string; shiftId: 'MORNING' | 'AFTERNOON'; hoursWorked: number; weightedHours: number; status: 'Absent' | 'Late' | 'Present'; note: string }[]) => {
setAttendance(prev => { setAttendance(prev => { const next = { ...prev }; records.forEach(record => { next[getAttendanceKey(record.date, record.youthId, record.shiftId)] = record; }); return next; });
const next = { ...prev };
records.forEach(record => {
next[getAttendanceKey(record.date, record.youthId, record.shiftId)] = record;
});
return next;
});
const processOfflineQueue = async () => { const processOfflineQueue = async () => {
const queue = await localforage.getItem<any[]>('sync-queue') || []; const queue = await localforage.getItem<any[]>('sync-queue') || [];
records.forEach(record => { records.forEach(record => {
const idx = queue.findIndex(q => q.payload.date === record.date && q.payload.youthId === record.youthId && q.payload.shiftId === record.shiftId); const idx = queue.findIndex(q => q.payload.date === record.date && q.payload.youthId === record.youthId && q.payload.shiftId === record.shiftId);
const action = { type: 'SET_ATTENDANCE', payload: record }; const action = { type: 'SET_ATTENDANCE', payload: record };
if (idx > -1) queue[idx] = action; if (idx > -1) queue[idx] = action; else queue.push(action);
else queue.push(action);
}); });
await localforage.setItem('sync-queue', queue); await localforage.setItem('sync-queue', queue);
setIsOffline(true); setIsOffline(true);
}; };
if (!navigator.onLine) { if (!navigator.onLine) await processOfflineQueue();
await processOfflineQueue(); else await bulkSetAttendanceDb(records).catch(async () => { await processOfflineQueue(); });
} else {
await bulkSetAttendanceDb(records).catch(async () => {
await processOfflineQueue();
});
}
}; };
const addPendingAttendance = async (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => { const addPendingAttendance = async (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => {
setAttendance(prev => ({ ...prev, [getAttendanceKey(date, youthId, shiftId)]: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' } })); setAttendance(prev => ({ ...prev, [getAttendanceKey(date, youthId, shiftId)]: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' } }));
if (!navigator.onLine) { if (!navigator.onLine) await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' } });
await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' } }); else await setAttendanceDb(date, youthId, shiftId, 0, 0, 'Pending', '').catch(async () => { await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' } }); });
} else {
await setAttendanceDb(date, youthId, shiftId, 0, 0, 'Pending', '').catch(async () => {
await addToOfflineQueue({ type: 'SET_ATTENDANCE', payload: { date, youthId, shiftId, hoursWorked: 0, weightedHours: 0, status: 'Pending', note: '' } });
});
}
}; };
const removeAttendanceEntry = async (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => { const removeAttendanceEntry = async (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => {
setAttendance(prev => { const next = { ...prev }; delete next[getAttendanceKey(date, youthId, shiftId)]; return next; }); setAttendance(prev => { const next = { ...prev }; delete next[getAttendanceKey(date, youthId, shiftId)]; return next; });
if (!navigator.onLine) { if (!navigator.onLine) await addToOfflineQueue({ type: 'REMOVE_ATTENDANCE', payload: { date, youthId, shiftId } });
await addToOfflineQueue({ type: 'REMOVE_ATTENDANCE', payload: { date, youthId, shiftId } }); else await removeAttendanceDb(date, youthId, shiftId).catch(async () => { await addToOfflineQueue({ type: 'REMOVE_ATTENDANCE', payload: { date, youthId, shiftId } }); });
} else {
await removeAttendanceDb(date, youthId, shiftId).catch(async () => {
await addToOfflineQueue({ type: 'REMOVE_ATTENDANCE', payload: { date, youthId, shiftId } });
});
}
}; };
return { return {
periods, attendance, activePeriodId, setActivePeriodId, isLoadingData, isOffline, isSyncing, periods, attendance, activePeriodId, setActivePeriodId, isLoadingData, isOffline, isSyncing, scheduleData,
createPeriod, deletePeriod, bulkAddYouth, removeYouth, createPeriod, deletePeriod, bulkAddYouth, removeYouth,
setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry, refreshData
}; };
}; };
+62
View File
@@ -0,0 +1,62 @@
{
"docs": [
{
"title": "Turistkarta",
"icon": "Map",
"file": "/files/Turistkarta - Kullaberg.pdf",
"size": "2.51 MB"
},
{
"title": "Orienteringskarta",
"icon": "MapPlus",
"file": "/files/Orienteringskarta - Kullaberg.pdf",
"size": "3.74 MB"
},
{
"title": "Badplatser att besöka",
"icon": "WavesLadder",
"file": "/files/Badplatser på Kullaberg.pdf",
"size": "1.88 MB"
},
{
"title": "Vandringsrutter",
"icon": "MapPinned",
"file": "/files/Vandringsrutter.pdf",
"size": "4.42 MB"
},
{
"title": "Underlag för guidediplomering",
"icon": "BookCopy",
"file": "/files/Underlag för guidediplomering.pdf",
"size": "3.18 MB"
},
{
"title": "Destinationskunskap Kullahalvön",
"icon": "BookCopy",
"file": "/files/Destinationskunskap.pdf",
"size": "1.97 MB"
}
],
"links": [
{
"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": "Naturkartan",
"url": "https://www.naturkartan.se/en/explore"
},
{
"title": "Skåneleden",
"url": "https://www.skaneleden.se/en"
},
{
"title": "Väderprognos Mölle",
"url": "https://www.smhi.se/vader/prognoser-och-varningar/vaderprognos/q/H%C3%B6gan%C3%A4s/M%C3%B6lle/2691501"
}
]
}
+60 -38
View File
@@ -2,52 +2,69 @@
"use client"; "use client";
import { BookCopy, CheckCircle, CloudDownload, Folder, Loader2, Map, MapPinned, MapPlus, WavesLadder } from 'lucide-react'; import { BookCopy, CheckCircle, CloudDownload, FileText, Folder, Loader2, Map, MapPinned, MapPlus, WavesLadder } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { DocumentCard, ExternalLinkCard, DocumentItem, LinkItem } from '../components/ui/Cards'; import { readJsonFile } from '../actions/jsonEditor';
import { DocumentCard, DocumentItem, ExternalLinkCard, LinkItem } from '../components/ui/Cards';
import { OfflineBadge } from '../components/ui/OfflineBadge'; import { OfflineBadge } from '../components/ui/OfflineBadge';
import { PageHeader } from '../components/ui/PageHeader'; import { PageHeader } from '../components/ui/PageHeader';
const IconMap: Record<string, any> = {
"Map": Map,
"MapPlus": MapPlus,
"WavesLadder": WavesLadder,
"MapPinned": MapPinned,
"BookCopy": BookCopy,
"FileText": FileText
};
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 [showNotification, setShowNotification] = useState(false);
const [docs, setDocs] = useState<DocumentItem[]>([]);
const [links, setLinks] = useState<LinkItem[]>([]);
const [isLoadingData, setIsLoadingData] = useState(true);
const docs: DocumentItem[] = [ const checkCacheStatus = async (currentDocs: DocumentItem[]) => {
{ title: 'Turistkarta', icon: <Map size={20} />, file: '/files/Turistkarta - Kullaberg.pdf', size: '2.51 MB' }, if (!('caches' in window) || currentDocs.length === 0) return;
{ 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: 'Vandringsrutter', icon: <MapPinned size={20} />, file: '/files/Vandringsrutter.pdf', size: '4.42 MB' },
{ title: 'Underlag för guidediplomering', icon: <BookCopy size={20} />, file: '/files/Underlag för guidediplomering.pdf', size: '3.18 MB' },
{ title: 'Destinationskunskap Kullahalvön', icon: <BookCopy size={20} />, file: '/files/Destinationskunskap.pdf', size: '1.97 MB' }
];
const links: LinkItem[] = [
{ 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: 'Naturkartan', url: 'https://www.naturkartan.se/en/explore' },
{ title: 'Skåneleden', url: 'https://www.skaneleden.se/en' },
{ title: 'Väderprognos Mölle', url: 'https://www.smhi.se/vader/prognoser-och-varningar/vaderprognos/q/H%C3%B6gan%C3%A4s/M%C3%B6lle/2691501' }
];
const checkCacheStatus = async () => {
if (!('caches' in window)) return;
const cached = new Set<string>(); const cached = new Set<string>();
try { try {
for (const doc of docs) { for (const doc of currentDocs) {
const response = await caches.match(doc.file); const response = await caches.match(doc.file);
if (response) cached.add(doc.file); if (response) cached.add(doc.file);
} }
setCachedFiles(cached); setCachedFiles(cached);
if (cached.size === docs.length) setSyncStatus('done'); if (cached.size === currentDocs.length) setSyncStatus('done');
else setSyncStatus('idle'); else setSyncStatus('idle');
} catch (error) { } catch (error) {
console.error("Cache check failed", error); console.error("Cache check failed", error);
} }
}; };
const loadDynamicData = async () => {
if (navigator.onLine) {
const res = await readJsonFile('documents.json');
if (res.success && res.data) {
setDocs(res.data.docs || []);
setLinks(res.data.links || []);
localStorage.setItem('kullaberg_documents_cache', JSON.stringify(res.data));
checkCacheStatus(res.data.docs || []);
}
} else {
const cachedData = localStorage.getItem('kullaberg_documents_cache');
if (cachedData) {
const parsed = JSON.parse(cachedData);
setDocs(parsed.docs || []);
setLinks(parsed.links || []);
checkCacheStatus(parsed.docs || []);
}
}
setIsLoadingData(false);
};
useEffect(() => { useEffect(() => {
setIsHydrated(true); setIsHydrated(true);
setIsOffline(!navigator.onLine); setIsOffline(!navigator.onLine);
@@ -56,13 +73,13 @@ export default function Documents() {
window.addEventListener('online', handleStatus); window.addEventListener('online', handleStatus);
window.addEventListener('offline', handleStatus); window.addEventListener('offline', handleStatus);
checkCacheStatus(); loadDynamicData();
const handleVisibility = () => { const handleVisibility = () => {
if (document.visibilityState === 'visible') checkCacheStatus(); if (document.visibilityState === 'visible') checkCacheStatus(docs);
}; };
document.addEventListener('visibilitychange', handleVisibility); document.addEventListener('visibilitychange', handleVisibility);
window.addEventListener('focus', checkCacheStatus); window.addEventListener('focus', () => checkCacheStatus(docs));
const conn = (navigator as any).connection; const conn = (navigator as any).connection;
if (conn && (conn.type === 'wifi' || conn.type === 'ethernet') && !conn.saveData) { if (conn && (conn.type === 'wifi' || conn.type === 'ethernet') && !conn.saveData) {
@@ -73,11 +90,12 @@ export default function Documents() {
window.removeEventListener('online', handleStatus); window.removeEventListener('online', handleStatus);
window.removeEventListener('offline', handleStatus); window.removeEventListener('offline', handleStatus);
document.removeEventListener('visibilitychange', handleVisibility); document.removeEventListener('visibilitychange', handleVisibility);
window.removeEventListener('focus', checkCacheStatus); window.removeEventListener('focus', () => checkCacheStatus(docs));
}; };
}, []); }, []);
const handleSyncAll = async () => { const handleSyncAll = async () => {
if (docs.length === 0) return;
setSyncStatus('syncing'); setSyncStatus('syncing');
const updatedCache = new Set(cachedFiles); const updatedCache = new Set(cachedFiles);
@@ -96,13 +114,13 @@ export default function Documents() {
} }
} }
await checkCacheStatus(); await checkCacheStatus(docs);
setSyncStatus('done'); setSyncStatus('done');
setShowNotification(true); setShowNotification(true);
setTimeout(() => setShowNotification(false), 4000); 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 || isLoadingData) 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 relative"> <div className="space-y-8 animate-fade-in w-full relative">
@@ -113,7 +131,7 @@ export default function Documents() {
actions={ actions={
<> <>
{isOffline && <OfflineBadge />} {isOffline && <OfflineBadge />}
{!isOffline && syncStatus !== 'done' && ( {!isOffline && syncStatus !== 'done' && docs.length > 0 && (
<button <button
onClick={handleSyncAll} onClick={handleSyncAll}
disabled={syncStatus === 'syncing'} disabled={syncStatus === 'syncing'}
@@ -130,14 +148,18 @@ export default function Documents() {
<section> <section>
<h2 className="text-sm font-black text-ebony/50 uppercase tracking-widest mb-4">Filer</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) => {
<DocumentCard const IconComponent = IconMap[doc.icon as string] || FileText;
key={idx} const renderDoc = { ...doc, icon: <IconComponent size={20} /> };
doc={doc} return (
isOffline={isOffline} <DocumentCard
isCached={cachedFiles.has(doc.file)} key={idx}
/> doc={renderDoc}
))} isOffline={isOffline}
isCached={cachedFiles.has(doc.file)}
/>
);
})}
</div> </div>
</section> </section>
+25 -4
View File
@@ -2,20 +2,41 @@
"use client"; "use client";
import { MessageCircleQuestionMark } from 'lucide-react'; import { Loader2, MessageCircleQuestionMark } from 'lucide-react';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { readJsonFile } from '../actions/jsonEditor';
import { FAQItem } from '../components/ui/FAQItem'; import { FAQItem } from '../components/ui/FAQItem';
import { PageHeader } from '../components/ui/PageHeader'; import { PageHeader } from '../components/ui/PageHeader';
import { SectionCard } from '../components/ui/SectionCard'; import { SectionCard } from '../components/ui/SectionCard';
import faqData from '../data/faq.json';
export default function FAQ() { export default function FAQ() {
const [openIndex, setOpenIndex] = useState<string | null>(null); const [openIndex, setOpenIndex] = useState<string | null>(null);
const [faqData, setFaqData] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadFAQ = async () => {
if (navigator.onLine) {
const res = await readJsonFile('faq.json');
if (res.success && res.data) {
setFaqData(res.data);
localStorage.setItem('kullaberg_faq_cache', JSON.stringify(res.data));
}
} else {
const cachedData = localStorage.getItem('kullaberg_faq_cache');
if (cachedData) setFaqData(JSON.parse(cachedData));
}
setIsLoading(false);
};
loadFAQ();
}, []);
const toggleQuestion = (index: string) => { const toggleQuestion = (index: string) => {
setOpenIndex(openIndex === index ? null : index); setOpenIndex(openIndex === index ? null : index);
}; };
if (isLoading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-slate-teal" size={40} /></div>;
return ( return (
<div className="w-full animate-fade-in space-y-6"> <div className="w-full animate-fade-in space-y-6">
<PageHeader <PageHeader
@@ -32,7 +53,7 @@ export default function FAQ() {
className="break-inside-avoid mb-4 inline-block w-full" 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: any, qIndex: number) => {
const id = `${sIndex}-${qIndex}`; const id = `${sIndex}-${qIndex}`;
return ( return (
<FAQItem <FAQItem
+27 -13
View File
@@ -2,15 +2,13 @@
"use client" "use client"
import { CalendarRange, Clock } from 'lucide-react'; import { CalendarRange, Clock, Loader2 } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import React, { useRef } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { PageHeader } from '../components/ui/PageHeader'; import { readJsonFile } from '../actions/jsonEditor';
import scheduleData from '../data/schedule.json';
// Icons
import falconIcon from '../assets/falcon.svg'; import falconIcon from '../assets/falcon.svg';
import porpoiseIcon from '../assets/porpoise.svg'; import porpoiseIcon from '../assets/porpoise.svg';
import { PageHeader } from '../components/ui/PageHeader';
// --- Helper Functions --- // --- Helper Functions ---
const parseTimeBlock = (timeStr: string) => { const parseTimeBlock = (timeStr: string) => {
@@ -81,6 +79,26 @@ export default function Schedule() {
const TOTAL_GRID_HEIGHT = (hours.length * PIXELS_PER_HOUR) + GRID_PADDING_TOP; const TOTAL_GRID_HEIGHT = (hours.length * PIXELS_PER_HOUR) + GRID_PADDING_TOP;
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [scheduleData, setScheduleData] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadSchedule = async () => {
if (navigator.onLine) {
const res = await readJsonFile('schedule.json');
if (res.success && res.data) {
setScheduleData(res.data);
localStorage.setItem('kullaberg_schedule_cache', JSON.stringify(res.data));
}
} else {
const cachedData = localStorage.getItem('kullaberg_schedule_cache');
if (cachedData) setScheduleData(JSON.parse(cachedData));
}
setIsLoading(false);
};
loadSchedule();
}, []);
const calculatePosition = (timeStr: string) => { const calculatePosition = (timeStr: string) => {
const t = parseTimeBlock(timeStr); const t = parseTimeBlock(timeStr);
if (!t) return null; if (!t) return null;
@@ -89,6 +107,8 @@ export default function Schedule() {
return { top: topOffset + GRID_PADDING_TOP + 1, height: duration - 2 }; return { top: topOffset + GRID_PADDING_TOP + 1, height: duration - 2 };
}; };
if (isLoading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-slate-teal" size={40} /></div>;
return ( return (
<div className="space-y-2 animate-fade-in w-full"> <div className="space-y-2 animate-fade-in w-full">
<PageHeader <PageHeader
@@ -97,13 +117,11 @@ export default function Schedule() {
description="Här hittar du när vi bemannar Kullaberg." description="Här hittar du när vi bemannar Kullaberg."
/> />
{/* 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="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 */}
<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 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-white/40 bg-white/40 backdrop-blur-md"></div> <div className="sticky top-0 z-30 h-10 border-b border-white/40 bg-white/40 backdrop-blur-md rounded-tl-3xl"></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-center" 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` }}>
@@ -115,9 +133,7 @@ export default function Schedule() {
</div> </div>
</div> </div>
{/* Grid Area */}
<div className="flex-auto relative"> <div className="flex-auto relative">
{/* Sticky Day Headers */}
<div className="sticky top-0 z-10 flex h-10 border-b border-white/40 bg-white/60 backdrop-blur-md"> <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-white/20 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">
@@ -126,7 +142,6 @@ export default function Schedule() {
))} ))}
</div> </div>
{/* Main Grid Canvas */}
<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) => (
@@ -161,7 +176,6 @@ export default function Schedule() {
</div> </div>
</div> </div>
{/* Legend with Icons */}
<div className="flex items-center justify-center space-x-6 py-2 text-xs font-bold text-ebony"> <div className="flex items-center justify-center space-x-6 py-2 text-xs font-bold text-ebony">
<span className="flex items-center"> <span className="flex items-center">
<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"> <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">
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.