Dynamic data files
This commit is contained in:
1 parent
d61f2af6d4
commit
2814a287fb
20 files changed
+272
-186
No files matched your search
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+60
-38
@@ -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
@@ -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">
|
||||||
|
|||||||
Whitespace-only changes.
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