Compare commits
@@ -0,0 +1,41 @@
|
|||||||
|
name: Publish Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
push_to_registry:
|
||||||
|
name: Build and Push Docker image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out the repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.stws.cc
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: git.stws.cc/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=tag
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- name: Build and push Docker images
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
stages:
|
|
||||||
- Build
|
|
||||||
|
|
||||||
"Build Docker":
|
|
||||||
stage: Build
|
|
||||||
image: docker:24.0.5
|
|
||||||
|
|
||||||
before_script:
|
|
||||||
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
|
|
||||||
script:
|
|
||||||
- echo "Building Docker-image..."
|
|
||||||
|
|
||||||
- >
|
|
||||||
docker build
|
|
||||||
--label "org.opencontainers.image.url=$CI_PROJECT_URL"
|
|
||||||
--label "org.opencontainers.image.source=$CI_PROJECT_URL"
|
|
||||||
-t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
|
|
||||||
-t $CI_REGISTRY_IMAGE:latest .
|
|
||||||
|
|
||||||
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
|
|
||||||
- docker push $CI_REGISTRY_IMAGE:latest
|
|
||||||
|
|
||||||
rules:
|
|
||||||
- if: $CI_COMMIT_TAG
|
|
||||||
+18
-3
@@ -23,6 +23,11 @@ export async function getAdminData() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDailyLogsDb() {
|
||||||
|
noStore();
|
||||||
|
return await prisma.dailyLog.findMany();
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 2. PERIOD ACTIONS
|
// 2. PERIOD ACTIONS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -52,7 +57,7 @@ export async function deletePeriodDb(periodId: string) {
|
|||||||
where: { id: periodId }
|
where: { id: periodId }
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true }; // Returning JSON prevents "Unexpected end of JSON input"
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -83,7 +88,7 @@ export async function removeYouthDb(youthId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 4. ATTENDANCE ACTIONS
|
// 4. ATTENDANCE & LOG ACTIONS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
export async function setAttendanceDb(date: string, youthId: string, shiftId: string, hoursWorked: number, weightedHours: number, status: string, note: string) {
|
export async function setAttendanceDb(date: string, youthId: string, shiftId: string, hoursWorked: number, weightedHours: number, status: string, note: string) {
|
||||||
@@ -103,7 +108,6 @@ export async function removeAttendanceDb(date: string, youthId: string, shiftId:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkSetAttendanceDb(records: { date: string; youthId: string; shiftId: string; hoursWorked: number; weightedHours: number; status: string; note: string }[]) {
|
export async function bulkSetAttendanceDb(records: { date: string; youthId: string; shiftId: string; hoursWorked: number; weightedHours: number; status: string; note: string }[]) {
|
||||||
// A Prisma transaction runs all these upserts in a single database round-trip!
|
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
records.map(record =>
|
records.map(record =>
|
||||||
prisma.attendance.upsert({
|
prisma.attendance.upsert({
|
||||||
@@ -116,6 +120,15 @@ export async function bulkSetAttendanceDb(records: { date: string; youthId: stri
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setDailyLogDb(date: string, content: string) {
|
||||||
|
await prisma.dailyLog.upsert({
|
||||||
|
where: { date },
|
||||||
|
update: { content },
|
||||||
|
create: { date, content }
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 5. AUTHENTICATION ACTIONS
|
// 5. AUTHENTICATION ACTIONS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -148,6 +161,8 @@ export async function syncOfflineQueueDb(queue: any[]) {
|
|||||||
await setAttendanceDb(action.payload.date, action.payload.youthId, action.payload.shiftId, action.payload.hoursWorked, action.payload.weightedHours, action.payload.status, action.payload.note);
|
await setAttendanceDb(action.payload.date, action.payload.youthId, action.payload.shiftId, action.payload.hoursWorked, action.payload.weightedHours, action.payload.status, action.payload.note);
|
||||||
} else if (action.type === 'REMOVE_ATTENDANCE') {
|
} else if (action.type === 'REMOVE_ATTENDANCE') {
|
||||||
await removeAttendanceDb(action.payload.date, action.payload.youthId, action.payload.shiftId);
|
await removeAttendanceDb(action.payload.date, action.payload.youthId, action.payload.shiftId);
|
||||||
|
} else if (action.type === 'SET_DAILY_LOG') {
|
||||||
|
await setDailyLogDb(action.payload.date, action.payload.content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// app/actions/files.ts
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import fs from 'fs';
|
||||||
|
import fsPromises 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");
|
||||||
|
const FILES_DIR = path.join(DATA_DIR, "files");
|
||||||
|
|
||||||
|
const generateFileHash = (filePath: string): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const hash = crypto.createHash('md5');
|
||||||
|
const stream = fs.createReadStream(filePath);
|
||||||
|
|
||||||
|
stream.on('error', (err) => reject(err));
|
||||||
|
stream.on('data', (chunk) => hash.update(chunk));
|
||||||
|
stream.on('end', () => resolve(hash.digest('hex').substring(0, 8)));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getLocalFileMeta(fileUrl: string) {
|
||||||
|
try {
|
||||||
|
const cleanName = decodeURIComponent(fileUrl.split('/').pop() || '');
|
||||||
|
const fullPath = path.join(FILES_DIR, cleanName);
|
||||||
|
const stats = await fsPromises.stat(fullPath);
|
||||||
|
const sizeMb = (stats.size / (1024 * 1024)).toFixed(2) + ' MB';
|
||||||
|
const fileHash = await generateFileHash(fullPath);
|
||||||
|
|
||||||
|
return { size: sizeMb, version: fileHash };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Kunde inte läsa filen (actions): ${fileUrl}`, error);
|
||||||
|
return { size: "Okänd", version: "v1" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// app/admin/LogTab.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { NotebookText, CalendarRange, CheckCircle, ChevronLeft, ChevronRight, Save, AlertTriangle, FileText, Download } from 'lucide-react';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { type Period, toIsoDate } from './adminTypes';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
periods: Period[];
|
||||||
|
dailyLogs: Record<string, string>;
|
||||||
|
setDailyLog: (date: string, content: string) => void;
|
||||||
|
activePeriodId: string;
|
||||||
|
setActivePeriodId: (id: string) => void;
|
||||||
|
scheduleData: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDaysInPeriod = (start: string, end: string) => {
|
||||||
|
const days = []; let curr = new Date(start + 'T12:00:00'); const endDate = new Date(end + 'T12:00:00');
|
||||||
|
while (curr <= endDate) { days.push(toIsoDate(curr)); curr.setDate(curr.getDate() + 1); }
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LogTab: React.FC<Props> = ({ periods, dailyLogs, setDailyLog, activePeriodId, setActivePeriodId, scheduleData }) => {
|
||||||
|
const activePeriod = periods.find(p => p.id === activePeriodId);
|
||||||
|
|
||||||
|
const [currentDate, setCurrentDate] = useState<string>(() => {
|
||||||
|
const today = toIsoDate(new Date());
|
||||||
|
if (activePeriod && today >= activePeriod.startDate && today <= activePeriod.endDate) return today;
|
||||||
|
return activePeriod ? activePeriod.startDate : today;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [currentText, setCurrentText] = useState("");
|
||||||
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved'>('idle');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentText(dailyLogs[currentDate] || "");
|
||||||
|
setSaveStatus('idle');
|
||||||
|
}, [currentDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveStatus === 'idle' && currentText === (dailyLogs[currentDate] || "")) {
|
||||||
|
setCurrentText(dailyLogs[currentDate] || "");
|
||||||
|
}
|
||||||
|
}, [dailyLogs, currentDate]);
|
||||||
|
|
||||||
|
if (!activePeriod) return <p className="text-center font-bold text-slate-teal mt-10">Ingen period aktiv.</p>;
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setDailyLog(currentDate, currentText);
|
||||||
|
setSaveStatus('saved');
|
||||||
|
setTimeout(() => setSaveStatus('idle'), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeDate = (days: number) => {
|
||||||
|
const newDateObj = new Date(currentDate + 'T12:00:00'); newDateObj.setDate(newDateObj.getDate() + days);
|
||||||
|
const startObj = new Date(activePeriod.startDate + 'T12:00:00'); const endObj = new Date(activePeriod.endDate + 'T12:00:00');
|
||||||
|
if (newDateObj >= startObj && newDateObj <= endObj) setCurrentDate(toIsoDate(newDateObj));
|
||||||
|
};
|
||||||
|
|
||||||
|
const dayNameStr = new Date(currentDate + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' });
|
||||||
|
const timelineDays = getDaysInPeriod(activePeriod.startDate, activePeriod.endDate);
|
||||||
|
const todayIso = toIsoDate(new Date());
|
||||||
|
|
||||||
|
const exportToCsv = () => {
|
||||||
|
if (!activePeriod) return;
|
||||||
|
let csvContent = "Datum;Logg\n";
|
||||||
|
const sortedDays = Object.keys(dailyLogs).sort((a, b) => a.localeCompare(b));
|
||||||
|
sortedDays.forEach(date => {
|
||||||
|
if (date >= activePeriod.startDate && date <= activePeriod.endDate) {
|
||||||
|
const logContent = dailyLogs[date] || "";
|
||||||
|
const cleanContent = logContent
|
||||||
|
.replace(/\n/g, " ")
|
||||||
|
.replace(/;/g, ",")
|
||||||
|
.replace(/"/g, '""');
|
||||||
|
|
||||||
|
csvContent += `${date};"${cleanContent}"\n`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new Blob(["\uFEFF" + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.setAttribute("href", url);
|
||||||
|
link.setAttribute("download", `Logg_${activePeriod.name.replace(/ /g, '_')}.csv`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* Datumskrollare likt AttendanceTab */}
|
||||||
|
<div className="rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h2 className="text-sm font-black text-ebony uppercase tracking-widest flex items-center mb-2">
|
||||||
|
<CalendarRange className="mr-2 text-slate-teal" size={20} /> Journalöversikt
|
||||||
|
</h2>
|
||||||
|
<select value={activePeriodId} onChange={(e) => setActivePeriodId(e.target.value)} className="bg-white border border-slate-teal/10 py-1.5 px-3 rounded-xl font-bold text-slate-teal text-xs cursor-pointer focus:outline-none shadow-sm">
|
||||||
|
{periods.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex overflow-x-auto gap-2.5 pb-3 pt-1 px-2 scrollbar-hide">
|
||||||
|
{timelineDays.map(day => {
|
||||||
|
const dayName = new Date(day + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase();
|
||||||
|
const daySchedule = scheduleData.find(d => d.day.toLowerCase() === dayName);
|
||||||
|
const hasWork = daySchedule && ((daySchedule.pilgrimsfalkarna?.time && daySchedule.pilgrimsfalkarna.time !== 'Ledig') || (daySchedule.tumlarna?.time && daySchedule.tumlarna.time !== 'Ledig'));
|
||||||
|
|
||||||
|
const hasLog = !!dailyLogs[day] && dailyLogs[day].trim() !== "";
|
||||||
|
const isSelected = day === currentDate;
|
||||||
|
|
||||||
|
let bgClass = "bg-white text-ebony border-slate-teal/10";
|
||||||
|
if (!hasWork) bgClass = "bg-slate-teal/5 text-ebony/40 border-transparent";
|
||||||
|
else if (hasLog) bgClass = "bg-moss text-white border-moss shadow-inner";
|
||||||
|
else if (day <= todayIso) bgClass = "bg-goldenrod text-white border-goldenrod shadow-inner";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button key={day} onClick={() => setCurrentDate(day)} className={`flex flex-col items-center justify-center min-w-14 p-2 rounded-xl border transition-colors ${bgClass} ${isSelected ? 'ring-2 ring-slate-teal ring-offset-2 ring-offset-eggshell' : 'hover:brightness-95 shadow-sm'}`}>
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-wider">{new Date(day + 'T12:00:00').toLocaleDateString('sv-SE', { weekday: 'short' })}</span>
|
||||||
|
<span className="text-sm font-black">{new Date(day + 'T12:00:00').getDate()}/{new Date(day + 'T12:00:00').getMonth() + 1}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row justify-between items-center rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md gap-4">
|
||||||
|
<button onClick={() => changeDate(-1)} className="p-3 text-slate-teal bg-slate-teal/10 hover:bg-slate-teal/30 rounded-2xl transition-colors"><ChevronLeft size={24} /></button>
|
||||||
|
<div className="text-center flex-1">
|
||||||
|
<h2 className="text-lg font-black text-ebony capitalize mb-0.5">{dayNameStr}</h2>
|
||||||
|
<p className="text-[11px] font-black uppercase tracking-widest text-moss">{currentDate}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => changeDate(1)} className="p-3 text-slate-teal bg-slate-teal/10 hover:bg-slate-teal/30 rounded-2xl transition-colors"><ChevronRight size={24} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skrivyta */}
|
||||||
|
<div className="rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md">
|
||||||
|
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center mb-6">
|
||||||
|
<div className="bg-white p-2.5 rounded-xl mr-3 text-slate-teal shadow-sm">
|
||||||
|
<NotebookText size={24} />
|
||||||
|
</div>
|
||||||
|
Daglig Journal
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={currentText}
|
||||||
|
onChange={(e) => setCurrentText(e.target.value)}
|
||||||
|
placeholder="Vad har hänt idag? Något trasigt staket? Spännande djurobservation? Sur turist?"
|
||||||
|
className="w-full h-48 bg-white border border-slate-teal/10 p-4 rounded-2xl text-sm font-medium resize-none focus:outline-none focus:border-slate-teal shadow-sm mb-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
{saveStatus === 'saved' && <span className="text-xs font-bold text-moss flex items-center"><CheckCircle size={14} className="mr-1" /> Sparat!</span>}
|
||||||
|
{dailyLogs[currentDate] && currentText !== dailyLogs[currentDate] && <span className="text-xs font-bold text-goldenrod flex items-center"><AlertTriangle size={14} className="mr-1" /> Osparade ändringar</span>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="bg-slate-teal text-eggshell font-black uppercase tracking-widest text-xs px-6 py-3 rounded-xl hover:bg-ebony transition-colors shadow-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Save size={16} /> Spara
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex flex-row justify-center items-center p-3 sm:p-5">
|
||||||
|
<button onClick={exportToCsv} className="flex items-center justify-center gap-2 bg-seafoam text-eggshell font-black uppercase tracking-widest text-[10px] px-5 py-3 rounded-xl hover:bg-slate-teal transition-colors shadow-sm">
|
||||||
|
<Download size={16} /> Exportera Journal till Excel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+55
-6
@@ -19,34 +19,83 @@ export const NoticeTab = ({ isOffline }: { isOffline: boolean }) => {
|
|||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
const fetchNotice = async () => {
|
const fetchNotice = async () => {
|
||||||
const res = await readJsonFile('notice.json');
|
const cached = localStorage.getItem('admin_notice_cache');
|
||||||
if (res.success && res.data) {
|
if (cached) {
|
||||||
setNotice(res.data);
|
setNotice(JSON.parse(cached));
|
||||||
}
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchWithTimeout = new Promise<any>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error('Timeout')), 5000);
|
||||||
|
readJsonFile('notice.json').then(res => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(res);
|
||||||
|
}).catch(err => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.onLine) {
|
||||||
|
const res = await fetchWithTimeout;
|
||||||
|
if (res.success && res.data && isMounted) {
|
||||||
|
setNotice(res.data);
|
||||||
|
localStorage.setItem('admin_notice_cache', JSON.stringify(res.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Kunde inte hämta färsk notice.json (Liar-Fi), använder cache.");
|
||||||
|
} finally {
|
||||||
|
if (isMounted) setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchNotice();
|
fetchNotice();
|
||||||
|
return () => { isMounted = false; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setSaveStatus('idle');
|
setSaveStatus('idle');
|
||||||
const res = await writeJsonFile('notice.json', notice);
|
|
||||||
|
try {
|
||||||
|
const saveWithTimeout = new Promise<any>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error('Timeout')), 8000);
|
||||||
|
writeJsonFile('notice.json', notice).then(res => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(res);
|
||||||
|
}).catch(err => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await saveWithTimeout;
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setSaveStatus('success');
|
setSaveStatus('success');
|
||||||
|
localStorage.setItem('admin_notice_cache', JSON.stringify(notice));
|
||||||
setTimeout(() => setSaveStatus('idle'), 3000);
|
setTimeout(() => setSaveStatus('idle'), 3000);
|
||||||
} else {
|
} else {
|
||||||
setSaveStatus('error');
|
setSaveStatus('error');
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Liar-Fi: Sparande tog för lång tid", error);
|
||||||
|
setSaveStatus('error');
|
||||||
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-slate-teal" size={32} /></div>;
|
if (isLoading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-slate-teal" size={32} /></div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
<div className="bg-eggshell border border-slate-teal/10 p-5 md:p-8 rounded-3xl shadow-sm">
|
<div className="rounded-3xl shadow-sm transition-all duration-300 bg-white/40 backdrop-blur-md border border-white/40 p-3 sm:p-5 hover:shadow-md">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center">
|
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center">
|
||||||
<div className="bg-white p-2.5 rounded-xl mr-3 text-slate-teal shadow-sm">
|
<div className="bg-white p-2.5 rounded-xl mr-3 text-slate-teal shadow-sm">
|
||||||
|
|||||||
+80
-10
@@ -2,15 +2,16 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CalendarRange, ClipboardCheck, Loader2, Lock, Unlock, Users as UsersIcon, BellRing } from 'lucide-react';
|
import { CalendarRange, ClipboardCheck, Loader2, Lock, Unlock, Users as UsersIcon, BellRing, NotebookText } from 'lucide-react';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { verifyLogin } from '../actions/admin';
|
import { verifyLogin } from '../actions/admin';
|
||||||
import { OfflineBadge } from '../components/ui/OfflineBadge';
|
import { OfflineBadge } from '../components/ui/OfflineBadge';
|
||||||
import { AppUser } from './adminTypes';
|
import { AppUser, toIsoDate, getAttendanceKey } from './adminTypes';
|
||||||
import { AttendanceTab } from './AttendanceTab';
|
import { AttendanceTab, getTeamShiftInfo } from './AttendanceTab';
|
||||||
import { ReportTab } from './ReportTab';
|
import { ReportTab } from './ReportTab';
|
||||||
import { SetupTab } from './SetupTab';
|
import { SetupTab } from './SetupTab';
|
||||||
import { NoticeTab } from './NoticeTab';
|
import { NoticeTab } from './NoticeTab';
|
||||||
|
import { LogTab } from './LogTab';
|
||||||
import { useAdminState } from './useAdminState';
|
import { useAdminState } from './useAdminState';
|
||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
@@ -23,7 +24,7 @@ export default function Admin() {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [loginError, setLoginError] = useState(false);
|
const [loginError, setLoginError] = useState(false);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'setup' | 'notice' | 'today' | 'report'>('report');
|
const [activeTab, setActiveTab] = useState<'setup' | 'notice' | 'today' | 'log' | 'report'>('report');
|
||||||
const adminState = useAdminState();
|
const adminState = useAdminState();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -121,10 +122,59 @@ export default function Admin() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SMART NOTIS-LOGIK FÖR BÅDE DAGBOK OCH NÄRVARO
|
||||||
|
let missingLogsCount = 0;
|
||||||
|
let missingAttendanceCount = 0;
|
||||||
|
|
||||||
|
if (adminState.periods.length > 0 && adminState.scheduleData.length > 0) {
|
||||||
|
const todayIso = toIsoDate(new Date());
|
||||||
|
const activePeriod = adminState.periods.find(p => p.id === adminState.activePeriodId) || adminState.periods[0];
|
||||||
|
|
||||||
|
let curr = new Date(activePeriod.startDate + 'T12:00:00');
|
||||||
|
const end = new Date((activePeriod.endDate < todayIso ? activePeriod.endDate : todayIso) + 'T12:00:00');
|
||||||
|
|
||||||
|
while (curr <= end) {
|
||||||
|
const dateStr = toIsoDate(curr);
|
||||||
|
const dayName = curr.toLocaleDateString('sv-SE', { weekday: 'long' }).toLowerCase();
|
||||||
|
const daySchedule = adminState.scheduleData.find(d => d.day.toLowerCase() === dayName);
|
||||||
|
|
||||||
|
if (daySchedule) {
|
||||||
|
const pfWork = daySchedule.pilgrimsfalkarna?.time && daySchedule.pilgrimsfalkarna.time !== 'Ledig';
|
||||||
|
const tuWork = daySchedule.tumlarna?.time && daySchedule.tumlarna.time !== 'Ledig';
|
||||||
|
|
||||||
|
if (pfWork || tuWork) {
|
||||||
|
// 1. Kolla Dagboken
|
||||||
|
const hasLog = !!adminState.dailyLogs?.[dateStr] && adminState.dailyLogs[dateStr].trim() !== "";
|
||||||
|
if (!hasLog) missingLogsCount++;
|
||||||
|
|
||||||
|
// 2. Kolla Närvaron
|
||||||
|
if (pfWork) {
|
||||||
|
const { shiftId } = getTeamShiftInfo(daySchedule, 'PF');
|
||||||
|
activePeriod.youthList.filter(y => y.team === 'PF').forEach(y => {
|
||||||
|
const entry = adminState.attendance[getAttendanceKey(dateStr, y.id, shiftId)];
|
||||||
|
if (!entry || entry.status === 'Pending') missingAttendanceCount++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (tuWork) {
|
||||||
|
const { shiftId } = getTeamShiftInfo(daySchedule, 'TU');
|
||||||
|
activePeriod.youthList.filter(y => y.team === 'TU').forEach(y => {
|
||||||
|
const entry = adminState.attendance[getAttendanceKey(dateStr, y.id, shiftId)];
|
||||||
|
if (!entry || entry.status === 'Pending') missingAttendanceCount++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
curr.setDate(curr.getDate() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const availableTabs = [];
|
const availableTabs = [];
|
||||||
if (currentUser.role === 'Admin') availableTabs.push({ id: 'setup', icon: CalendarRange, label: 'Perioder' });
|
if (currentUser.role === 'Admin') availableTabs.push({ id: 'setup', icon: CalendarRange, label: 'Perioder' });
|
||||||
if (currentUser.role === 'Admin' || currentUser.role === 'Staff') availableTabs.push({ id: 'notice', icon: BellRing, label: 'Notis' });
|
if (currentUser.role === 'Admin' || currentUser.role === 'Staff') availableTabs.push({ id: 'notice', icon: BellRing, label: 'Notis' });
|
||||||
if (currentUser.role === 'Admin' || currentUser.role === 'Staff') availableTabs.push({ id: 'today', icon: ClipboardCheck, label: 'Närvaro' });
|
if (currentUser.role === 'Admin' || currentUser.role === 'Staff') {
|
||||||
|
availableTabs.push({ id: 'today', icon: ClipboardCheck, label: 'Närvaro', badge: missingAttendanceCount });
|
||||||
|
availableTabs.push({ id: 'log', icon: NotebookText, label: 'Journal', badge: missingLogsCount });
|
||||||
|
}
|
||||||
availableTabs.push({ id: 'report', icon: UsersIcon, label: 'Rapport' });
|
availableTabs.push({ id: 'report', icon: UsersIcon, label: 'Rapport' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -152,16 +202,26 @@ export default function Admin() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{availableTabs.length > 1 && (
|
{availableTabs.length > 1 && (
|
||||||
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
|
<div className="flex gap-2 overflow-x-auto scrollbar-hide pt-3 pb-2 px-1 -mx-1">
|
||||||
{availableTabs.map(tab => (
|
{availableTabs.map(tab => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
const isActive = activeTab === tab.id;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id as any)}
|
onClick={() => setActiveTab(tab.id as any)}
|
||||||
className={`flex items-center px-4 py-2.5 rounded-lg font-bold text-xs uppercase tracking-widest transition-colors ${activeTab === tab.id ? 'bg-slate-teal text-eggshell' : 'bg-white/60 text-slate-teal hover:bg-slate-teal/10'}`}
|
className={`relative flex items-center px-4 py-2.5 rounded-lg font-bold text-xs uppercase tracking-widest transition-colors ${isActive ? 'bg-slate-teal text-eggshell' : 'bg-white/60 text-slate-teal hover:bg-slate-teal/10'}`}
|
||||||
>
|
>
|
||||||
<tab.icon size={16} className="mr-2 hidden md:block shrink-0" /> {tab.label}
|
<Icon size={16} className="mr-2 hidden md:block shrink-0" /> {tab.label}
|
||||||
|
|
||||||
|
{!!tab.badge && tab.badge > 0 && (
|
||||||
|
<div className="absolute -top-1.5 -right-1.5 bg-emergency text-white text-[10px] font-black w-5 h-5 flex items-center justify-center rounded-full shadow-sm ring-[1.5px] ring-white">
|
||||||
|
{tab.badge}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -180,6 +240,16 @@ export default function Admin() {
|
|||||||
scheduleData={adminState.scheduleData}
|
scheduleData={adminState.scheduleData}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'log' && (currentUser.role === 'Admin' || currentUser.role === 'Staff') && (
|
||||||
|
<LogTab
|
||||||
|
periods={adminState.periods}
|
||||||
|
dailyLogs={adminState.dailyLogs}
|
||||||
|
setDailyLog={adminState.setDailyLog}
|
||||||
|
activePeriodId={adminState.activePeriodId}
|
||||||
|
setActivePeriodId={adminState.setActivePeriodId}
|
||||||
|
scheduleData={adminState.scheduleData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{activeTab === 'report' && (
|
{activeTab === 'report' && (
|
||||||
<ReportTab
|
<ReportTab
|
||||||
periods={adminState.periods}
|
periods={adminState.periods}
|
||||||
|
|||||||
+86
-18
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import localforage from 'localforage';
|
import localforage from 'localforage';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { bulkAddYouthDb, bulkSetAttendanceDb, createPeriodDb, deletePeriodDb, getAdminData, removeAttendanceDb, removeYouthDb, setAttendanceDb, syncOfflineQueueDb } from '../actions/admin';
|
import { bulkAddYouthDb, bulkSetAttendanceDb, createPeriodDb, deletePeriodDb, getAdminData, removeAttendanceDb, removeYouthDb, setAttendanceDb, syncOfflineQueueDb, getDailyLogsDb, setDailyLogDb } from '../actions/admin';
|
||||||
import { readJsonFile } from '../actions/jsonEditor';
|
import { readJsonFile } from '../actions/jsonEditor';
|
||||||
import { AttendanceDataMap, Period, Youth, getAttendanceKey, isWeekend, toIsoDate } from './adminTypes';
|
import { AttendanceDataMap, Period, Youth, getAttendanceKey, isWeekend, toIsoDate } from './adminTypes';
|
||||||
|
|
||||||
@@ -12,6 +12,8 @@ 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 [scheduleData, setScheduleData] = useState<any[]>([]);
|
||||||
|
const [dailyLogs, setDailyLogs] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
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);
|
||||||
@@ -54,6 +56,17 @@ export const useAdminState = () => {
|
|||||||
return nextAttendance;
|
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 () => {
|
const flushOfflineQueue = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const queue = await localforage.getItem<any[]>('sync-queue') || [];
|
const queue = await localforage.getItem<any[]>('sync-queue') || [];
|
||||||
@@ -71,50 +84,85 @@ export const useAdminState = () => {
|
|||||||
const refreshData = useCallback(async (isInitialLoad = false) => {
|
const refreshData = useCallback(async (isInitialLoad = false) => {
|
||||||
if (!isInitialLoad) setIsSyncing(true);
|
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 {
|
try {
|
||||||
if (navigator.onLine) await flushOfflineQueue();
|
if (navigator.onLine) await flushOfflineQueue();
|
||||||
|
|
||||||
// NYTT: Hämta schemat via filsystemet
|
const schedRes = await withTimeout(readJsonFile('schedule.json'));
|
||||||
const schedRes = await readJsonFile('schedule.json');
|
|
||||||
if (schedRes.success && schedRes.data) {
|
if (schedRes.success && schedRes.data) {
|
||||||
setScheduleData(schedRes.data);
|
setScheduleData(schedRes.data);
|
||||||
await localforage.setItem('cachedScheduleData', schedRes.data);
|
await localforage.setItem('cachedScheduleData', schedRes.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbPeriods = await getAdminData();
|
const dbPeriods = await withTimeout(getAdminData());
|
||||||
await localforage.setItem('cachedAdminData', dbPeriods);
|
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);
|
setIsOffline(false);
|
||||||
|
|
||||||
const { loadedPeriods, loadedAttendance } = mapDbDataToUI(dbPeriods);
|
const { loadedPeriods, loadedAttendance } = mapDbDataToUI(dbPeriods);
|
||||||
const finalAttendance = await applyQueueToAttendance(loadedAttendance);
|
const finalAttendance = await applyQueueToAttendance(loadedAttendance);
|
||||||
|
const finalLogs = await applyQueueToLogs(mappedLogs);
|
||||||
|
|
||||||
setPeriods(loadedPeriods);
|
setPeriods(loadedPeriods);
|
||||||
setAttendance(finalAttendance);
|
setAttendance(finalAttendance);
|
||||||
|
setDailyLogs(finalLogs);
|
||||||
|
|
||||||
if (isInitialLoad && loadedPeriods.length > 0) {
|
if (isInitialLoad && periods.length === 0 && 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) setActivePeriodId(activePeriods[0].id);
|
||||||
else setActivePeriodId(loadedPeriods[0].id);
|
else setActivePeriodId(loadedPeriods[0].id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.warn("Nätverkstimeout eller offline - faller tillbaka på cache.", error);
|
||||||
setIsOffline(true);
|
setIsOffline(true);
|
||||||
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 {
|
} finally {
|
||||||
setIsLoadingData(false);
|
setIsLoadingData(false);
|
||||||
setIsSyncing(false);
|
setIsSyncing(false);
|
||||||
}
|
}
|
||||||
}, [flushOfflineQueue]);
|
}, [flushOfflineQueue, periods.length]);
|
||||||
|
|
||||||
useEffect(() => { refreshData(true); }, [refreshData]);
|
useEffect(() => { refreshData(true); }, [refreshData]);
|
||||||
|
|
||||||
@@ -134,7 +182,14 @@ export const useAdminState = () => {
|
|||||||
|
|
||||||
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 => !(q.payload.date === action.payload.date && q.payload.youthId === action.payload.youthId && q.payload.shiftId === action.payload.shiftId));
|
|
||||||
|
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);
|
filteredQueue.push(action);
|
||||||
await localforage.setItem('sync-queue', filteredQueue);
|
await localforage.setItem('sync-queue', filteredQueue);
|
||||||
setIsOffline(true);
|
setIsOffline(true);
|
||||||
@@ -207,8 +262,21 @@ export const useAdminState = () => {
|
|||||||
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 } }); });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
periods, attendance, activePeriodId, setActivePeriodId, isLoadingData, isOffline, isSyncing, scheduleData,
|
periods, attendance, activePeriodId, setActivePeriodId, isLoadingData, isOffline, isSyncing, scheduleData,
|
||||||
|
dailyLogs, setDailyLog,
|
||||||
createPeriod, deletePeriod, bulkAddYouth, removeYouth,
|
createPeriod, deletePeriod, bulkAddYouth, removeYouth,
|
||||||
setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry, refreshData
|
setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry, refreshData
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// app/components/ui/EmergencyButton.tsx
|
||||||
|
|
||||||
|
import { ChevronRight, Siren } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface EmergencyButtonProps {
|
||||||
|
href: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmergencyButton({ href, title }: EmergencyButtonProps) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="group flex items-center justify-between w-full bg-emergency text-eggshell px-5 py-4 rounded-2xl shadow-md hover:shadow-lg hover:brightness-110 active:scale-[0.98] transition-all duration-300 border border-emergency/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-white/20 p-2 rounded-xl group-hover:bg-white/30 transition-colors">
|
||||||
|
{/* Ikonen pulserar mjukt för att direkt fånga uppmärksamheten */}
|
||||||
|
<Siren size={24} className="animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-base sm:text-lg font-black uppercase tracking-widest drop-shadow-sm">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<ChevronRight
|
||||||
|
size={24}
|
||||||
|
className="opacity-70 group-hover:opacity-100 group-hover:translate-x-1 transition-all"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
+20
-6
@@ -3,32 +3,42 @@
|
|||||||
{
|
{
|
||||||
"title": "Turistkarta",
|
"title": "Turistkarta",
|
||||||
"icon": "Map",
|
"icon": "Map",
|
||||||
"file": "/files/Turistkarta - Kullaberg.pdf"
|
"file": "Turistkarta - Kullaberg.pdf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Orienteringskarta",
|
"title": "Orienteringskarta",
|
||||||
"icon": "MapPlus",
|
"icon": "MapPlus",
|
||||||
"file": "/files/Orienteringskarta - Kullaberg.pdf"
|
"file": "Orienteringskarta - Kullaberg.pdf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Badplatser att besöka",
|
"title": "Badplatser att besöka",
|
||||||
"icon": "WavesLadder",
|
"icon": "WavesLadder",
|
||||||
"file": "/files/Badplatser på Kullaberg.pdf"
|
"file": "Badplatser på Kullaberg.pdf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Vandringsrutter",
|
"title": "Vandringsrutter",
|
||||||
"icon": "MapPinned",
|
"icon": "MapPinned",
|
||||||
"file": "/files/Vandringsrutter.pdf"
|
"file": "Vandringsrutter.pdf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Underlag för guidediplomering",
|
"title": "Underlag för guidediplomering",
|
||||||
"icon": "BookCopy",
|
"icon": "BookCopy",
|
||||||
"file": "/files/Underlag för guidediplomering.pdf"
|
"file": "Underlag för guidediplomering.pdf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Destinationskunskap Kullahalvön",
|
"title": "Destinationskunskap Kullahalvön",
|
||||||
"icon": "BookCopy",
|
"icon": "BookCopy",
|
||||||
"file": "/files/Destinationskunskap.pdf"
|
"file": "Destinationskunskap.pdf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Tjänstgöringsrapport",
|
||||||
|
"icon": "BookCopy",
|
||||||
|
"file": "Tjänstgöringsrapport 2026.pdf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Information om ditt sommarjobb",
|
||||||
|
"icon": "BookCopy",
|
||||||
|
"file": "Information om ditt sommarjobb 2026.pdf"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
@@ -48,6 +58,10 @@
|
|||||||
"title": "Skåneleden",
|
"title": "Skåneleden",
|
||||||
"url": "https://www.skaneleden.se/en"
|
"url": "https://www.skaneleden.se/en"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"title": "RSNV Brandriskprognos",
|
||||||
|
"url": "https://rsnv.se/brandriskprognos/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"title": "Väderprognos Mölle",
|
"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"
|
"url": "https://www.smhi.se/vader/prognoser-och-varningar/vaderprognos/q/H%C3%B6gan%C3%A4s/M%C3%B6lle/2691501"
|
||||||
|
|||||||
@@ -92,6 +92,10 @@
|
|||||||
{
|
{
|
||||||
"q": "Var parkerar man om det är fullt?",
|
"q": "Var parkerar man om det är fullt?",
|
||||||
"a": "Ransviks övre parkering."
|
"a": "Ransviks övre parkering."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "Är det tillåtet att tälta här?",
|
||||||
|
"a": "Nej, förr fanns det en tältruta här men den ligger nu uppe vid stora parkeringen"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"isActive": true,
|
"isActive": true,
|
||||||
"type": "important",
|
"type": "warning",
|
||||||
"message": "Glöm inte minst 1 liter vatten, solkräm och myggmedel. Det förväntas bli mycket varmt idag!"
|
"message": "Glöm inte minst 1 liter vatten, solkräm och myggmedel. Det förväntas bli mycket varmt idag!"
|
||||||
}
|
}
|
||||||
+15
-23
@@ -8,6 +8,7 @@ import { readJsonFile } from '../actions/jsonEditor';
|
|||||||
import { DocumentCard, DocumentItem, ExternalLinkCard, LinkItem } from '../components/ui/Cards';
|
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';
|
||||||
|
import { getLocalFileMeta } from '../actions/files';
|
||||||
|
|
||||||
const IconMap: Record<string, any> = {
|
const IconMap: Record<string, any> = {
|
||||||
"Map": Map,
|
"Map": Map,
|
||||||
@@ -18,20 +19,6 @@ const IconMap: Record<string, any> = {
|
|||||||
"FileText": FileText
|
"FileText": FileText
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchFileSize = async (url: string) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, { method: 'HEAD' });
|
|
||||||
const bytes = res.headers.get('content-length');
|
|
||||||
if (bytes) {
|
|
||||||
const mb = (parseInt(bytes) / (1024 * 1024)).toFixed(2);
|
|
||||||
return `${mb} MB`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Kunde inte hämta filstorlek för", url);
|
|
||||||
}
|
|
||||||
return "Okänd";
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
||||||
@@ -62,22 +49,27 @@ export default function Documents() {
|
|||||||
if (navigator.onLine) {
|
if (navigator.onLine) {
|
||||||
const res = await readJsonFile('documents.json');
|
const res = await readJsonFile('documents.json');
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
const docsWithSizes = await Promise.all(
|
const docsWithMeta = await Promise.all(
|
||||||
(res.data.docs || []).map(async (doc: DocumentItem) => {
|
(res.data.docs || []).map(async (doc: DocumentItem) => {
|
||||||
if (!doc.size || doc.size === "") {
|
const meta = await getLocalFileMeta(doc.file);
|
||||||
const calculatedSize = await fetchFileSize(doc.file);
|
const fileNameOnly = doc.file.split('/').pop();
|
||||||
return { ...doc, size: calculatedSize };
|
const versionedUrl = `/files/${fileNameOnly}?v=${meta.version}`;
|
||||||
}
|
|
||||||
return doc;
|
return {
|
||||||
|
...doc,
|
||||||
|
size: (!doc.size || doc.size === "Okänd") ? meta.size : doc.size,
|
||||||
|
originalFile: doc.file,
|
||||||
|
file: versionedUrl
|
||||||
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
setDocs(docsWithSizes);
|
setDocs(docsWithMeta);
|
||||||
setLinks(res.data.links || []);
|
setLinks(res.data.links || []);
|
||||||
const dataToCache = { ...res.data, docs: docsWithSizes };
|
const dataToCache = { ...res.data, docs: docsWithMeta };
|
||||||
localStorage.setItem('kullaberg_documents_cache', JSON.stringify(dataToCache));
|
localStorage.setItem('kullaberg_documents_cache', JSON.stringify(dataToCache));
|
||||||
|
|
||||||
checkCacheStatus(docsWithSizes);
|
checkCacheStatus(docsWithMeta);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const cachedData = localStorage.getItem('kullaberg_documents_cache');
|
const cachedData = localStorage.getItem('kullaberg_documents_cache');
|
||||||
|
|||||||
+131
-11
@@ -1,34 +1,154 @@
|
|||||||
// app/emergency/page.tsx
|
// app/emergency/page.tsx
|
||||||
|
|
||||||
import { AlertTriangle, FileText, HeartPulse, MapPin, Phone } from 'lucide-react';
|
"use client";
|
||||||
|
|
||||||
|
import { AlertTriangle, CheckCircle, Crosshair, FileText, HeartPulse, Loader2, MapPin, PhoneCall, XCircle, Navigation } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
import { SafePhoneLink } from '../components/PhoneLinks';
|
import { SafePhoneLink } from '../components/PhoneLinks';
|
||||||
import { PageHeader } from '../components/ui/PageHeader';
|
import { PageHeader } from '../components/ui/PageHeader';
|
||||||
import { SectionCard } from '../components/ui/SectionCard';
|
import { SectionCard } from '../components/ui/SectionCard';
|
||||||
|
|
||||||
export default function Emergency() {
|
export default function Emergency() {
|
||||||
|
const [confirmCall, setConfirmCall] = useState(false);
|
||||||
|
const [location, setLocation] = useState<{ lat: number, lng: number, acc: number } | null>(null);
|
||||||
|
const [locLoading, setLocLoading] = useState(false);
|
||||||
|
const [locError, setLocError] = useState("");
|
||||||
|
|
||||||
|
const getLocation = () => {
|
||||||
|
setLocLoading(true);
|
||||||
|
setLocError("");
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
setLocError("Din enhet saknar stöd för GPS.");
|
||||||
|
setLocLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
setLocation({
|
||||||
|
lat: pos.coords.latitude,
|
||||||
|
lng: pos.coords.longitude,
|
||||||
|
acc: Math.round(pos.coords.accuracy) // Noggrannhet i meter
|
||||||
|
});
|
||||||
|
setLocLoading(false);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
setLocError("Kunde inte hämta plats. Kontrollera att GPS är aktiverat i telefonen.");
|
||||||
|
setLocLoading(false);
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in w-full mx-auto">
|
<div className="space-y-6 animate-fade-in w-full mx-auto">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Vid Nödsituation"
|
title="Vid Nödsituation"
|
||||||
icon={AlertTriangle}
|
icon={AlertTriangle}
|
||||||
description="Agera lugnt, stanna kvar på platsen och tillkalla hjälp."
|
description="Agera lugnt, stanna kvar på platsen och tillkalla hjälp."
|
||||||
variant="emergency" // <-- Sets the red colors and uppercase
|
variant="emergency"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 112 Card */}
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
variant="alert"
|
variant="alert"
|
||||||
title="Ring 112"
|
title="Ring 112"
|
||||||
icon={Phone}
|
icon={PhoneCall}
|
||||||
description="Vid olycka, brand eller livshotande tillstånd. Berätta vem du är och vad som har hänt."
|
description="Vid olycka, brand eller livshotande tillstånd. Berätta vem du är och vad som har hänt."
|
||||||
>
|
>
|
||||||
<div className="bg-white/60 p-4 rounded-xl border border-emergency/20">
|
<div className="space-y-4">
|
||||||
<h3 className="font-bold text-emergency flex items-center mb-1 text-xs uppercase tracking-wider">
|
|
||||||
<MapPin className="mr-2" size={14} /> Uppge din position
|
{/* SÄKER 112-KNAPP */}
|
||||||
|
<div className="bg-white/40 p-2 rounded-2xl border border-emergency/20">
|
||||||
|
{!confirmCall ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmCall(true)}
|
||||||
|
className="w-full flex items-center justify-center gap-3 bg-emergency text-white font-black uppercase tracking-widest text-lg py-4 rounded-xl shadow-sm hover:brightness-110 active:scale-[0.98] transition-all"
|
||||||
|
>
|
||||||
|
<PhoneCall size={24} className="animate-pulse" />
|
||||||
|
Ring 112
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 animate-fade-in">
|
||||||
|
<a
|
||||||
|
href="tel:112"
|
||||||
|
onClick={() => setTimeout(() => setConfirmCall(false), 2000)} // Återställ efter klick
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 bg-emergency text-white font-black uppercase tracking-widest text-lg py-4 rounded-xl shadow-md hover:brightness-110 active:scale-[0.98] transition-all ring-4 ring-emergency/30"
|
||||||
|
>
|
||||||
|
<CheckCircle size={24} />
|
||||||
|
Ja, ring nu
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmCall(false)}
|
||||||
|
className="sm:w-1/3 flex items-center justify-center gap-2 bg-white text-ebony font-bold uppercase tracking-widest text-sm py-4 rounded-xl shadow-sm border border-emergency/20 hover:bg-eggshell transition-all"
|
||||||
|
>
|
||||||
|
<XCircle size={18} />
|
||||||
|
Avbryt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GPS OCH POSITION */}
|
||||||
|
<div className="bg-white/60 p-4 md:p-5 rounded-2xl border border-emergency/20">
|
||||||
|
<h3 className="font-black text-emergency flex items-center mb-2 text-xs uppercase tracking-widest">
|
||||||
|
<MapPin className="mr-2" size={16} /> Uppge din position
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-ebony font-medium text-sm">
|
<p className="text-ebony font-medium text-sm leading-relaxed mb-4">
|
||||||
Använd appen <strong>112</strong> eller GPS. Säg att du befinner dig i Kullabergs Naturreservat. Var specifik (t.ex. "Nära fyren" eller "Vid Josefinelust").
|
Säg att du befinner dig i Kullabergs Naturreservat. Var specifik (t.ex. "Nära fyren" eller "Vid Josefinelust"). Minns du närmsta räddningspunkt?
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-white p-3 rounded-xl border border-emergency/10 shadow-sm">
|
||||||
|
{location ? (
|
||||||
|
<div className="space-y-1 animate-fade-in">
|
||||||
|
<p className="text-xs font-bold text-slate-teal uppercase tracking-widest">Dina koordinater (WGS84)</p>
|
||||||
|
<p className="font-mono text-lg font-black text-ebony tracking-tight">
|
||||||
|
{location.lat.toFixed(5)}, {location.lng.toFixed(5)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] font-bold text-ebony/50 uppercase">
|
||||||
|
Noggrannhet: ca {location.acc} meter
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mt-4 pt-3 border-t border-slate-teal/5">
|
||||||
|
<a
|
||||||
|
href={`https://maps.google.com/?q=${location.lat},${location.lng}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-1.5 bg-seafoam/10 text-slate-teal px-3 py-1.5 rounded-lg text-xs font-bold hover:bg-seafoam/20 transition-colors"
|
||||||
|
>
|
||||||
|
<MapPin size={14} />
|
||||||
|
Google Maps
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`http://maps.apple.com/?ll=${location.lat},${location.lng}&q=Min+Position`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-1.5 bg-seafoam/10 text-slate-teal px-3 py-1.5 rounded-lg text-xs font-bold hover:bg-seafoam/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Navigation size={14} />
|
||||||
|
Apple Maps
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={getLocation}
|
||||||
|
disabled={locLoading}
|
||||||
|
className="w-full flex items-center justify-center gap-2 bg-emergency/10 text-emergency hover:bg-emergency/20 font-bold uppercase tracking-widest text-xs py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{locLoading ? <Loader2 size={16} className="animate-spin" /> : <Crosshair size={16} />}
|
||||||
|
{locLoading ? "Söker satelliter..." : "Hämta min exakta position"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{locError && (
|
||||||
|
<p className="text-xs font-bold text-emergency mt-3 animate-fade-in flex items-start gap-1.5">
|
||||||
|
<AlertTriangle size={14} className="shrink-0" />
|
||||||
|
{locError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
@@ -45,7 +165,7 @@ export default function Emergency() {
|
|||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="bg-seafoam text-eggshell font-bold px-2 py-0.5 rounded mr-3 text-xs shrink-0">2</span>
|
<span className="bg-seafoam text-eggshell font-bold px-2 py-0.5 rounded mr-3 text-xs shrink-0">2</span>
|
||||||
<span>Finns hjärtstartare? Ja, närmaste hjärtstartare finns inne på <strong className="text-ebony font-black">Naturum Kullaberg</strong> (vid fyren) under deras öppettider.</span>
|
<span>Finns hjärtstartare? Ja, närmaste hjärtstartare finns utanför <strong className="text-ebony font-black">Naturum</strong> (vid fyren) eller vid <strong className="text-ebony font-black">golfbanans klubbhus</strong>.</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -74,6 +194,6 @@ export default function Emergency() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</div>
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// app/files/[...slug]/route.ts
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
const DATA_DIR = isProd ? "/app/data" : path.join(process.cwd(), "app/data");
|
||||||
|
const FILES_DIR = path.join(DATA_DIR, "files");
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug: string[] }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const filename = decodeURIComponent(resolvedParams.slug[resolvedParams.slug.length - 1]);
|
||||||
|
const filePath = path.join(FILES_DIR, filename);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.error(`404 - Filen finns inte på disk: ${filePath}`);
|
||||||
|
return new NextResponse('Filen hittades inte', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBuffer = fs.readFileSync(filePath);
|
||||||
|
|
||||||
|
let contentType = 'application/pdf';
|
||||||
|
if (filename.toLowerCase().endsWith('.png')) contentType = 'image/png';
|
||||||
|
else if (filename.toLowerCase().endsWith('.jpg') || filename.toLowerCase().endsWith('.jpeg')) contentType = 'image/jpeg';
|
||||||
|
else if (filename.toLowerCase().endsWith('.doc') || filename.toLowerCase().endsWith('.docx')) contentType = 'application/msword';
|
||||||
|
|
||||||
|
return new NextResponse(fileBuffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Content-Disposition': `inline; filename="${filename}"`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fel vid fildelning:", error);
|
||||||
|
return new NextResponse('Ett internt serverfel uppstod', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-23
@@ -6,6 +6,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { readJsonFile } from './actions/jsonEditor';
|
import { readJsonFile } from './actions/jsonEditor';
|
||||||
import { SafePhoneLink } from './components/PhoneLinks';
|
import { SafePhoneLink } from './components/PhoneLinks';
|
||||||
import { ActionLinkCard } from './components/ui/ActionLinkCard';
|
import { ActionLinkCard } from './components/ui/ActionLinkCard';
|
||||||
|
import { EmergencyButton } from './components/ui/EmergencyButton';
|
||||||
import { SectionCard } from './components/ui/SectionCard';
|
import { SectionCard } from './components/ui/SectionCard';
|
||||||
|
|
||||||
const getNoticeConfig = (type: string) => {
|
const getNoticeConfig = (type: string) => {
|
||||||
@@ -25,20 +26,43 @@ export default function Home() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
const loadNotice = async () => {
|
const loadNotice = async () => {
|
||||||
|
const cached = localStorage.getItem('kullaberg_notice_cache');
|
||||||
|
if (cached) {
|
||||||
|
setNotice(JSON.parse(cached));
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchWithTimeout = new Promise<any>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error('Timeout')), 5000);
|
||||||
|
readJsonFile('notice.json').then(res => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(res);
|
||||||
|
}).catch(err => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
if (navigator.onLine) {
|
if (navigator.onLine) {
|
||||||
const res = await readJsonFile('notice.json');
|
const res = await fetchWithTimeout;
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data && isMounted) {
|
||||||
setNotice(res.data);
|
setNotice(res.data);
|
||||||
localStorage.setItem('kullaberg_notice_cache', JSON.stringify(res.data));
|
localStorage.setItem('kullaberg_notice_cache', JSON.stringify(res.data));
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const cached = localStorage.getItem('kullaberg_notice_cache');
|
|
||||||
if (cached) setNotice(JSON.parse(cached));
|
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
} catch (error) {
|
||||||
|
console.warn("Kunde inte hämta nytt meddelande (Liar-Fi), behåller cache.");
|
||||||
|
} finally {
|
||||||
|
if (isMounted) setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadNotice();
|
loadNotice();
|
||||||
|
return () => { isMounted = false; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const nConfig = notice ? getNoticeConfig(notice.type) : null;
|
const nConfig = notice ? getNoticeConfig(notice.type) : null;
|
||||||
@@ -65,26 +89,17 @@ export default function Home() {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quick Action Cards now use the glass style */}
|
{/* NÖDKNAPPEN */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<EmergencyButton href="/emergency" title="Nödsituation" />
|
||||||
<ActionLinkCard
|
|
||||||
href="/schedule"
|
|
||||||
title="Ditt Schema"
|
|
||||||
description="Kolla dina arbetspass och dagliga rundor snabbt."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActionLinkCard
|
{/* Quick Action Cards */}
|
||||||
href="/faq"
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
title="Vanliga Frågor"
|
<ActionLinkCard href="/schedule" title="Ditt Schema" description="Kolla dina arbetspass och dagliga rundor snabbt." />
|
||||||
description="Snabba svar på turisternas vanligaste funderingar."
|
<ActionLinkCard href="/faq" title="Vanliga Frågor" description="Snabba svar på turisternas vanligaste funderingar." />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact Card updated to 'glass' variant */}
|
{/* Contact Card */}
|
||||||
<SectionCard
|
<SectionCard title="Snabbkontakt" icon={PhoneCall}>
|
||||||
title="Snabbkontakt"
|
|
||||||
icon={PhoneCall}
|
|
||||||
>
|
|
||||||
<div className="space-y-4 pt-2">
|
<div className="space-y-4 pt-2">
|
||||||
<div className="flex flex-col md:flex-row md:justify-between md:items-center pb-4 border-b border-white gap-2">
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center pb-4 border-b border-white gap-2">
|
||||||
<span className="text-sm font-bold text-ebony">William Söderberg</span>
|
<span className="text-sm font-bold text-ebony">William Söderberg</span>
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ const withSerwist = withSerwistInit({
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
allowedDevOrigins: [
|
||||||
|
'10.10.0.121',
|
||||||
|
'10.11.0.122',
|
||||||
|
'localhost',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withSerwist(nextConfig);
|
export default withSerwist(nextConfig);
|
||||||
Generated
+567
-506
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
// prisma.config.ts
|
// prisma.config.ts
|
||||||
|
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { defineConfig, env } from "prisma/config";
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: "prisma/schema.prisma",
|
schema: "prisma/schema.prisma",
|
||||||
@@ -9,6 +9,6 @@ export default defineConfig({
|
|||||||
path: "prisma/migrations",
|
path: "prisma/migrations",
|
||||||
},
|
},
|
||||||
datasource: {
|
datasource: {
|
||||||
url: env("DATABASE_URL"),
|
url: process.env.DATABASE_URL,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -53,3 +53,10 @@ model Attendance {
|
|||||||
// A youth can only have one specific shift record per day
|
// A youth can only have one specific shift record per day
|
||||||
@@unique([date, youthId, shiftId])
|
@@unique([date, youthId, shiftId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model DailyLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
date String @unique
|
||||||
|
content String
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user