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
|
||||
node_modules
|
||||
npm-debug.log
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ generated/
|
||||
*.db-journal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
data/
|
||||
|
||||
|
||||
# PWA / Serwist generated files
|
||||
public/sw.js
|
||||
|
||||
@@ -24,6 +24,7 @@ ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN mkdir -p data
|
||||
RUN mkdir -p public/files
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
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
|
||||
|
||||
"use client";
|
||||
|
||||
import { CalendarRange, CheckCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import scheduleData from '../data/schedule.json';
|
||||
import { type AttendanceDataMap, getAttendanceKey, type Period, toIsoDate } from './adminTypes';
|
||||
import { TeamAttendanceCard } from './TeamAttendanceCard';
|
||||
|
||||
@@ -15,6 +13,7 @@ interface Props {
|
||||
addPendingAttendance: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
|
||||
removeAttendanceEntry: (date: string, youthId: string, shiftId: 'MORNING' | 'AFTERNOON') => void;
|
||||
activePeriodId: string; setActivePeriodId: (id: string) => void;
|
||||
scheduleData: any[];
|
||||
}
|
||||
|
||||
export const getTeamShiftInfo = (daySchedule: any, team: 'PF' | 'TU') => {
|
||||
@@ -33,7 +32,7 @@ const getDaysInPeriod = (start: string, end: string) => {
|
||||
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 daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayNameStr);
|
||||
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 };
|
||||
};
|
||||
|
||||
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 [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 timelineDays = getDaysInPeriod(activePeriod.startDate, activePeriod.endDate);
|
||||
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 timeA = getTeamShiftInfo(daySchedule, a as 'PF' | 'TU').time;
|
||||
@@ -99,7 +98,7 @@ export const AttendanceTab: React.FC<Props> = ({ periods, attendance, setManualA
|
||||
</div>
|
||||
<div className="flex overflow-x-auto gap-2.5 pb-3 pt-1 px-2 scrollbar-hide">
|
||||
{timelineDays.map(day => {
|
||||
const stats = getDailyCompletionStats(day, activePeriod, attendance);
|
||||
const stats = getDailyCompletionStats(day, activePeriod, attendance, scheduleData);
|
||||
const isSelected = day === currentDate;
|
||||
let bgClass = "bg-white text-ebony border-slate-teal/10";
|
||||
if (!stats.hasWork) bgClass = "bg-slate-teal/5 text-ebony/40 border-transparent";
|
||||
|
||||
@@ -9,11 +9,12 @@ import { YouthReportCard } from './YouthReportCard';
|
||||
|
||||
interface Props {
|
||||
periods: Period[]; attendance: AttendanceDataMap; activePeriodId: string; setActivePeriodId: (id: string) => void; currentUserRole: Role;
|
||||
scheduleData: any[];
|
||||
}
|
||||
|
||||
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 [expandedYouthId, setExpandedYouthId] = useState<string | null>(null);
|
||||
|
||||
@@ -109,6 +110,7 @@ export const ReportTab: React.FC<Props> = ({ periods, attendance, activePeriodId
|
||||
isExpanded={expandedYouthId === youth.id}
|
||||
onToggle={() => setExpandedYouthId(expandedYouthId === youth.id ? null : youth.id)}
|
||||
timeline={getTimelineForYouth(youth.id)}
|
||||
scheduleData={scheduleData}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import Image from 'next/image';
|
||||
import React from 'react';
|
||||
import falconIcon from '../assets/falcon.svg';
|
||||
import porpoiseIcon from '../assets/porpoise.svg';
|
||||
import scheduleData from '../data/schedule.json';
|
||||
import { formatTimeHHMM, isWeekend, type Youth } from './adminTypes';
|
||||
import { getTeamShiftInfo } from './AttendanceTab';
|
||||
|
||||
@@ -18,6 +17,7 @@ interface YouthReportCardProps {
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
timeline: any[];
|
||||
scheduleData: any[];
|
||||
}
|
||||
|
||||
const formatHours = (h: number) => Number(h.toFixed(1)).toString();
|
||||
@@ -28,7 +28,8 @@ export const YouthReportCard: React.FC<YouthReportCardProps> = ({
|
||||
potHourLimit,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
timeline
|
||||
timeline,
|
||||
scheduleData
|
||||
}) => {
|
||||
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';
|
||||
|
||||
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">
|
||||
<span className="font-black text-ebony min-w-24">
|
||||
{entry.date} | {capitalizedShortDay}
|
||||
</span>
|
||||
|
||||
{/* 1. Shift Label */}
|
||||
{!isWknd && (
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-teal bg-slate-teal/10 px-2 py-1 rounded-lg">
|
||||
{shiftLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 2. Weekend Tag */}
|
||||
{isWknd && <span className="text-[10px] font-black uppercase tracking-widest text-goldenrod bg-goldenrod/10 px-2 py-1 rounded-lg">Helg</span>}
|
||||
|
||||
{/* 3. Extra Tag (Moved to end, made bolder) */}
|
||||
{isExtra && (
|
||||
<span className="text-[10px] font-black uppercase tracking-widest bg-slate-teal text-eggshell px-2 py-1 rounded-lg shadow-sm">
|
||||
Extra
|
||||
|
||||
+3
-1
@@ -142,7 +142,7 @@ export default function Admin() {
|
||||
</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">
|
||||
<Loader2 size={40} className="animate-spin mb-4" />
|
||||
<p className="font-bold text-sm animate-pulse">Hämtar data...</p>
|
||||
@@ -174,6 +174,7 @@ export default function Admin() {
|
||||
removeAttendanceEntry={adminState.removeAttendanceEntry}
|
||||
activePeriodId={adminState.activePeriodId}
|
||||
setActivePeriodId={adminState.setActivePeriodId}
|
||||
scheduleData={adminState.scheduleData}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'report' && (
|
||||
@@ -183,6 +184,7 @@ export default function Admin() {
|
||||
activePeriodId={adminState.activePeriodId}
|
||||
setActivePeriodId={adminState.setActivePeriodId}
|
||||
currentUserRole={currentUser.role}
|
||||
scheduleData={adminState.scheduleData}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
+41
-113
@@ -2,20 +2,21 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import localforage from 'localforage';
|
||||
import { Period, AttendanceDataMap, Youth, toIsoDate, isWeekend, getAttendanceKey } from './adminTypes';
|
||||
import { getAdminData, createPeriodDb, deletePeriodDb, bulkAddYouthDb, removeYouthDb, setAttendanceDb, removeAttendanceDb, syncOfflineQueueDb, bulkSetAttendanceDb } from '../actions/admin';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
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 = () => {
|
||||
const [periods, setPeriods] = useState<Period[]>([]);
|
||||
const [attendance, setAttendance] = useState<AttendanceDataMap>({});
|
||||
const [scheduleData, setScheduleData] = useState<any[]>([]);
|
||||
const [activePeriodId, setActivePeriodId] = useState<string>('');
|
||||
const [isLoadingData, setIsLoadingData] = useState<boolean>(true);
|
||||
const [isOffline, setIsOffline] = useState<boolean>(false);
|
||||
const [isSyncing, setIsSyncing] = useState<boolean>(false);
|
||||
|
||||
// --- 1. DATA MAPPING ---
|
||||
const mapDbDataToUI = (dbPeriods: any[]) => {
|
||||
const loadedPeriods: Period[] = [];
|
||||
const loadedAttendance: AttendanceDataMap = {};
|
||||
@@ -44,9 +45,7 @@ export const useAdminState = () => {
|
||||
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
|
||||
};
|
||||
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)];
|
||||
@@ -55,7 +54,6 @@ export const useAdminState = () => {
|
||||
return nextAttendance;
|
||||
};
|
||||
|
||||
// --- 2. OFFLINE QUEUE FLUSHER ---
|
||||
const flushOfflineQueue = useCallback(async () => {
|
||||
try {
|
||||
const queue = await localforage.getItem<any[]>('sync-queue') || [];
|
||||
@@ -70,13 +68,17 @@ export const useAdminState = () => {
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// --- 3. MAIN DATA FETCHING ---
|
||||
const refreshData = useCallback(async (isInitialLoad = false) => {
|
||||
if (!isInitialLoad) setIsSyncing(true);
|
||||
|
||||
try {
|
||||
if (navigator.onLine) {
|
||||
await flushOfflineQueue();
|
||||
if (navigator.onLine) 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();
|
||||
@@ -92,24 +94,20 @@ export const useAdminState = () => {
|
||||
if (isInitialLoad && 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);
|
||||
}
|
||||
if (activePeriods.length > 0) setActivePeriodId(activePeriods[0].id);
|
||||
else setActivePeriodId(loadedPeriods[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
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) {
|
||||
const { loadedPeriods, loadedAttendance } = mapDbDataToUI(cachedData);
|
||||
const finalAttendance = await applyQueueToAttendance(loadedAttendance);
|
||||
|
||||
setPeriods(loadedPeriods);
|
||||
setAttendance(finalAttendance);
|
||||
|
||||
if (isInitialLoad && loadedPeriods.length > 0) setActivePeriodId(loadedPeriods[0].id);
|
||||
}
|
||||
} finally {
|
||||
@@ -118,62 +116,30 @@ export const useAdminState = () => {
|
||||
}
|
||||
}, [flushOfflineQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshData(true);
|
||||
}, [refreshData]);
|
||||
useEffect(() => { refreshData(true); }, [refreshData]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (navigator.onLine) refreshData();
|
||||
}, 10000);
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible' && navigator.onLine) {
|
||||
refreshData();
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
return () => { clearInterval(interval); document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('focus', handleVisibilityChange); };
|
||||
}, [refreshData]);
|
||||
|
||||
// --- 4. OFFLINE/ONLINE EVENT LISTENERS ---
|
||||
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));
|
||||
};
|
||||
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') || [];
|
||||
const filteredQueue = queue.filter(q =>
|
||||
!(q.payload.date === action.payload.date &&
|
||||
q.payload.youthId === action.payload.youthId &&
|
||||
q.payload.shiftId === action.payload.shiftId)
|
||||
);
|
||||
|
||||
const 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);
|
||||
};
|
||||
|
||||
// --- 5. DATABASE WRITE ACTIONS ---
|
||||
const createPeriod = async (name: string, startDateStr: string) => {
|
||||
setIsLoadingData(true);
|
||||
const start = new Date(startDateStr + 'T12:00:00');
|
||||
@@ -183,11 +149,7 @@ export const useAdminState = () => {
|
||||
setActivePeriodId(newPeriod.id);
|
||||
};
|
||||
|
||||
const deletePeriod = async (periodId: string) => {
|
||||
setPeriods(periods.filter(p => p.id !== periodId));
|
||||
await deletePeriodDb(periodId);
|
||||
await refreshData();
|
||||
};
|
||||
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);
|
||||
@@ -199,89 +161,55 @@ export const useAdminState = () => {
|
||||
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 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(); };
|
||||
|
||||
// --- 6. ATTENDANCE ACTIONS ---
|
||||
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 }
|
||||
}));
|
||||
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 } });
|
||||
});
|
||||
}
|
||||
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;
|
||||
});
|
||||
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);
|
||||
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();
|
||||
});
|
||||
}
|
||||
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: '' } });
|
||||
});
|
||||
}
|
||||
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 } });
|
||||
});
|
||||
}
|
||||
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 } }); });
|
||||
};
|
||||
|
||||
return {
|
||||
periods, attendance, activePeriodId, setActivePeriodId, isLoadingData, isOffline, isSyncing,
|
||||
periods, attendance, activePeriodId, setActivePeriodId, isLoadingData, isOffline, isSyncing, scheduleData,
|
||||
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";
|
||||
|
||||
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 { 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 { 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() {
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
const [isOffline, setIsOffline] = useState(false);
|
||||
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'done'>('idle');
|
||||
const [cachedFiles, setCachedFiles] = useState<Set<string>>(new Set());
|
||||
const [showNotification, setShowNotification] = useState(false);
|
||||
const [docs, setDocs] = useState<DocumentItem[]>([]);
|
||||
const [links, setLinks] = useState<LinkItem[]>([]);
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
|
||||
const docs: DocumentItem[] = [
|
||||
{ title: 'Turistkarta', icon: <Map size={20} />, file: '/files/Turistkarta - Kullaberg.pdf', size: '2.51 MB' },
|
||||
{ title: 'Orienteringskarta', icon: <MapPlus size={20} />, file: '/files/Orienteringskarta - Kullaberg.pdf', size: '3.74 MB' },
|
||||
{ title: '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 checkCacheStatus = async (currentDocs: DocumentItem[]) => {
|
||||
if (!('caches' in window) || currentDocs.length === 0) return;
|
||||
const cached = new Set<string>();
|
||||
try {
|
||||
for (const doc of docs) {
|
||||
for (const doc of currentDocs) {
|
||||
const response = await caches.match(doc.file);
|
||||
if (response) cached.add(doc.file);
|
||||
}
|
||||
setCachedFiles(cached);
|
||||
if (cached.size === docs.length) setSyncStatus('done');
|
||||
if (cached.size === currentDocs.length) setSyncStatus('done');
|
||||
else setSyncStatus('idle');
|
||||
} catch (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(() => {
|
||||
setIsHydrated(true);
|
||||
setIsOffline(!navigator.onLine);
|
||||
@@ -56,13 +73,13 @@ export default function Documents() {
|
||||
window.addEventListener('online', handleStatus);
|
||||
window.addEventListener('offline', handleStatus);
|
||||
|
||||
checkCacheStatus();
|
||||
loadDynamicData();
|
||||
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === 'visible') checkCacheStatus();
|
||||
if (document.visibilityState === 'visible') checkCacheStatus(docs);
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
window.addEventListener('focus', checkCacheStatus);
|
||||
window.addEventListener('focus', () => checkCacheStatus(docs));
|
||||
|
||||
const conn = (navigator as any).connection;
|
||||
if (conn && (conn.type === 'wifi' || conn.type === 'ethernet') && !conn.saveData) {
|
||||
@@ -73,11 +90,12 @@ export default function Documents() {
|
||||
window.removeEventListener('online', handleStatus);
|
||||
window.removeEventListener('offline', handleStatus);
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
window.removeEventListener('focus', checkCacheStatus);
|
||||
window.removeEventListener('focus', () => checkCacheStatus(docs));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSyncAll = async () => {
|
||||
if (docs.length === 0) return;
|
||||
setSyncStatus('syncing');
|
||||
const updatedCache = new Set(cachedFiles);
|
||||
|
||||
@@ -96,13 +114,13 @@ export default function Documents() {
|
||||
}
|
||||
}
|
||||
|
||||
await checkCacheStatus();
|
||||
await checkCacheStatus(docs);
|
||||
setSyncStatus('done');
|
||||
setShowNotification(true);
|
||||
setTimeout(() => setShowNotification(false), 4000);
|
||||
};
|
||||
|
||||
if (!isHydrated) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-slate-teal" size={40} /></div>;
|
||||
if (!isHydrated || isLoadingData) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-slate-teal" size={40} /></div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in w-full relative">
|
||||
@@ -113,7 +131,7 @@ export default function Documents() {
|
||||
actions={
|
||||
<>
|
||||
{isOffline && <OfflineBadge />}
|
||||
{!isOffline && syncStatus !== 'done' && (
|
||||
{!isOffline && syncStatus !== 'done' && docs.length > 0 && (
|
||||
<button
|
||||
onClick={handleSyncAll}
|
||||
disabled={syncStatus === 'syncing'}
|
||||
@@ -130,14 +148,18 @@ export default function Documents() {
|
||||
<section>
|
||||
<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">
|
||||
{docs.map((doc, idx) => (
|
||||
<DocumentCard
|
||||
key={idx}
|
||||
doc={doc}
|
||||
isOffline={isOffline}
|
||||
isCached={cachedFiles.has(doc.file)}
|
||||
/>
|
||||
))}
|
||||
{docs.map((doc, idx) => {
|
||||
const IconComponent = IconMap[doc.icon as string] || FileText;
|
||||
const renderDoc = { ...doc, icon: <IconComponent size={20} /> };
|
||||
return (
|
||||
<DocumentCard
|
||||
key={idx}
|
||||
doc={renderDoc}
|
||||
isOffline={isOffline}
|
||||
isCached={cachedFiles.has(doc.file)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
+25
-4
@@ -2,20 +2,41 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { MessageCircleQuestionMark } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Loader2, MessageCircleQuestionMark } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { readJsonFile } from '../actions/jsonEditor';
|
||||
import { FAQItem } from '../components/ui/FAQItem';
|
||||
import { PageHeader } from '../components/ui/PageHeader';
|
||||
import { SectionCard } from '../components/ui/SectionCard';
|
||||
import faqData from '../data/faq.json';
|
||||
|
||||
export default function FAQ() {
|
||||
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) => {
|
||||
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 (
|
||||
<div className="w-full animate-fade-in space-y-6">
|
||||
<PageHeader
|
||||
@@ -32,7 +53,7 @@ export default function FAQ() {
|
||||
className="break-inside-avoid mb-4 inline-block w-full"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{section.questions.map((item, qIndex) => {
|
||||
{section.questions.map((item: any, qIndex: number) => {
|
||||
const id = `${sIndex}-${qIndex}`;
|
||||
return (
|
||||
<FAQItem
|
||||
|
||||
+27
-13
@@ -2,15 +2,13 @@
|
||||
|
||||
"use client"
|
||||
|
||||
import { CalendarRange, Clock } from 'lucide-react';
|
||||
import { CalendarRange, Clock, Loader2 } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import React, { useRef } from 'react';
|
||||
import { PageHeader } from '../components/ui/PageHeader';
|
||||
import scheduleData from '../data/schedule.json';
|
||||
|
||||
// Icons
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { readJsonFile } from '../actions/jsonEditor';
|
||||
import falconIcon from '../assets/falcon.svg';
|
||||
import porpoiseIcon from '../assets/porpoise.svg';
|
||||
import { PageHeader } from '../components/ui/PageHeader';
|
||||
|
||||
// --- Helper Functions ---
|
||||
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 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 t = parseTimeBlock(timeStr);
|
||||
if (!t) return null;
|
||||
@@ -89,6 +107,8 @@ export default function Schedule() {
|
||||
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 (
|
||||
<div className="space-y-2 animate-fade-in w-full">
|
||||
<PageHeader
|
||||
@@ -97,13 +117,11 @@ export default function Schedule() {
|
||||
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="flex-1 overflow-auto scrollbar-hide" ref={containerRef}>
|
||||
<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 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` }}>
|
||||
{hours.map((hour, i) => (
|
||||
<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>
|
||||
|
||||
{/* Grid Area */}
|
||||
<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">
|
||||
{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">
|
||||
@@ -126,7 +142,6 @@ export default function Schedule() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main Grid Canvas */}
|
||||
<div className="relative w-full" style={{ height: `${TOTAL_GRID_HEIGHT}px` }}>
|
||||
<div className="absolute inset-0 z-0">
|
||||
{hours.map((hour, i) => (
|
||||
@@ -161,7 +176,6 @@ export default function Schedule() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend with Icons */}
|
||||
<div className="flex items-center justify-center space-x-6 py-2 text-xs font-bold text-ebony">
|
||||
<span className="flex items-center">
|
||||
<div className="w-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