4 Commits
14 changed files with 1085 additions and 543 deletions
+18 -3
View File
@@ -23,6 +23,11 @@ export async function getAdminData() {
});
}
export async function getDailyLogsDb() {
noStore();
return await prisma.dailyLog.findMany();
}
// ==========================================
// 2. PERIOD ACTIONS
// ==========================================
@@ -52,7 +57,7 @@ export async function deletePeriodDb(periodId: string) {
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) {
@@ -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 }[]) {
// A Prisma transaction runs all these upserts in a single database round-trip!
await prisma.$transaction(
records.map(record =>
prisma.attendance.upsert({
@@ -116,6 +120,15 @@ export async function bulkSetAttendanceDb(records: { date: string; youthId: stri
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
// ==========================================
@@ -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);
} else if (action.type === 'REMOVE_ATTENDANCE') {
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 };
+173
View File
@@ -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>
);
};
+1 -1
View File
@@ -46,7 +46,7 @@ export const NoticeTab = ({ isOffline }: { isOffline: boolean }) => {
return (
<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">
<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">
+80 -10
View File
@@ -2,15 +2,16 @@
"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 { verifyLogin } from '../actions/admin';
import { OfflineBadge } from '../components/ui/OfflineBadge';
import { AppUser } from './adminTypes';
import { AttendanceTab } from './AttendanceTab';
import { AppUser, toIsoDate, getAttendanceKey } from './adminTypes';
import { AttendanceTab, getTeamShiftInfo } from './AttendanceTab';
import { ReportTab } from './ReportTab';
import { SetupTab } from './SetupTab';
import { NoticeTab } from './NoticeTab';
import { LogTab } from './LogTab';
import { useAdminState } from './useAdminState';
export default function Admin() {
@@ -23,7 +24,7 @@ export default function Admin() {
const [isLoading, setIsLoading] = 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();
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 = [];
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: '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' });
return (
@@ -152,16 +202,26 @@ export default function Admin() {
) : (
<>
{availableTabs.length > 1 && (
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
{availableTabs.map(tab => (
<div className="flex gap-2 overflow-x-auto scrollbar-hide pt-3 pb-2 px-1 -mx-1">
{availableTabs.map(tab => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
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>
))}
);
})}
</div>
)}
@@ -180,6 +240,16 @@ export default function Admin() {
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' && (
<ReportTab
periods={adminState.periods}
+52 -3
View File
@@ -4,7 +4,7 @@
import localforage from 'localforage';
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 { AttendanceDataMap, Period, Youth, getAttendanceKey, isWeekend, toIsoDate } from './adminTypes';
@@ -12,6 +12,8 @@ export const useAdminState = () => {
const [periods, setPeriods] = useState<Period[]>([]);
const [attendance, setAttendance] = useState<AttendanceDataMap>({});
const [scheduleData, setScheduleData] = useState<any[]>([]);
const [dailyLogs, setDailyLogs] = useState<Record<string, string>>({}); // NYTT
const [activePeriodId, setActivePeriodId] = useState<string>('');
const [isLoadingData, setIsLoadingData] = useState<boolean>(true);
const [isOffline, setIsOffline] = useState<boolean>(false);
@@ -54,6 +56,18 @@ export const useAdminState = () => {
return nextAttendance;
};
// NYTT: Hantera offfline-kö för dagböcker
const applyQueueToLogs = async (baseLogs: Record<string, string>) => {
const queue = await localforage.getItem<any[]>('sync-queue') || [];
const nextLogs = { ...baseLogs };
for (const action of queue) {
if (action.type === 'SET_DAILY_LOG') {
nextLogs[action.payload.date] = action.payload.content;
}
}
return nextLogs;
};
const flushOfflineQueue = useCallback(async () => {
try {
const queue = await localforage.getItem<any[]>('sync-queue') || [];
@@ -74,7 +88,6 @@ export const useAdminState = () => {
try {
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);
@@ -83,13 +96,22 @@ export const useAdminState = () => {
const dbPeriods = await getAdminData();
await localforage.setItem('cachedAdminData', dbPeriods);
// NYTT: Ladda in loggarna
const dbLogs = await getDailyLogsDb();
const mappedLogs: Record<string, string> = {};
dbLogs.forEach((l: { date: string | number; content: string; }) => mappedLogs[l.date] = l.content);
await localforage.setItem('cachedDailyLogs', mappedLogs);
setIsOffline(false);
const { loadedPeriods, loadedAttendance } = mapDbDataToUI(dbPeriods);
const finalAttendance = await applyQueueToAttendance(loadedAttendance);
const finalLogs = await applyQueueToLogs(mappedLogs);
setPeriods(loadedPeriods);
setAttendance(finalAttendance);
setDailyLogs(finalLogs);
if (isInitialLoad && loadedPeriods.length > 0) {
const today = toIsoDate(new Date());
@@ -108,6 +130,11 @@ export const useAdminState = () => {
const finalAttendance = await applyQueueToAttendance(loadedAttendance);
setPeriods(loadedPeriods);
setAttendance(finalAttendance);
const cachedLogs = await localforage.getItem<Record<string, string>>('cachedDailyLogs') || {};
const finalLogs = await applyQueueToLogs(cachedLogs);
setDailyLogs(finalLogs);
if (isInitialLoad && loadedPeriods.length > 0) setActivePeriodId(loadedPeriods[0].id);
}
} finally {
@@ -134,7 +161,15 @@ export const useAdminState = () => {
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));
// Undvik dubbletter för just detta datum och denna åtgärd
let filteredQueue = queue;
if (action.type === 'SET_DAILY_LOG') {
filteredQueue = queue.filter(q => !(q.type === 'SET_DAILY_LOG' && q.payload.date === action.payload.date));
} else if (action.type === 'SET_ATTENDANCE' || action.type === 'REMOVE_ATTENDANCE') {
filteredQueue = queue.filter(q => !(q.payload.date === action.payload.date && q.payload.youthId === action.payload.youthId && q.payload.shiftId === action.payload.shiftId));
}
filteredQueue.push(action);
await localforage.setItem('sync-queue', filteredQueue);
setIsOffline(true);
@@ -207,8 +242,22 @@ export const useAdminState = () => {
else await removeAttendanceDb(date, youthId, shiftId).catch(async () => { await addToOfflineQueue({ type: 'REMOVE_ATTENDANCE', payload: { date, youthId, shiftId } }); });
};
// NYTT: Funktion för att spara dagbok lokalt och i databasen
const setDailyLog = async (date: string, content: string) => {
setDailyLogs(prev => ({ ...prev, [date]: content }));
if (!navigator.onLine) {
await addToOfflineQueue({ type: 'SET_DAILY_LOG', payload: { date, content } });
} else {
await setDailyLogDb(date, content).catch(async () => {
await addToOfflineQueue({ type: 'SET_DAILY_LOG', payload: { date, content } });
});
}
};
return {
periods, attendance, activePeriodId, setActivePeriodId, isLoadingData, isOffline, isSyncing, scheduleData,
dailyLogs, setDailyLog, // <-- Expotera här
createPeriod, deletePeriod, bulkAddYouth, removeYouth,
setManualAttendance, bulkSetManualAttendance, addPendingAttendance, removeAttendanceEntry, refreshData
};
+32
View File
@@ -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>
);
}
+4
View File
@@ -92,6 +92,10 @@
{
"q": "Var parkerar man om det är fullt?",
"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"
}
]
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"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!"
}
+131 -11
View File
@@ -1,34 +1,154 @@
// 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 { PageHeader } from '../components/ui/PageHeader';
import { SectionCard } from '../components/ui/SectionCard';
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 (
<div className="space-y-6 animate-fade-in w-full mx-auto">
<PageHeader
title="Vid Nödsituation"
icon={AlertTriangle}
description="Agera lugnt, stanna kvar på platsen och tillkalla hjälp."
variant="emergency" // <-- Sets the red colors and uppercase
variant="emergency"
/>
{/* 112 Card */}
<SectionCard
variant="alert"
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."
>
<div className="bg-white/60 p-4 rounded-xl border border-emergency/20">
<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
<div className="space-y-4">
{/* 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>
<p className="text-ebony font-medium text-sm">
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").
<p className="text-ebony font-medium text-sm leading-relaxed mb-4">
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>
<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>
</SectionCard>
@@ -45,7 +165,7 @@ export default function Emergency() {
</li>
<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>Finns hjärtstartare? Ja, närmaste hjärtstartare finns inne <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>
</ul>
</SectionCard>
@@ -74,6 +194,6 @@ export default function Emergency() {
</div>
</div>
</SectionCard>
</div>
</div >
);
}
+7
View File
@@ -6,6 +6,7 @@ import { useEffect, useState } from 'react';
import { readJsonFile } from './actions/jsonEditor';
import { SafePhoneLink } from './components/PhoneLinks';
import { ActionLinkCard } from './components/ui/ActionLinkCard';
import { EmergencyButton } from './components/ui/EmergencyButton';
import { SectionCard } from './components/ui/SectionCard';
const getNoticeConfig = (type: string) => {
@@ -65,6 +66,12 @@ export default function Home() {
)
)}
{/* NÖDKNAPPEN - Ligger utanför griddet så den alltid är fullbredd och i fokus */}
<EmergencyButton
href="/emergency"
title="Nödsituation"
/>
{/* Quick Action Cards now use the glass style */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ActionLinkCard
+4
View File
@@ -21,6 +21,10 @@ const withSerwist = withSerwistInit({
const nextConfig: NextConfig = {
output: "standalone",
allowedDevOrigins: [
'10.10.0.121',
'localhost',
],
};
export default withSerwist(nextConfig);
+567 -506
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,6 +9,6 @@ export default defineConfig({
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
url: process.env.DATABASE_URL,
},
});
+7
View File
@@ -53,3 +53,10 @@ model Attendance {
// A youth can only have one specific shift record per day
@@unique([date, youthId, shiftId])
}
model DailyLog {
id String @id @default(cuid())
date String @unique
content String
updatedAt DateTime @updatedAt
}