Dynamic data files
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 ./
|
||||||
|
|||||||
@@ -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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+55
-33
@@ -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) => {
|
||||||
|
const IconComponent = IconMap[doc.icon as string] || FileText;
|
||||||
|
const renderDoc = { ...doc, icon: <IconComponent size={20} /> };
|
||||||
|
return (
|
||||||
<DocumentCard
|
<DocumentCard
|
||||||
key={idx}
|
key={idx}
|
||||||
doc={doc}
|
doc={renderDoc}
|
||||||
isOffline={isOffline}
|
isOffline={isOffline}
|
||||||
isCached={cachedFiles.has(doc.file)}
|
isCached={cachedFiles.has(doc.file)}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
+25
-4
@@ -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
@@ -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">
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user