283 lines
15 KiB
TypeScript
283 lines
15 KiB
TypeScript
// app/admin/useAdminState.ts
|
|
|
|
"use client";
|
|
|
|
import localforage from 'localforage';
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import { bulkAddYouthDb, bulkSetAttendanceDb, createPeriodDb, deletePeriodDb, getAdminData, removeAttendanceDb, removeYouthDb, setAttendanceDb, syncOfflineQueueDb, getDailyLogsDb, setDailyLogDb } from '../actions/admin';
|
|
import { readJsonFile } from '../actions/jsonEditor';
|
|
import { AttendanceDataMap, Period, Youth, getAttendanceKey, isWeekend, toIsoDate } from './adminTypes';
|
|
|
|
export const useAdminState = () => {
|
|
const [periods, setPeriods] = useState<Period[]>([]);
|
|
const [attendance, setAttendance] = useState<AttendanceDataMap>({});
|
|
const [scheduleData, setScheduleData] = useState<any[]>([]);
|
|
const [dailyLogs, setDailyLogs] = useState<Record<string, string>>({});
|
|
|
|
const [activePeriodId, setActivePeriodId] = useState<string>('');
|
|
const [isLoadingData, setIsLoadingData] = useState<boolean>(true);
|
|
const [isOffline, setIsOffline] = useState<boolean>(false);
|
|
const [isSyncing, setIsSyncing] = useState<boolean>(false);
|
|
|
|
const mapDbDataToUI = (dbPeriods: any[]) => {
|
|
const loadedPeriods: Period[] = [];
|
|
const loadedAttendance: AttendanceDataMap = {};
|
|
|
|
for (const dbPeriod of dbPeriods) {
|
|
const youthList: Youth[] = dbPeriod.youths.map((y: any) => ({ id: y.id, name: y.name, team: y.team as 'PF' | 'TU' }));
|
|
loadedPeriods.push({ id: dbPeriod.id, name: dbPeriod.name, startDate: dbPeriod.startDate, endDate: dbPeriod.endDate, youthList });
|
|
|
|
for (const youth of dbPeriod.youths) {
|
|
for (const att of youth.attendance) {
|
|
const key = getAttendanceKey(att.date, att.youthId, att.shiftId);
|
|
loadedAttendance[key] = {
|
|
date: att.date, youthId: att.youthId, shiftId: att.shiftId as 'MORNING' | 'AFTERNOON',
|
|
hoursWorked: att.hoursWorked, weightedHours: att.weightedHours, status: att.status as any, note: att.note || ''
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return { loadedPeriods, loadedAttendance };
|
|
};
|
|
|
|
const applyQueueToAttendance = async (baseAttendance: AttendanceDataMap) => {
|
|
const queue = await localforage.getItem<any[]>('sync-queue') || [];
|
|
const nextAttendance = { ...baseAttendance };
|
|
|
|
for (const action of queue) {
|
|
if (action.type === 'SET_ATTENDANCE') {
|
|
const { date, youthId, shiftId, hoursWorked, weightedHours, status, note } = action.payload;
|
|
nextAttendance[getAttendanceKey(date, youthId, shiftId)] = { date, youthId, shiftId, hoursWorked, weightedHours, status, note };
|
|
} else if (action.type === 'REMOVE_ATTENDANCE') {
|
|
const { date, youthId, shiftId } = action.payload;
|
|
delete nextAttendance[getAttendanceKey(date, youthId, shiftId)];
|
|
}
|
|
}
|
|
return nextAttendance;
|
|
};
|
|
|
|
const applyQueueToLogs = async (baseLogs: Record<string, string>) => {
|
|
const queue = await localforage.getItem<any[]>('sync-queue') || [];
|
|
const nextLogs = { ...baseLogs };
|
|
for (const action of queue) {
|
|
if (action.type === 'SET_DAILY_LOG') {
|
|
nextLogs[action.payload.date] = action.payload.content;
|
|
}
|
|
}
|
|
return nextLogs;
|
|
};
|
|
|
|
const flushOfflineQueue = useCallback(async () => {
|
|
try {
|
|
const queue = await localforage.getItem<any[]>('sync-queue') || [];
|
|
if (queue.length > 0) {
|
|
await syncOfflineQueueDb(queue);
|
|
await localforage.setItem('sync-queue', []);
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
console.error("Server sync failed, keeping items in offline queue for later.", error);
|
|
}
|
|
return false;
|
|
}, []);
|
|
|
|
const refreshData = useCallback(async (isInitialLoad = false) => {
|
|
if (!isInitialLoad) setIsSyncing(true);
|
|
|
|
if (isInitialLoad) {
|
|
try {
|
|
const cachedSched = await localforage.getItem<any[]>('cachedScheduleData');
|
|
const cachedData = await localforage.getItem<any[]>('cachedAdminData');
|
|
const cachedLogs = await localforage.getItem<Record<string, string>>('cachedDailyLogs');
|
|
|
|
if (cachedData) {
|
|
const { loadedPeriods, loadedAttendance } = mapDbDataToUI(cachedData);
|
|
const finalAttendance = await applyQueueToAttendance(loadedAttendance);
|
|
const finalLogs = await applyQueueToLogs(cachedLogs || {});
|
|
|
|
if (cachedSched) setScheduleData(cachedSched);
|
|
setPeriods(loadedPeriods);
|
|
setAttendance(finalAttendance);
|
|
setDailyLogs(finalLogs);
|
|
|
|
if (loadedPeriods.length > 0) {
|
|
const today = toIsoDate(new Date());
|
|
const activePeriods = loadedPeriods.filter(p => today >= p.startDate && today <= p.endDate);
|
|
if (activePeriods.length > 0) setActivePeriodId(activePeriods[0].id);
|
|
else setActivePeriodId(loadedPeriods[0].id);
|
|
}
|
|
setIsLoadingData(false);
|
|
}
|
|
} catch (e) {
|
|
console.warn("Kunde inte ladda lokal cache", e);
|
|
}
|
|
}
|
|
|
|
const withTimeout = <T>(promise: Promise<T>, ms: number = 5000): Promise<T> => {
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error('NETWORK_TIMEOUT')), ms);
|
|
promise
|
|
.then(val => { clearTimeout(timer); resolve(val); })
|
|
.catch(err => { clearTimeout(timer); reject(err); });
|
|
});
|
|
};
|
|
|
|
try {
|
|
if (navigator.onLine) await flushOfflineQueue();
|
|
|
|
const schedRes = await withTimeout(readJsonFile('schedule.json'));
|
|
if (schedRes.success && schedRes.data) {
|
|
setScheduleData(schedRes.data);
|
|
await localforage.setItem('cachedScheduleData', schedRes.data);
|
|
}
|
|
|
|
const dbPeriods = await withTimeout(getAdminData());
|
|
await localforage.setItem('cachedAdminData', dbPeriods);
|
|
|
|
const dbLogs = await withTimeout(getDailyLogsDb());
|
|
const mappedLogs: Record<string, string> = {};
|
|
dbLogs.forEach((l: { date: string | number; content: string; }) => mappedLogs[l.date] = l.content);
|
|
await localforage.setItem('cachedDailyLogs', mappedLogs);
|
|
|
|
setIsOffline(false);
|
|
|
|
const { loadedPeriods, loadedAttendance } = mapDbDataToUI(dbPeriods);
|
|
const finalAttendance = await applyQueueToAttendance(loadedAttendance);
|
|
const finalLogs = await applyQueueToLogs(mappedLogs);
|
|
|
|
setPeriods(loadedPeriods);
|
|
setAttendance(finalAttendance);
|
|
setDailyLogs(finalLogs);
|
|
|
|
if (isInitialLoad && periods.length === 0 && loadedPeriods.length > 0) {
|
|
const today = toIsoDate(new Date());
|
|
const activePeriods = loadedPeriods.filter(p => today >= p.startDate && today <= p.endDate);
|
|
if (activePeriods.length > 0) setActivePeriodId(activePeriods[0].id);
|
|
else setActivePeriodId(loadedPeriods[0].id);
|
|
}
|
|
} catch (error) {
|
|
console.warn("Nätverkstimeout eller offline - faller tillbaka på cache.", error);
|
|
setIsOffline(true);
|
|
} finally {
|
|
setIsLoadingData(false);
|
|
setIsSyncing(false);
|
|
}
|
|
}, [flushOfflineQueue, periods.length]);
|
|
|
|
useEffect(() => { refreshData(true); }, [refreshData]);
|
|
|
|
useEffect(() => {
|
|
const interval = setInterval(() => { if (navigator.onLine) refreshData(); }, 10000);
|
|
const handleVisibilityChange = () => { if (document.visibilityState === 'visible' && navigator.onLine) refreshData(); };
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
window.addEventListener('focus', handleVisibilityChange);
|
|
return () => { clearInterval(interval); document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('focus', handleVisibilityChange); };
|
|
}, [refreshData]);
|
|
|
|
useEffect(() => {
|
|
const handleOnline = async () => { setIsOffline(false); const didSync = await flushOfflineQueue(); if (didSync) refreshData(); };
|
|
window.addEventListener('online', handleOnline); window.addEventListener('offline', () => setIsOffline(true));
|
|
return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', () => setIsOffline(true)); };
|
|
}, [flushOfflineQueue, refreshData]);
|
|
|
|
const addToOfflineQueue = async (action: any) => {
|
|
const queue = await localforage.getItem<any[]>('sync-queue') || [];
|
|
|
|
let filteredQueue = queue;
|
|
if (action.type === 'SET_DAILY_LOG') {
|
|
filteredQueue = queue.filter(q => !(q.type === 'SET_DAILY_LOG' && q.payload.date === action.payload.date));
|
|
} else if (action.type === 'SET_ATTENDANCE' || action.type === 'REMOVE_ATTENDANCE') {
|
|
filteredQueue = queue.filter(q => !(q.payload.date === action.payload.date && q.payload.youthId === action.payload.youthId && q.payload.shiftId === action.payload.shiftId));
|
|
}
|
|
|
|
filteredQueue.push(action);
|
|
await localforage.setItem('sync-queue', filteredQueue);
|
|
setIsOffline(true);
|
|
};
|
|
|
|
const createPeriod = async (name: string, startDateStr: string) => {
|
|
setIsLoadingData(true);
|
|
const start = new Date(startDateStr + 'T12:00:00');
|
|
const end = new Date(start); end.setDate(start.getDate() + 20);
|
|
const newPeriod = await createPeriodDb(name, toIsoDate(start), toIsoDate(end));
|
|
await refreshData();
|
|
setActivePeriodId(newPeriod.id);
|
|
};
|
|
|
|
const deletePeriod = async (periodId: string) => { setPeriods(periods.filter(p => p.id !== periodId)); await deletePeriodDb(periodId); await refreshData(); };
|
|
|
|
const bulkAddYouth = async (periodId: string, text: string, defaultTeam: 'PF' | 'TU') => {
|
|
setIsLoadingData(true);
|
|
const newYouthData = text.split('\n').map(l => l.trim()).filter(l => l.length > 0).map((line) => {
|
|
const parts = line.split(/[,|-]/).map(p => p.trim());
|
|
return { name: parts[0], team: (parts[1]?.toUpperCase() === 'PF' || parts[1]?.toUpperCase() === 'TU') ? parts[1].toUpperCase() : defaultTeam };
|
|
});
|
|
await bulkAddYouthDb(periodId, newYouthData);
|
|
await refreshData();
|
|
};
|
|
|
|
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(); };
|
|
|
|
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;
|
|
let weightedHours = status === 'Late' || status === 'Present' ? hoursManual * weight : 0;
|
|
let hoursWorked = status === 'Late' || status === 'Present' ? hoursManual : 0;
|
|
const noteStr = note || '';
|
|
|
|
setAttendance(prev => ({ ...prev, [getAttendanceKey(date, youthId, shiftId)]: { date, youthId, shiftId, hoursWorked, weightedHours, status, note: noteStr } }));
|
|
|
|
if (!navigator.onLine) 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 }[]) => {
|
|
setAttendance(prev => { const next = { ...prev }; records.forEach(record => { next[getAttendanceKey(record.date, record.youthId, record.shiftId)] = record; }); return next; });
|
|
|
|
const processOfflineQueue = async () => {
|
|
const queue = await localforage.getItem<any[]>('sync-queue') || [];
|
|
records.forEach(record => {
|
|
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 };
|
|
if (idx > -1) queue[idx] = action; else queue.push(action);
|
|
});
|
|
await localforage.setItem('sync-queue', queue);
|
|
setIsOffline(true);
|
|
};
|
|
|
|
if (!navigator.onLine) await processOfflineQueue();
|
|
else await bulkSetAttendanceDb(records).catch(async () => { await processOfflineQueue(); });
|
|
};
|
|
|
|
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: '' } }));
|
|
|
|
if (!navigator.onLine) 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') => {
|
|
setAttendance(prev => { const next = { ...prev }; delete next[getAttendanceKey(date, youthId, shiftId)]; return next; });
|
|
|
|
if (!navigator.onLine) 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 } }); });
|
|
};
|
|
|
|
const setDailyLog = async (date: string, content: string) => {
|
|
setDailyLogs(prev => ({ ...prev, [date]: content }));
|
|
|
|
if (!navigator.onLine) {
|
|
await addToOfflineQueue({ type: 'SET_DAILY_LOG', payload: { date, content } });
|
|
} else {
|
|
await setDailyLogDb(date, content).catch(async () => {
|
|
await addToOfflineQueue({ type: 'SET_DAILY_LOG', payload: { date, content } });
|
|
});
|
|
}
|
|
};
|
|
|
|
return {
|
|
periods, attendance, activePeriodId, setActivePeriodId, isLoadingData, isOffline, isSyncing, scheduleData,
|
|
dailyLogs, setDailyLog,
|
|
createPeriod, deletePeriod, bulkAddYouth, removeYouth,
|
|
setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry, refreshData
|
|
};
|
|
}; |